feat: add /map, robots.txt enforcement, and m3u96a deployment
- frontier: RFC 9309 robots.txt parser + matcher (longest-match, *,$), 24h cache with 5xx=disallow-1h, recursive sitemap/index discovery, mapSite() merging sitemaps + homepage links + /llms.txt (§10.1, §14.3) - scrape: enforce robots.txt by default (respectRobots), ERR_ROBOTS_DENIED (§26) - api: POST /v1/map, GET /v1/status, @fastify/compress (br/gzip), graceful shutdown; workspace packages now expose dist/ for production node execution - deploy: PM2 ecosystem (loopback API + ngrok www.ten-dril.com), ngrok.yml, scripts/deploy.sh + health.sh, deployed to m3u96a:8092 (§16) 96 tests green. Ingress blocked only on reserving www.ten-dril.com in the ngrok dashboard (DNS CNAME already live). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 28 changed files with +1,077 and −20
modified
README.md
+25 −3
@@ -22,12 +22,17 @@ what currently exists and is tested: | ||
| 22 | 22 | | Extraction: structured-data harvest | §8.3 | ✅ `packages/extract` | |
| 23 | 23 | | Extraction: boilerplate (Readability + density fallback) | §8.2 | ✅ | |
| 24 | 24 | | Extraction: Turndown rules (tables, code, figures, dl…) | §8.6 | ✅ | |
| 25 | −| `POST /v1/scrape` + `/healthz` | §14.1 | ✅ `apps/api` | | |
| 25 | +| robots.txt parsing + enforcement (RFC 9309) | §10.1, §26 | ✅ `packages/frontier` | | |
| 26 | +| Sitemap discovery / `POST /v1/map` | §14.3 | ✅ `packages/frontier` | | |
| 27 | +| `POST /v1/scrape`, `/v1/status`, `/healthz` | §14.1 | ✅ `apps/api` | | |
| 28 | +| Deployment: m3u96a + PM2 + ngrok | §16 | ✅ `scripts/deploy.sh`, `deploy/` | | |
| 29 | + | |
| 30 | +86 unit/contract tests, all hermetic (no network). | |
| 26 | 31 | |
| 27 | 32 | **Not yet built** (later phases): Tier 1 WKWebView daemon (§4), Tier 2 Safari |
| 28 | 33 | (§5), proxy layer (§6), profiles (§7), selector/inference extraction (§8.4–8.5), |
| 29 | −non-HTML formats (§9), crawl frontier (§10), cache (§11), Postgres/Redis/BullMQ | |
| 30 | −(§12–13), `/crawl` `/map` `/search` `/extract`, deployment (§16), SDKs (§22). | |
| 34 | +non-HTML formats (§9), full crawl frontier (§10), cache (§11), Postgres/Redis/BullMQ | |
| 35 | +(§12–13), `/crawl` `/search` `/extract`, SDKs (§22). | |
| 31 | 36 | |
| 32 | 37 | Because Tiers 1–2 do not exist yet, a page that `shouldEscalate` flags under |
| 33 | 38 | `tier: "auto"` **fails closed** with `ERR_TARGET_BLOCKED` and the decisive |
@@ -44,10 +49,27 @@ packages/router shouldEscalate (§3.4) | ||
| 44 | 49 | packages/egress SSRF validation + IP classification (§16.5) |
| 45 | 50 | packages/fetcher-http Tier 0 undici client (§3) |
| 46 | 51 | packages/extract deterministic pipeline (§8): structured, boilerplate, markdown |
| 52 | +packages/frontier robots.txt (§10.1), sitemaps, /map discovery (§14.3) | |
| 47 | 53 | apps/api Fastify public surface (§14) |
| 54 | +deploy/ PM2 ecosystem, ngrok config (§16) | |
| 55 | +scripts/ deploy.sh, health.sh (§16.4) | |
| 48 | 56 | test/fixtures/html saved pages for offline extraction tests (§20) |
| 49 | 57 | ``` |
| 50 | 58 | |
| 59 | +## Deploy | |
| 60 | + | |
| 61 | +Production host is `m3u96a` (§16), ingress via ngrok reserved domain | |
| 62 | +`www.ten-dril.com`. The API binds to `127.0.0.1` only; ngrok is the sole ingress. | |
| 63 | + | |
| 64 | +```bash | |
| 65 | +TENDRIL_NODE=M3U96a TENDRIL_PORT=8092 ./scripts/deploy.sh # rsync + install + build + PM2 + smoke | |
| 66 | +./scripts/health.sh https://www.ten-dril.com # smoke an existing deployment | |
| 67 | +``` | |
| 68 | + | |
| 69 | +The ngrok domain must be reserved once at | |
| 70 | +`https://dashboard.ngrok.com/domains/new` (the DNS CNAME is already in place); | |
| 71 | +`tendril-ngrok` connects automatically once it is. | |
| 72 | + | |
| 51 | 73 | ## Develop |
| 52 | 74 | |
| 53 | 75 | ```bash |
modified
apps/api/package.json
+2 −0
@@ -14,6 +14,8 @@ | ||
| 14 | 14 | "@tendril/egress": "workspace:*", |
| 15 | 15 | "@tendril/fetcher-http": "workspace:*", |
| 16 | 16 | "@tendril/extract": "workspace:*", |
| 17 | + "@tendril/frontier": "workspace:*", | |
| 18 | + "@fastify/compress": "^8.0.1", | |
| 17 | 19 | "fastify": "^5.2.0", |
| 18 | 20 | "zod": "^3.24.1" |
| 19 | 21 | } |
modified
apps/api/src/app.ts
+43 −1
@@ -1,9 +1,11 @@ | ||
| 1 | 1 | // author: simon-pierre boucher <contact@spboucher.ai> |
| 2 | 2 | import { randomUUID } from "node:crypto"; |
| 3 | 3 | import Fastify, { type FastifyInstance } from "fastify"; |
| 4 | +import compress from "@fastify/compress"; | |
| 4 | 5 | import { tendrilError } from "@tendril/shared"; |
| 6 | +import { mapSite } from "@tendril/frontier"; | |
| 5 | 7 | import { toApiError } from "./errors.js"; |
| 6 | −import { ScrapeRequestSchema } from "./schemas.js"; | |
| 8 | +import { MapRequestSchema, ScrapeRequestSchema } from "./schemas.js"; | |
| 7 | 9 | import { runScrape } from "./scrape.js"; |
| 8 | 10 | |
| 9 | 11 | function requestId(): string { |
@@ -17,6 +19,8 @@ export function buildApp(): FastifyInstance { | ||
| 17 | 19 | bodyLimit: 2 * 1024 * 1024, |
| 18 | 20 | }); |
| 19 | 21 | |
| 22 | + app.register(compress, { global: true, encodings: ["br", "gzip", "deflate"], threshold: 1024 }); | |
| 23 | + | |
| 20 | 24 | app.addHook("onSend", async (req, reply) => { |
| 21 | 25 | reply.header("X-Request-Id", req.id); |
| 22 | 26 | }); |
@@ -46,6 +50,44 @@ export function buildApp(): FastifyInstance { | ||
| 46 | 50 | return reply.status(200).send({ success: true, data: result.value, requestId: req.id }); |
| 47 | 51 | }); |
| 48 | 52 | |
| 53 | + app.post("/v1/map", async (req, reply) => { | |
| 54 | + const parsed = MapRequestSchema.safeParse(req.body); | |
| 55 | + if (!parsed.success) { | |
| 56 | + const { status, body } = toApiError( | |
| 57 | + tendrilError("ERR_INVALID_URL", { | |
| 58 | + message: "Invalid request body", | |
| 59 | + details: { issues: parsed.error.issues.map((i) => ({ path: i.path, message: i.message })) }, | |
| 60 | + }), | |
| 61 | + req.id, | |
| 62 | + ); | |
| 63 | + return reply.status(status).send(body); | |
| 64 | + } | |
| 65 | + | |
| 66 | + const { url, search, limit, includeSubdomains, sitemapOnly, ignoreSitemap } = parsed.data; | |
| 67 | + const result = await mapSite(url, { | |
| 68 | + ...(search !== undefined ? { search } : {}), | |
| 69 | + limit, | |
| 70 | + includeSubdomains, | |
| 71 | + sitemapOnly, | |
| 72 | + ignoreSitemap, | |
| 73 | + }); | |
| 74 | + if (!result.ok) { | |
| 75 | + const { status, body } = toApiError(result.error, req.id); | |
| 76 | + return reply.status(status).send(body); | |
| 77 | + } | |
| 78 | + return reply | |
| 79 | + .status(200) | |
| 80 | + .send({ success: true, data: { links: result.value.links, count: result.value.links.length }, requestId: req.id }); | |
| 81 | + }); | |
| 82 | + | |
| 83 | + app.get("/v1/status", async () => ({ | |
| 84 | + success: true, | |
| 85 | + data: { | |
| 86 | + tiers: { http: { available: true }, webkit: { available: false }, safari: { available: false } }, | |
| 87 | + pipelineVersion: "0.1.0", | |
| 88 | + }, | |
| 89 | + })); | |
| 90 | + | |
| 49 | 91 | app.setErrorHandler((error, req, reply) => { |
| 50 | 92 | req.log.error({ err: error }, "unhandled error"); |
| 51 | 93 | const { status, body } = toApiError( |
modified
apps/api/src/main.ts
+10 −0
@@ -6,6 +6,16 @@ const HOST = process.env["HOST"] ?? "127.0.0.1"; | ||
| 6 | 6 | |
| 7 | 7 | const app = buildApp(); |
| 8 | 8 | |
| 9 | +for (const signal of ["SIGINT", "SIGTERM"] as const) { | |
| 10 | + process.on(signal, () => { | |
| 11 | + app.log.info({ signal }, "shutting down"); | |
| 12 | + app.close().then( | |
| 13 | + () => process.exit(0), | |
| 14 | + () => process.exit(1), | |
| 15 | + ); | |
| 16 | + }); | |
| 17 | +} | |
| 18 | + | |
| 9 | 19 | app |
| 10 | 20 | .listen({ port: PORT, host: HOST }) |
| 11 | 21 | .then((address) => { |
modified
apps/api/src/schemas.ts
+14 −0
@@ -24,7 +24,21 @@ export const ScrapeRequestSchema = z | ||
| 24 | 24 | maxAge: z.number().int().min(0).default(0), |
| 25 | 25 | headers: z.record(z.string()).optional(), |
| 26 | 26 | maxBytes: z.number().int().min(1).optional(), |
| 27 | + respectRobots: z.boolean().default(true), | |
| 27 | 28 | }) |
| 28 | 29 | .strict(); |
| 29 | 30 | |
| 30 | 31 | export type ScrapeRequest = z.infer<typeof ScrapeRequestSchema>; |
| 32 | + | |
| 33 | +export const MapRequestSchema = z | |
| 34 | + .object({ | |
| 35 | + url: z.string().url(), | |
| 36 | + search: z.string().optional(), | |
| 37 | + limit: z.number().int().min(1).max(50_000).default(5_000), | |
| 38 | + includeSubdomains: z.boolean().default(false), | |
| 39 | + sitemapOnly: z.boolean().default(false), | |
| 40 | + ignoreSitemap: z.boolean().default(false), | |
| 41 | + }) | |
| 42 | + .strict(); | |
| 43 | + | |
| 44 | +export type MapRequest = z.infer<typeof MapRequestSchema>; | |
modified
apps/api/src/scrape.ts
+14 −0
@@ -2,6 +2,7 @@ | ||
| 2 | 2 | import { performance } from "node:perf_hooks"; |
| 3 | 3 | import { err, ok, tendrilError, type FetchTimings, type Result, type Tier } from "@tendril/shared"; |
| 4 | 4 | import { shouldEscalate } from "@tendril/router"; |
| 5 | +import { getRobots, isAllowed } from "@tendril/frontier"; | |
| 5 | 6 | import { httpFetch } from "@tendril/fetcher-http"; |
| 6 | 7 | import { extract, PIPELINE_VERSION, type ExtractResult } from "@tendril/extract"; |
| 7 | 8 | import type { ScrapeRequest } from "./schemas.js"; |
@@ -43,6 +44,19 @@ export async function runScrape(req: ScrapeRequest): Promise<Result<ScrapeData>> | ||
| 43 | 44 | const started = performance.now(); |
| 44 | 45 | const timings: FetchTimings = { total: 0, escalations: [] }; |
| 45 | 46 | |
| 47 | + if (req.respectRobots) { | |
| 48 | + let target: URL; | |
| 49 | + try { | |
| 50 | + target = new URL(req.url); | |
| 51 | + } catch { | |
| 52 | + return err(tendrilError("ERR_INVALID_URL", { details: { url: req.url } })); | |
| 53 | + } | |
| 54 | + const robots = await getRobots(target.origin); | |
| 55 | + if (!isAllowed(robots, target.pathname + target.search)) { | |
| 56 | + return err(tendrilError("ERR_ROBOTS_DENIED", { details: { url: req.url } })); | |
| 57 | + } | |
| 58 | + } | |
| 59 | + | |
| 46 | 60 | const fetched = await httpFetch(req.url, { |
| 47 | 61 | timeout: req.timeout, |
| 48 | 62 | ...(req.headers !== undefined ? { headers: req.headers } : {}), |
modified
apps/api/tsconfig.json
+3 −0
@@ -20,6 +20,9 @@ | ||
| 20 | 20 | }, |
| 21 | 21 | { |
| 22 | 22 | "path": "../../packages/extract" |
| 23 | + }, | |
| 24 | + { | |
| 25 | + "path": "../../packages/frontier" | |
| 23 | 26 | } |
| 24 | 27 | ], |
| 25 | 28 | "include": [ |
added
deploy/ecosystem.config.cjs
+32 −0
@@ -0,0 +1,32 @@ | ||
| 1 | +// author: simon-pierre boucher <contact@spboucher.ai> | |
| 2 | +// PM2 process definitions for Tendril on the production node (§16.3). | |
| 3 | +// The API binds to loopback only; ngrok is the sole ingress (§16.5). | |
| 4 | +const PORT = process.env.TENDRIL_PORT || "8092"; | |
| 5 | + | |
| 6 | +module.exports = { | |
| 7 | + apps: [ | |
| 8 | + { | |
| 9 | + name: "tendril-api", | |
| 10 | + script: "apps/api/dist/main.js", | |
| 11 | + interpreter: "node", | |
| 12 | + cwd: process.env.TENDRIL_DIR || `${process.env.HOME}/apps/tendril`, | |
| 13 | + env: { | |
| 14 | + NODE_ENV: "production", | |
| 15 | + HOST: "127.0.0.1", | |
| 16 | + PORT, | |
| 17 | + LOG_LEVEL: "info", | |
| 18 | + }, | |
| 19 | + max_restarts: 20, | |
| 20 | + restart_delay: 2000, | |
| 21 | + kill_timeout: 65000, | |
| 22 | + }, | |
| 23 | + { | |
| 24 | + name: "tendril-ngrok", | |
| 25 | + script: "/opt/homebrew/bin/ngrok", | |
| 26 | + interpreter: "none", | |
| 27 | + args: `http --url=www.ten-dril.com ${PORT}`, | |
| 28 | + max_restarts: 50, | |
| 29 | + restart_delay: 5000, | |
| 30 | + }, | |
| 31 | + ], | |
| 32 | +}; | |
added
deploy/ngrok.yml
+31 −0
@@ -0,0 +1,31 @@ | ||
| 1 | +# author: simon-pierre boucher <contact@spboucher.ai> | |
| 2 | +# Reference ngrok configuration (§16.2). In production Tendril is launched via | |
| 3 | +# PM2 (deploy/ecosystem.config.cjs) with `ngrok http --url=www.ten-dril.com <port>`, | |
| 4 | +# matching the other cluster apps. This file documents the richer Traffic Policy | |
| 5 | +# setup for when Caddy/edge rate-limiting is introduced. | |
| 6 | +version: "3" | |
| 7 | +agent: | |
| 8 | + authtoken: ${NGROK_AUTHTOKEN} | |
| 9 | + log: /usr/local/var/log/ngrok.log | |
| 10 | + log_level: info | |
| 11 | + connect_timeout: 10s | |
| 12 | +endpoints: | |
| 13 | + - name: tendril-api | |
| 14 | + url: https://www.ten-dril.com | |
| 15 | + upstream: | |
| 16 | + url: 8092 | |
| 17 | + traffic_policy: | |
| 18 | + inbound: | |
| 19 | + - actions: | |
| 20 | + - type: rate-limit | |
| 21 | + config: | |
| 22 | + name: global | |
| 23 | + algorithm: sliding_window | |
| 24 | + capacity: 600 | |
| 25 | + rate: 60s | |
| 26 | + - expressions: | |
| 27 | + - "req.url.path.startsWith('/internal')" | |
| 28 | + actions: | |
| 29 | + - type: deny | |
| 30 | + config: | |
| 31 | + status_code: 404 | |
modified
packages/egress/package.json
+8 −3
@@ -3,9 +3,14 @@ | ||
| 3 | 3 | "version": "0.1.0", |
| 4 | 4 | "private": true, |
| 5 | 5 | "type": "module", |
| 6 | − "main": "./src/index.ts", | |
| 7 | − "types": "./src/index.ts", | |
| 8 | − "exports": { ".": "./src/index.ts" }, | |
| 6 | + "main": "./dist/index.js", | |
| 7 | + "types": "./dist/index.d.ts", | |
| 8 | + "exports": { | |
| 9 | + ".": { | |
| 10 | + "types": "./dist/index.d.ts", | |
| 11 | + "default": "./dist/index.js" | |
| 12 | + } | |
| 13 | + }, | |
| 9 | 14 | "dependencies": { |
| 10 | 15 | "@tendril/shared": "workspace:*" |
| 11 | 16 | } |
modified
packages/extract/package.json
+8 −3
@@ -3,9 +3,14 @@ | ||
| 3 | 3 | "version": "0.1.0", |
| 4 | 4 | "private": true, |
| 5 | 5 | "type": "module", |
| 6 | − "main": "./src/index.ts", | |
| 7 | − "types": "./src/index.ts", | |
| 8 | − "exports": { ".": "./src/index.ts" }, | |
| 6 | + "main": "./dist/index.js", | |
| 7 | + "types": "./dist/index.d.ts", | |
| 8 | + "exports": { | |
| 9 | + ".": { | |
| 10 | + "types": "./dist/index.d.ts", | |
| 11 | + "default": "./dist/index.js" | |
| 12 | + } | |
| 13 | + }, | |
| 9 | 14 | "dependencies": { |
| 10 | 15 | "@tendril/shared": "workspace:*", |
| 11 | 16 | "@mozilla/readability": "^0.5.0", |
modified
packages/extract/src/index.ts
+1 −1
@@ -3,7 +3,7 @@ export { extract } from "./pipeline.js"; | ||
| 3 | 3 | export { htmlToMarkdown, createTurndown, postProcess } from "./markdown.js"; |
| 4 | 4 | export { harvestStructured } from "./structured.js"; |
| 5 | 5 | export { extractMainContent, densityExtract } from "./boilerplate.js"; |
| 6 | −export { extractLinks } from "./links.js"; | |
| 6 | +export { extractLinks, extractLinksFromHtml } from "./links.js"; | |
| 7 | 7 | export { |
| 8 | 8 | PIPELINE_VERSION, |
| 9 | 9 | type ExtractOptions, |
modified
packages/extract/src/links.ts
+5 −0
@@ -1,6 +1,11 @@ | ||
| 1 | 1 | // author: simon-pierre boucher <contact@spboucher.ai> |
| 2 | +import { parse } from "./dom.js"; | |
| 2 | 3 | import type { PageLink } from "./types.js"; |
| 3 | 4 | |
| 5 | +export function extractLinksFromHtml(html: string, base: string): PageLink[] { | |
| 6 | + return extractLinks(parse(html).document, base); | |
| 7 | +} | |
| 8 | + | |
| 4 | 9 | export function extractLinks(document: Document, base: string): PageLink[] { |
| 5 | 10 | let baseHost: string; |
| 6 | 11 | try { |
modified
packages/fetcher-http/package.json
+8 −3
@@ -3,9 +3,14 @@ | ||
| 3 | 3 | "version": "0.1.0", |
| 4 | 4 | "private": true, |
| 5 | 5 | "type": "module", |
| 6 | − "main": "./src/index.ts", | |
| 7 | − "types": "./src/index.ts", | |
| 8 | − "exports": { ".": "./src/index.ts" }, | |
| 6 | + "main": "./dist/index.js", | |
| 7 | + "types": "./dist/index.d.ts", | |
| 8 | + "exports": { | |
| 9 | + ".": { | |
| 10 | + "types": "./dist/index.d.ts", | |
| 11 | + "default": "./dist/index.js" | |
| 12 | + } | |
| 13 | + }, | |
| 9 | 14 | "dependencies": { |
| 10 | 15 | "@tendril/shared": "workspace:*", |
| 11 | 16 | "@tendril/egress": "workspace:*", |
added
packages/frontier/package.json
+19 −0
@@ -0,0 +1,19 @@ | ||
| 1 | +{ | |
| 2 | + "name": "@tendril/frontier", | |
| 3 | + "version": "0.1.0", | |
| 4 | + "private": true, | |
| 5 | + "type": "module", | |
| 6 | + "main": "./dist/index.js", | |
| 7 | + "types": "./dist/index.d.ts", | |
| 8 | + "exports": { | |
| 9 | + ".": { | |
| 10 | + "types": "./dist/index.d.ts", | |
| 11 | + "default": "./dist/index.js" | |
| 12 | + } | |
| 13 | + }, | |
| 14 | + "dependencies": { | |
| 15 | + "@tendril/shared": "workspace:*", | |
| 16 | + "@tendril/fetcher-http": "workspace:*", | |
| 17 | + "@tendril/extract": "workspace:*" | |
| 18 | + } | |
| 19 | +} | |
added
packages/frontier/src/index.ts
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +// author: simon-pierre boucher <contact@spboucher.ai> | |
| 2 | +export { parseRobots, isAllowed, crawlDelay, type Robots } from "./robots.js"; | |
| 3 | +export { parseSitemap, type ParsedSitemap, type SitemapUrl } from "./sitemap.js"; | |
| 4 | +export { getRobots, clearRobotsCache } from "./robots-cache.js"; | |
| 5 | +export { mapSite, type MapOptions, type MappedLink, type MapSource } from "./map.js"; | |
added
packages/frontier/src/map.ts
+152 −0
@@ -0,0 +1,152 @@ | ||
| 1 | +// author: simon-pierre boucher <contact@spboucher.ai> | |
| 2 | +import { err, normalizeUrl, ok, tendrilError, type Result } from "@tendril/shared"; | |
| 3 | +import { httpFetch } from "@tendril/fetcher-http"; | |
| 4 | +import { extractLinksFromHtml } from "@tendril/extract"; | |
| 5 | +import { getRobots } from "./robots-cache.js"; | |
| 6 | +import { parseSitemap } from "./sitemap.js"; | |
| 7 | + | |
| 8 | +export type MapSource = "sitemap" | "robots" | "homepage" | "llms" | "index"; | |
| 9 | + | |
| 10 | +export interface MappedLink { | |
| 11 | + readonly url: string; | |
| 12 | + readonly source: MapSource; | |
| 13 | + readonly lastModified?: string; | |
| 14 | +} | |
| 15 | + | |
| 16 | +export interface MapOptions { | |
| 17 | + readonly search?: string; | |
| 18 | + readonly limit?: number; | |
| 19 | + readonly includeSubdomains?: boolean; | |
| 20 | + readonly sitemapOnly?: boolean; | |
| 21 | + readonly ignoreSitemap?: boolean; | |
| 22 | +} | |
| 23 | + | |
| 24 | +const SITEMAP_URL_CAP = 50_000; | |
| 25 | +const DEFAULT_LIMIT = 5_000; | |
| 26 | +const MAX_SITEMAP_FETCHES = 60; | |
| 27 | + | |
| 28 | +function apexOf(host: string): string { | |
| 29 | + const parts = host.split("."); | |
| 30 | + return parts.length <= 2 ? host : parts.slice(-2).join("."); | |
| 31 | +} | |
| 32 | + | |
| 33 | +function hostMatches(host: string, baseHost: string, includeSubdomains: boolean): boolean { | |
| 34 | + if (host === baseHost) return true; | |
| 35 | + if (!includeSubdomains) return false; | |
| 36 | + const apex = apexOf(baseHost); | |
| 37 | + return host === apex || host.endsWith("." + apex); | |
| 38 | +} | |
| 39 | + | |
| 40 | +async function collectSitemaps( | |
| 41 | + seeds: string[], | |
| 42 | + baseHost: string, | |
| 43 | + includeSubdomains: boolean, | |
| 44 | + add: (url: string, source: MapSource, lastModified?: string) => void, | |
| 45 | +): Promise<void> { | |
| 46 | + const queue = [...seeds]; | |
| 47 | + const visited = new Set<string>(); | |
| 48 | + let fetches = 0; | |
| 49 | + let collected = 0; | |
| 50 | + | |
| 51 | + while (queue.length > 0 && fetches < MAX_SITEMAP_FETCHES && collected < SITEMAP_URL_CAP) { | |
| 52 | + const sm = queue.shift(); | |
| 53 | + if (sm === undefined) break; | |
| 54 | + const key = normalizeUrl(sm); | |
| 55 | + const nk = key.ok ? key.value : sm; | |
| 56 | + if (visited.has(nk)) continue; | |
| 57 | + visited.add(nk); | |
| 58 | + | |
| 59 | + const res = await httpFetch(sm, { timeout: 10_000 }); | |
| 60 | + fetches++; | |
| 61 | + if (!res.ok) continue; | |
| 62 | + const parsed = parseSitemap(res.value.body); | |
| 63 | + | |
| 64 | + for (const child of parsed.childSitemaps) queue.push(child); | |
| 65 | + for (const u of parsed.urls) { | |
| 66 | + if (collected >= SITEMAP_URL_CAP) break; | |
| 67 | + let host: string; | |
| 68 | + try { | |
| 69 | + host = new URL(u.loc).hostname.toLowerCase(); | |
| 70 | + } catch { | |
| 71 | + continue; | |
| 72 | + } | |
| 73 | + if (!hostMatches(host, baseHost, includeSubdomains)) continue; | |
| 74 | + add(u.loc, "sitemap", u.lastModified); | |
| 75 | + collected++; | |
| 76 | + } | |
| 77 | + } | |
| 78 | +} | |
| 79 | + | |
| 80 | +/** | |
| 81 | + * Discover a site's URLs without rendering (§14.3): robots.txt Sitemap | |
| 82 | + * directives, sitemap.xml (recursing through indexes, capped at 50k), homepage | |
| 83 | + * links, and /llms.txt — merged and deduplicated on the normalized URL, with the | |
| 84 | + * first-seen source preserved. | |
| 85 | + */ | |
| 86 | +export async function mapSite(rawUrl: string, options: MapOptions = {}): Promise<Result<{ links: MappedLink[] }>> { | |
| 87 | + let base: URL; | |
| 88 | + try { | |
| 89 | + base = new URL(rawUrl); | |
| 90 | + } catch { | |
| 91 | + return err(tendrilError("ERR_INVALID_URL", { details: { url: rawUrl } })); | |
| 92 | + } | |
| 93 | + const origin = base.origin; | |
| 94 | + const baseHost = base.hostname.toLowerCase(); | |
| 95 | + const includeSubdomains = options.includeSubdomains ?? false; | |
| 96 | + | |
| 97 | + const byKey = new Map<string, MappedLink>(); | |
| 98 | + const add = (url: string, source: MapSource, lastModified?: string): void => { | |
| 99 | + const norm = normalizeUrl(url); | |
| 100 | + if (!norm.ok) return; | |
| 101 | + if (byKey.has(norm.value)) return; | |
| 102 | + byKey.set(norm.value, lastModified !== undefined ? { url, source, lastModified } : { url, source }); | |
| 103 | + }; | |
| 104 | + | |
| 105 | + if (options.ignoreSitemap !== true) { | |
| 106 | + const robots = await getRobots(origin); | |
| 107 | + const seeds = [...robots.sitemaps, `${origin}/sitemap.xml`]; | |
| 108 | + await collectSitemaps(seeds, baseHost, includeSubdomains, add); | |
| 109 | + } | |
| 110 | + | |
| 111 | + if (options.sitemapOnly !== true) { | |
| 112 | + const home = await httpFetch(rawUrl, { timeout: 10_000 }); | |
| 113 | + if (home.ok) { | |
| 114 | + for (const link of extractLinksFromHtml(home.value.body, home.value.finalUrl)) { | |
| 115 | + let host: string; | |
| 116 | + try { | |
| 117 | + host = new URL(link.url).hostname.toLowerCase(); | |
| 118 | + } catch { | |
| 119 | + continue; | |
| 120 | + } | |
| 121 | + if (hostMatches(host, baseHost, includeSubdomains)) add(link.url, "homepage"); | |
| 122 | + } | |
| 123 | + } | |
| 124 | + | |
| 125 | + const llms = await httpFetch(`${origin}/llms.txt`, { timeout: 8_000 }); | |
| 126 | + if (llms.ok && llms.value.status < 400) { | |
| 127 | + const urlRe = /https?:\/\/[^\s)]+/g; | |
| 128 | + let m: RegExpExecArray | null; | |
| 129 | + while ((m = urlRe.exec(llms.value.body)) !== null) { | |
| 130 | + try { | |
| 131 | + if (hostMatches(new URL(m[0]).hostname.toLowerCase(), baseHost, includeSubdomains)) add(m[0], "llms"); | |
| 132 | + } catch { | |
| 133 | + continue; | |
| 134 | + } | |
| 135 | + } | |
| 136 | + } | |
| 137 | + } | |
| 138 | + | |
| 139 | + let links = [...byKey.values()]; | |
| 140 | + | |
| 141 | + const search = options.search?.trim().toLowerCase(); | |
| 142 | + if (search !== undefined && search !== "") { | |
| 143 | + links = links | |
| 144 | + .map((l) => ({ l, pos: l.url.toLowerCase().indexOf(search) })) | |
| 145 | + .filter((x) => x.pos !== -1) | |
| 146 | + .sort((a, b) => a.pos - b.pos) | |
| 147 | + .map((x) => x.l); | |
| 148 | + } | |
| 149 | + | |
| 150 | + const limit = options.limit ?? DEFAULT_LIMIT; | |
| 151 | + return ok({ links: links.slice(0, limit) }); | |
| 152 | +} | |
added
packages/frontier/src/robots-cache.ts
+49 −0
@@ -0,0 +1,49 @@ | ||
| 1 | +// author: simon-pierre boucher <contact@spboucher.ai> | |
| 2 | +import { httpFetch } from "@tendril/fetcher-http"; | |
| 3 | +import { parseRobots, type Robots } from "./robots.js"; | |
| 4 | + | |
| 5 | +const ALLOW_ALL: Robots = { groups: [], sitemaps: [] }; | |
| 6 | +const DISALLOW_ALL: Robots = { groups: [{ agents: ["*"], rules: [{ allow: false, pattern: "/" }] }], sitemaps: [] }; | |
| 7 | + | |
| 8 | +const OK_TTL_MS = 24 * 60 * 60 * 1000; | |
| 9 | +const DISALLOW_TTL_MS = 60 * 60 * 1000; | |
| 10 | + | |
| 11 | +interface Entry { | |
| 12 | + robots: Robots; | |
| 13 | + expiresAt: number; | |
| 14 | +} | |
| 15 | + | |
| 16 | +const cache = new Map<string, Entry>(); | |
| 17 | + | |
| 18 | +/** | |
| 19 | + * Fetch and cache robots.txt for an origin (§10.1). Cached 24h. A 5xx is treated | |
| 20 | + * as disallow-all for 1h (per the spec's intent); a 4xx or network failure is | |
| 21 | + * treated as allow-all. `origin` must be a scheme://host[:port] string. | |
| 22 | + */ | |
| 23 | +export async function getRobots(origin: string): Promise<Robots> { | |
| 24 | + const now = Date.now(); | |
| 25 | + const cached = cache.get(origin); | |
| 26 | + if (cached !== undefined && cached.expiresAt > now) return cached.robots; | |
| 27 | + | |
| 28 | + const res = await httpFetch(`${origin}/robots.txt`, { timeout: 8_000 }); | |
| 29 | + let robots: Robots; | |
| 30 | + let ttl = OK_TTL_MS; | |
| 31 | + | |
| 32 | + if (!res.ok) { | |
| 33 | + robots = ALLOW_ALL; | |
| 34 | + } else if (res.value.status >= 500) { | |
| 35 | + robots = DISALLOW_ALL; | |
| 36 | + ttl = DISALLOW_TTL_MS; | |
| 37 | + } else if (res.value.status >= 400) { | |
| 38 | + robots = ALLOW_ALL; | |
| 39 | + } else { | |
| 40 | + robots = parseRobots(res.value.body); | |
| 41 | + } | |
| 42 | + | |
| 43 | + cache.set(origin, { robots, expiresAt: now + ttl }); | |
| 44 | + return robots; | |
| 45 | +} | |
| 46 | + | |
| 47 | +export function clearRobotsCache(): void { | |
| 48 | + cache.clear(); | |
| 49 | +} | |
added
packages/frontier/src/robots.test.ts
+54 −0
@@ -0,0 +1,54 @@ | ||
| 1 | +// author: simon-pierre boucher <contact@spboucher.ai> | |
| 2 | +import { describe, expect, it } from "vitest"; | |
| 3 | +import { crawlDelay, isAllowed, parseRobots } from "./robots.js"; | |
| 4 | + | |
| 5 | +describe("parseRobots / isAllowed", () => { | |
| 6 | + it("allows everything when robots is empty", () => { | |
| 7 | + const r = parseRobots(""); | |
| 8 | + expect(isAllowed(r, "/anything")).toBe(true); | |
| 9 | + }); | |
| 10 | + | |
| 11 | + it("honors a wildcard Disallow", () => { | |
| 12 | + const r = parseRobots("User-agent: *\nDisallow: /private/"); | |
| 13 | + expect(isAllowed(r, "/private/x")).toBe(false); | |
| 14 | + expect(isAllowed(r, "/public/x")).toBe(true); | |
| 15 | + }); | |
| 16 | + | |
| 17 | + it("applies longest-match-wins between Allow and Disallow", () => { | |
| 18 | + const r = parseRobots("User-agent: *\nDisallow: /a/\nAllow: /a/b"); | |
| 19 | + expect(isAllowed(r, "/a/b/c")).toBe(true); | |
| 20 | + expect(isAllowed(r, "/a/x")).toBe(false); | |
| 21 | + }); | |
| 22 | + | |
| 23 | + it("supports * and $ wildcards", () => { | |
| 24 | + const r = parseRobots("User-agent: *\nDisallow: /*.pdf$"); | |
| 25 | + expect(isAllowed(r, "/docs/file.pdf")).toBe(false); | |
| 26 | + expect(isAllowed(r, "/docs/file.pdf?x=1")).toBe(true); | |
| 27 | + expect(isAllowed(r, "/docs/file.html")).toBe(true); | |
| 28 | + }); | |
| 29 | + | |
| 30 | + it("selects the most specific matching user-agent group", () => { | |
| 31 | + const r = parseRobots( | |
| 32 | + "User-agent: *\nDisallow: /\n\nUser-agent: Tendril\nAllow: /\nDisallow: /secret", | |
| 33 | + ); | |
| 34 | + expect(isAllowed(r, "/page", "Tendril/1.0")).toBe(true); | |
| 35 | + expect(isAllowed(r, "/secret", "Tendril/1.0")).toBe(false); | |
| 36 | + expect(isAllowed(r, "/page", "SomeOtherBot")).toBe(false); | |
| 37 | + }); | |
| 38 | + | |
| 39 | + it("treats an empty Disallow as allow-all", () => { | |
| 40 | + const r = parseRobots("User-agent: *\nDisallow:"); | |
| 41 | + expect(isAllowed(r, "/anything")).toBe(true); | |
| 42 | + }); | |
| 43 | + | |
| 44 | + it("groups consecutive user-agent lines together", () => { | |
| 45 | + const r = parseRobots("User-agent: A\nUser-agent: Tendril\nDisallow: /x"); | |
| 46 | + expect(isAllowed(r, "/x", "Tendril")).toBe(false); | |
| 47 | + }); | |
| 48 | + | |
| 49 | + it("collects Sitemap directives and Crawl-delay", () => { | |
| 50 | + const r = parseRobots("Sitemap: https://e.com/sitemap.xml\nUser-agent: *\nCrawl-delay: 5\nDisallow: /y"); | |
| 51 | + expect(r.sitemaps).toEqual(["https://e.com/sitemap.xml"]); | |
| 52 | + expect(crawlDelay(r)).toBe(5); | |
| 53 | + }); | |
| 54 | +}); | |
added
packages/frontier/src/robots.ts
+139 −0
@@ -0,0 +1,139 @@ | ||
| 1 | +// author: simon-pierre boucher <contact@spboucher.ai> | |
| 2 | +export interface RobotsRule { | |
| 3 | + readonly allow: boolean; | |
| 4 | + readonly pattern: string; | |
| 5 | +} | |
| 6 | + | |
| 7 | +export interface RobotsGroup { | |
| 8 | + readonly agents: string[]; | |
| 9 | + rules: RobotsRule[]; | |
| 10 | + crawlDelay?: number; | |
| 11 | +} | |
| 12 | + | |
| 13 | +export interface Robots { | |
| 14 | + readonly groups: RobotsGroup[]; | |
| 15 | + readonly sitemaps: string[]; | |
| 16 | +} | |
| 17 | + | |
| 18 | +/** | |
| 19 | + * Parse robots.txt (RFC 9309 subset, §10.1): User-agent groups, Allow/Disallow | |
| 20 | + * with `*`/`$` wildcards, Crawl-delay, and global Sitemap directives. Consecutive | |
| 21 | + * User-agent lines share the following rule block. | |
| 22 | + */ | |
| 23 | +export function parseRobots(text: string): Robots { | |
| 24 | + const groups: RobotsGroup[] = []; | |
| 25 | + const sitemaps: string[] = []; | |
| 26 | + let current: RobotsGroup | null = null; | |
| 27 | + let expectingAgent = false; | |
| 28 | + | |
| 29 | + for (const rawLine of text.split(/\r?\n/)) { | |
| 30 | + const line = rawLine.replace(/#.*$/, "").trim(); | |
| 31 | + if (line === "") continue; | |
| 32 | + const idx = line.indexOf(":"); | |
| 33 | + if (idx === -1) continue; | |
| 34 | + const field = line.slice(0, idx).trim().toLowerCase(); | |
| 35 | + const value = line.slice(idx + 1).trim(); | |
| 36 | + | |
| 37 | + switch (field) { | |
| 38 | + case "user-agent": { | |
| 39 | + if (current === null || !expectingAgent) { | |
| 40 | + current = { agents: [], rules: [] }; | |
| 41 | + groups.push(current); | |
| 42 | + } | |
| 43 | + current.agents.push(value.toLowerCase()); | |
| 44 | + expectingAgent = true; | |
| 45 | + break; | |
| 46 | + } | |
| 47 | + case "allow": | |
| 48 | + case "disallow": { | |
| 49 | + if (current === null) { | |
| 50 | + current = { agents: ["*"], rules: [] }; | |
| 51 | + groups.push(current); | |
| 52 | + } | |
| 53 | + expectingAgent = false; | |
| 54 | + current.rules.push({ allow: field === "allow", pattern: value }); | |
| 55 | + break; | |
| 56 | + } | |
| 57 | + case "crawl-delay": { | |
| 58 | + if (current !== null) { | |
| 59 | + const n = Number(value); | |
| 60 | + if (Number.isFinite(n)) current.crawlDelay = n; | |
| 61 | + } | |
| 62 | + expectingAgent = false; | |
| 63 | + break; | |
| 64 | + } | |
| 65 | + case "sitemap": { | |
| 66 | + if (value !== "") sitemaps.push(value); | |
| 67 | + break; | |
| 68 | + } | |
| 69 | + default: | |
| 70 | + break; | |
| 71 | + } | |
| 72 | + } | |
| 73 | + | |
| 74 | + return { groups, sitemaps }; | |
| 75 | +} | |
| 76 | + | |
| 77 | +function patternToRegex(pattern: string): RegExp { | |
| 78 | + let anchoredEnd = false; | |
| 79 | + let p = pattern; | |
| 80 | + if (p.endsWith("$")) { | |
| 81 | + anchoredEnd = true; | |
| 82 | + p = p.slice(0, -1); | |
| 83 | + } | |
| 84 | + const escaped = p | |
| 85 | + .split("*") | |
| 86 | + .map((seg) => seg.replace(/[.+?^${}()|[\]\\]/g, "\\$&")) | |
| 87 | + .join(".*"); | |
| 88 | + return new RegExp("^" + escaped + (anchoredEnd ? "$" : "")); | |
| 89 | +} | |
| 90 | + | |
| 91 | +function selectGroup(robots: Robots, userAgent: string): RobotsGroup | null { | |
| 92 | + const ua = userAgent.toLowerCase(); | |
| 93 | + let best: RobotsGroup | null = null; | |
| 94 | + let bestLen = -1; | |
| 95 | + let star: RobotsGroup | null = null; | |
| 96 | + for (const group of robots.groups) { | |
| 97 | + for (const agent of group.agents) { | |
| 98 | + if (agent === "*") { | |
| 99 | + star = group; | |
| 100 | + continue; | |
| 101 | + } | |
| 102 | + if (ua.includes(agent) && agent.length > bestLen) { | |
| 103 | + best = group; | |
| 104 | + bestLen = agent.length; | |
| 105 | + } | |
| 106 | + } | |
| 107 | + } | |
| 108 | + return best ?? star; | |
| 109 | +} | |
| 110 | + | |
| 111 | +/** | |
| 112 | + * Decide whether a path is crawlable for our agent. Longest matching rule wins; | |
| 113 | + * ties resolve to Allow (RFC 9309). No matching group, or an empty rule set, | |
| 114 | + * means allow-all. | |
| 115 | + */ | |
| 116 | +export function isAllowed(robots: Robots, path: string, userAgent = "Tendril"): boolean { | |
| 117 | + const group = selectGroup(robots, userAgent); | |
| 118 | + if (group === null || group.rules.length === 0) return true; | |
| 119 | + | |
| 120 | + let decision = true; | |
| 121 | + let bestLen = -1; | |
| 122 | + for (const rule of group.rules) { | |
| 123 | + if (rule.pattern === "") { | |
| 124 | + if (!rule.allow) continue; | |
| 125 | + continue; | |
| 126 | + } | |
| 127 | + if (patternToRegex(rule.pattern).test(path) && rule.pattern.length > bestLen) { | |
| 128 | + bestLen = rule.pattern.length; | |
| 129 | + decision = rule.allow; | |
| 130 | + } else if (patternToRegex(rule.pattern).test(path) && rule.pattern.length === bestLen && rule.allow) { | |
| 131 | + decision = true; | |
| 132 | + } | |
| 133 | + } | |
| 134 | + return decision; | |
| 135 | +} | |
| 136 | + | |
| 137 | +export function crawlDelay(robots: Robots, userAgent = "Tendril"): number | undefined { | |
| 138 | + return selectGroup(robots, userAgent)?.crawlDelay; | |
| 139 | +} | |
added
packages/frontier/src/sitemap.test.ts
+41 −0
@@ -0,0 +1,41 @@ | ||
| 1 | +// author: simon-pierre boucher <contact@spboucher.ai> | |
| 2 | +import { describe, expect, it } from "vitest"; | |
| 3 | +import { parseSitemap } from "./sitemap.js"; | |
| 4 | + | |
| 5 | +describe("parseSitemap", () => { | |
| 6 | + it("parses a urlset with lastmod", () => { | |
| 7 | + const xml = `<?xml version="1.0"?><urlset> | |
| 8 | + <url><loc>https://e.com/a</loc><lastmod>2026-01-01</lastmod></url> | |
| 9 | + <url><loc>https://e.com/b</loc></url> | |
| 10 | + </urlset>`; | |
| 11 | + const r = parseSitemap(xml); | |
| 12 | + expect(r.urls).toEqual([ | |
| 13 | + { loc: "https://e.com/a", lastModified: "2026-01-01" }, | |
| 14 | + { loc: "https://e.com/b" }, | |
| 15 | + ]); | |
| 16 | + expect(r.childSitemaps).toEqual([]); | |
| 17 | + }); | |
| 18 | + | |
| 19 | + it("parses a sitemap index into child sitemaps", () => { | |
| 20 | + const xml = `<sitemapindex> | |
| 21 | + <sitemap><loc>https://e.com/sitemap-1.xml</loc></sitemap> | |
| 22 | + <sitemap><loc>https://e.com/sitemap-2.xml</loc></sitemap> | |
| 23 | + </sitemapindex>`; | |
| 24 | + const r = parseSitemap(xml); | |
| 25 | + expect(r.childSitemaps).toEqual(["https://e.com/sitemap-1.xml", "https://e.com/sitemap-2.xml"]); | |
| 26 | + expect(r.urls).toEqual([]); | |
| 27 | + }); | |
| 28 | + | |
| 29 | + it("decodes entities and CDATA in loc", () => { | |
| 30 | + const xml = `<urlset><url><loc>https://e.com/s?a=1&b=2</loc></url> | |
| 31 | + <url><loc><![CDATA[https://e.com/c]]></loc></url></urlset>`; | |
| 32 | + const r = parseSitemap(xml); | |
| 33 | + expect(r.urls[0]?.loc).toBe("https://e.com/s?a=1&b=2"); | |
| 34 | + expect(r.urls[1]?.loc).toBe("https://e.com/c"); | |
| 35 | + }); | |
| 36 | + | |
| 37 | + it("falls back to bare <loc> tags", () => { | |
| 38 | + const r = parseSitemap("<loc>https://e.com/x</loc><loc>https://e.com/y</loc>"); | |
| 39 | + expect(r.urls.map((u) => u.loc)).toEqual(["https://e.com/x", "https://e.com/y"]); | |
| 40 | + }); | |
| 41 | +}); | |
added
packages/frontier/src/sitemap.ts
+68 −0
@@ -0,0 +1,68 @@ | ||
| 1 | +// author: simon-pierre boucher <contact@spboucher.ai> | |
| 2 | +export interface SitemapUrl { | |
| 3 | + readonly loc: string; | |
| 4 | + readonly lastModified?: string; | |
| 5 | +} | |
| 6 | + | |
| 7 | +export interface ParsedSitemap { | |
| 8 | + readonly urls: SitemapUrl[]; | |
| 9 | + readonly childSitemaps: string[]; | |
| 10 | +} | |
| 11 | + | |
| 12 | +function decodeXml(s: string): string { | |
| 13 | + return s | |
| 14 | + .replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1") | |
| 15 | + .replace(/</g, "<") | |
| 16 | + .replace(/>/g, ">") | |
| 17 | + .replace(/"/g, '"') | |
| 18 | + .replace(/'/g, "'") | |
| 19 | + .replace(/'/g, "'") | |
| 20 | + .replace(/&/g, "&") | |
| 21 | + .trim(); | |
| 22 | +} | |
| 23 | + | |
| 24 | +function tag(block: string, name: string): string | undefined { | |
| 25 | + const m = new RegExp(`<${name}[^>]*>([\\s\\S]*?)</${name}>`, "i").exec(block); | |
| 26 | + return m?.[1] !== undefined ? decodeXml(m[1]) : undefined; | |
| 27 | +} | |
| 28 | + | |
| 29 | +function blocks(xml: string, name: string): string[] { | |
| 30 | + const out: string[] = []; | |
| 31 | + const re = new RegExp(`<${name}[^>]*>([\\s\\S]*?)</${name}>`, "gi"); | |
| 32 | + let m: RegExpExecArray | null; | |
| 33 | + while ((m = re.exec(xml)) !== null) { | |
| 34 | + if (m[1] !== undefined) out.push(m[1]); | |
| 35 | + } | |
| 36 | + return out; | |
| 37 | +} | |
| 38 | + | |
| 39 | +/** | |
| 40 | + * Parse a sitemap or sitemap index (§14.3). Returns child sitemap URLs (for | |
| 41 | + * recursion through indexes) and page URLs with optional lastmod. | |
| 42 | + */ | |
| 43 | +export function parseSitemap(xml: string): ParsedSitemap { | |
| 44 | + const childSitemaps: string[] = []; | |
| 45 | + for (const block of blocks(xml, "sitemap")) { | |
| 46 | + const loc = tag(block, "loc"); | |
| 47 | + if (loc !== undefined && loc !== "") childSitemaps.push(loc); | |
| 48 | + } | |
| 49 | + | |
| 50 | + const urls: SitemapUrl[] = []; | |
| 51 | + for (const block of blocks(xml, "url")) { | |
| 52 | + const loc = tag(block, "loc"); | |
| 53 | + if (loc === undefined || loc === "") continue; | |
| 54 | + const lastmod = tag(block, "lastmod"); | |
| 55 | + urls.push(lastmod !== undefined ? { loc, lastModified: lastmod } : { loc }); | |
| 56 | + } | |
| 57 | + | |
| 58 | + if (urls.length === 0 && childSitemaps.length === 0) { | |
| 59 | + const re = /<loc[^>]*>([\s\S]*?)<\/loc>/gi; | |
| 60 | + let m: RegExpExecArray | null; | |
| 61 | + while ((m = re.exec(xml)) !== null) { | |
| 62 | + const loc = m[1] !== undefined ? decodeXml(m[1]) : ""; | |
| 63 | + if (loc !== "") urls.push({ loc }); | |
| 64 | + } | |
| 65 | + } | |
| 66 | + | |
| 67 | + return { urls, childSitemaps }; | |
| 68 | +} | |
added
packages/frontier/tsconfig.json
+11 −0
@@ -0,0 +1,11 @@ | ||
| 1 | +{ | |
| 2 | + "extends": "../../tsconfig.base.json", | |
| 3 | + "compilerOptions": { | |
| 4 | + "rootDir": "src", | |
| 5 | + "outDir": "dist", | |
| 6 | + "noEmit": false | |
| 7 | + }, | |
| 8 | + "references": [{ "path": "../shared" }, { "path": "../fetcher-http" }, { "path": "../extract" }], | |
| 9 | + "include": ["src/**/*.ts"], | |
| 10 | + "exclude": ["src/**/*.test.ts"] | |
| 11 | +} | |
modified
packages/router/package.json
+8 −3
@@ -3,9 +3,14 @@ | ||
| 3 | 3 | "version": "0.1.0", |
| 4 | 4 | "private": true, |
| 5 | 5 | "type": "module", |
| 6 | − "main": "./src/index.ts", | |
| 7 | − "types": "./src/index.ts", | |
| 8 | − "exports": { ".": "./src/index.ts" }, | |
| 6 | + "main": "./dist/index.js", | |
| 7 | + "types": "./dist/index.d.ts", | |
| 8 | + "exports": { | |
| 9 | + ".": { | |
| 10 | + "types": "./dist/index.d.ts", | |
| 11 | + "default": "./dist/index.js" | |
| 12 | + } | |
| 13 | + }, | |
| 9 | 14 | "dependencies": { |
| 10 | 15 | "@tendril/shared": "workspace:*" |
| 11 | 16 | } |
modified
packages/shared/package.json
+6 −3
@@ -3,10 +3,13 @@ | ||
| 3 | 3 | "version": "0.1.0", |
| 4 | 4 | "private": true, |
| 5 | 5 | "type": "module", |
| 6 | − "main": "./src/index.ts", | |
| 7 | − "types": "./src/index.ts", | |
| 6 | + "main": "./dist/index.js", | |
| 7 | + "types": "./dist/index.d.ts", | |
| 8 | 8 | "exports": { |
| 9 | − ".": "./src/index.ts" | |
| 9 | + ".": { | |
| 10 | + "types": "./dist/index.d.ts", | |
| 11 | + "default": "./dist/index.js" | |
| 12 | + } | |
| 10 | 13 | }, |
| 11 | 14 | "dependencies": { |
| 12 | 15 | "pino": "^9.5.0" |
modified
pnpm-lock.yaml
+271 −0
@@ -51,6 +51,9 @@ importers: | ||
| 51 | 51 | |
| 52 | 52 | apps/api: |
| 53 | 53 | dependencies: |
| 54 | + '@fastify/compress': | |
| 55 | + specifier: ^8.0.1 | |
| 56 | + version: 8.3.1 | |
| 54 | 57 | '@tendril/egress': |
| 55 | 58 | specifier: workspace:* |
| 56 | 59 | version: link:../../packages/egress |
@@ -60,6 +63,9 @@ importers: | ||
| 60 | 63 | '@tendril/fetcher-http': |
| 61 | 64 | specifier: workspace:* |
| 62 | 65 | version: link:../../packages/fetcher-http |
| 66 | + '@tendril/frontier': | |
| 67 | + specifier: workspace:* | |
| 68 | + version: link:../../packages/frontier | |
| 63 | 69 | '@tendril/router': |
| 64 | 70 | specifier: workspace:* |
| 65 | 71 | version: link:../../packages/router |
@@ -109,6 +115,18 @@ importers: | ||
| 109 | 115 | specifier: ^7.2.0 |
| 110 | 116 | version: 7.29.0 |
| 111 | 117 | |
| 118 | + packages/frontier: | |
| 119 | + dependencies: | |
| 120 | + '@tendril/extract': | |
| 121 | + specifier: workspace:* | |
| 122 | + version: link:../extract | |
| 123 | + '@tendril/fetcher-http': | |
| 124 | + specifier: workspace:* | |
| 125 | + version: link:../fetcher-http | |
| 126 | + '@tendril/shared': | |
| 127 | + specifier: workspace:* | |
| 128 | + version: link:../shared | |
| 129 | + | |
| 112 | 130 | packages/router: |
| 113 | 131 | dependencies: |
| 114 | 132 | '@tendril/shared': |
@@ -417,9 +435,15 @@ packages: | ||
| 417 | 435 | cpu: [x64] |
| 418 | 436 | os: [win32] |
| 419 | 437 | |
| 438 | + '@fastify/accept-negotiator@2.1.0': | |
| 439 | + resolution: {integrity: sha512-F3EVbzWt+xcnVaOHmWyIlpuFtbxOln7HDZQsh09MtMmMm/CipMayNt8hnIL8VQi54u2ZociDbf+iluGYkf7B1A==} | |
| 440 | + | |
| 420 | 441 | '@fastify/ajv-compiler@4.0.6': |
| 421 | 442 | resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==} |
| 422 | 443 | |
| 444 | + '@fastify/compress@8.3.1': | |
| 445 | + resolution: {integrity: sha512-BUpItLr6MUX9e9ukg5Y6xekyA/7pBFG8QWtFCrUDm9ctoBc3R2/nA16yOaOWtVoccpXGjdDEYA/MxAb5+8cxag==} | |
| 446 | + | |
| 423 | 447 | '@fastify/error@4.2.0': |
| 424 | 448 | resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} |
| 425 | 449 | |
@@ -631,6 +655,10 @@ packages: | ||
| 631 | 655 | '@vitest/utils@2.1.9': |
| 632 | 656 | resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} |
| 633 | 657 | |
| 658 | + abort-controller@3.0.0: | |
| 659 | + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} | |
| 660 | + engines: {node: '>=6.5'} | |
| 661 | + | |
| 634 | 662 | abstract-logging@2.0.1: |
| 635 | 663 | resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} |
| 636 | 664 | |
@@ -656,10 +684,19 @@ packages: | ||
| 656 | 684 | avvio@9.3.0: |
| 657 | 685 | resolution: {integrity: sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==} |
| 658 | 686 | |
| 687 | + base64-js@1.5.1: | |
| 688 | + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} | |
| 689 | + | |
| 659 | 690 | boolbase@2.0.0: |
| 660 | 691 | resolution: {integrity: sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==} |
| 661 | 692 | engines: {node: '>=20.19.0'} |
| 662 | 693 | |
| 694 | + buffer-from@1.1.2: | |
| 695 | + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} | |
| 696 | + | |
| 697 | + buffer@6.0.3: | |
| 698 | + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} | |
| 699 | + | |
| 663 | 700 | cac@6.7.14: |
| 664 | 701 | resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} |
| 665 | 702 | engines: {node: '>=8'} |
@@ -676,6 +713,9 @@ packages: | ||
| 676 | 713 | resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} |
| 677 | 714 | engines: {node: '>=18'} |
| 678 | 715 | |
| 716 | + core-util-is@1.0.3: | |
| 717 | + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} | |
| 718 | + | |
| 679 | 719 | css-select@7.0.0: |
| 680 | 720 | resolution: {integrity: sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==} |
| 681 | 721 | engines: {node: '>=20.19.0'} |
@@ -733,6 +773,15 @@ packages: | ||
| 733 | 773 | resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} |
| 734 | 774 | engines: {node: '>=20.19.0'} |
| 735 | 775 | |
| 776 | + duplexify@3.7.1: | |
| 777 | + resolution: {integrity: sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==} | |
| 778 | + | |
| 779 | + duplexify@4.1.3: | |
| 780 | + resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} | |
| 781 | + | |
| 782 | + end-of-stream@1.4.5: | |
| 783 | + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} | |
| 784 | + | |
| 736 | 785 | entities@4.5.0: |
| 737 | 786 | resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} |
| 738 | 787 | engines: {node: '>=0.12'} |
@@ -761,6 +810,14 @@ packages: | ||
| 761 | 810 | estree-walker@3.0.3: |
| 762 | 811 | resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} |
| 763 | 812 | |
| 813 | + event-target-shim@5.0.1: | |
| 814 | + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} | |
| 815 | + engines: {node: '>=6'} | |
| 816 | + | |
| 817 | + events@3.3.0: | |
| 818 | + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} | |
| 819 | + engines: {node: '>=0.8.x'} | |
| 820 | + | |
| 764 | 821 | expect-type@1.4.0: |
| 765 | 822 | resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} |
| 766 | 823 | engines: {node: '>=12.0.0'} |
@@ -783,6 +840,9 @@ packages: | ||
| 783 | 840 | fast-uri@4.1.2: |
| 784 | 841 | resolution: {integrity: sha512-TyGmBcbDTZXcb2cj5MV89DrF42DKvb3y5DDUNh95iO+IMeAzMkVSxK1PZRrRIpc9yg8U2GhGdbofNa0LS/a4Bw==} |
| 785 | 842 | |
| 843 | + fastify-plugin@5.1.0: | |
| 844 | + resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} | |
| 845 | + | |
| 786 | 846 | fastify@5.11.3: |
| 787 | 847 | resolution: {integrity: sha512-W6hzDP8s0iSeL7LGwY6Oc/ZxuXWOvFEMs6p2L0Si415YRo27W5pBKdOTXxhemBDeSTAcpYf5evRA9onF2OYhPA==} |
| 788 | 848 | |
@@ -807,10 +867,19 @@ packages: | ||
| 807 | 867 | htmlparser2@10.1.0: |
| 808 | 868 | resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} |
| 809 | 869 | |
| 870 | + ieee754@1.2.1: | |
| 871 | + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} | |
| 872 | + | |
| 873 | + inherits@2.0.4: | |
| 874 | + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} | |
| 875 | + | |
| 810 | 876 | ipaddr.js@2.5.0: |
| 811 | 877 | resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==} |
| 812 | 878 | engines: {node: '>= 10'} |
| 813 | 879 | |
| 880 | + isarray@1.0.0: | |
| 881 | + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} | |
| 882 | + | |
| 814 | 883 | json-schema-ref-resolver@3.0.0: |
| 815 | 884 | resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} |
| 816 | 885 | |
@@ -835,6 +904,14 @@ packages: | ||
| 835 | 904 | magic-string@0.30.21: |
| 836 | 905 | resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} |
| 837 | 906 | |
| 907 | + mime-db@1.54.0: | |
| 908 | + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} | |
| 909 | + engines: {node: '>= 0.6'} | |
| 910 | + | |
| 911 | + minipass@7.1.3: | |
| 912 | + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} | |
| 913 | + engines: {node: '>=16 || 14 >=14.17'} | |
| 914 | + | |
| 838 | 915 | ms@2.1.3: |
| 839 | 916 | resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} |
| 840 | 917 | |
@@ -851,6 +928,9 @@ packages: | ||
| 851 | 928 | resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} |
| 852 | 929 | engines: {node: '>=14.0.0'} |
| 853 | 930 | |
| 931 | + once@1.4.0: | |
| 932 | + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} | |
| 933 | + | |
| 854 | 934 | pathe@1.1.2: |
| 855 | 935 | resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} |
| 856 | 936 | |
@@ -858,6 +938,9 @@ packages: | ||
| 858 | 938 | resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} |
| 859 | 939 | engines: {node: '>= 14.16'} |
| 860 | 940 | |
| 941 | + peek-stream@1.1.3: | |
| 942 | + resolution: {integrity: sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==} | |
| 943 | + | |
| 861 | 944 | picocolors@1.1.1: |
| 862 | 945 | resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} |
| 863 | 946 | |
@@ -875,15 +958,39 @@ packages: | ||
| 875 | 958 | resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} |
| 876 | 959 | engines: {node: ^10 || ^12 || >=14} |
| 877 | 960 | |
| 961 | + process-nextick-args@2.0.1: | |
| 962 | + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} | |
| 963 | + | |
| 878 | 964 | process-warning@4.0.1: |
| 879 | 965 | resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} |
| 880 | 966 | |
| 881 | 967 | process-warning@5.1.0: |
| 882 | 968 | resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} |
| 883 | 969 | |
| 970 | + process@0.11.10: | |
| 971 | + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} | |
| 972 | + engines: {node: '>= 0.6.0'} | |
| 973 | + | |
| 974 | + pump@3.0.4: | |
| 975 | + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} | |
| 976 | + | |
| 977 | + pumpify@2.0.1: | |
| 978 | + resolution: {integrity: sha512-m7KOje7jZxrmutanlkS1daj1dS6z6BgslzOXmcSEpIlCxM3VJH7lG5QLeck/6hgF6F4crFf01UtQmNsJfweTAw==} | |
| 979 | + | |
| 884 | 980 | quick-format-unescaped@4.0.4: |
| 885 | 981 | resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} |
| 886 | 982 | |
| 983 | + readable-stream@2.3.8: | |
| 984 | + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} | |
| 985 | + | |
| 986 | + readable-stream@3.6.2: | |
| 987 | + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} | |
| 988 | + engines: {node: '>= 6'} | |
| 989 | + | |
| 990 | + readable-stream@4.7.0: | |
| 991 | + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} | |
| 992 | + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} | |
| 993 | + | |
| 887 | 994 | real-require@0.2.0: |
| 888 | 995 | resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} |
| 889 | 996 | engines: {node: '>= 12.13.0'} |
@@ -908,6 +1015,12 @@ packages: | ||
| 908 | 1015 | engines: {node: '>=18.0.0', npm: '>=8.0.0'} |
| 909 | 1016 | hasBin: true |
| 910 | 1017 | |
| 1018 | + safe-buffer@5.1.2: | |
| 1019 | + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} | |
| 1020 | + | |
| 1021 | + safe-buffer@5.2.1: | |
| 1022 | + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} | |
| 1023 | + | |
| 911 | 1024 | safe-regex2@5.1.1: |
| 912 | 1025 | resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} |
| 913 | 1026 | hasBin: true |
@@ -947,9 +1060,21 @@ packages: | ||
| 947 | 1060 | std-env@3.10.0: |
| 948 | 1061 | resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} |
| 949 | 1062 | |
| 1063 | + stream-shift@1.0.3: | |
| 1064 | + resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} | |
| 1065 | + | |
| 1066 | + string_decoder@1.1.1: | |
| 1067 | + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} | |
| 1068 | + | |
| 1069 | + string_decoder@1.3.0: | |
| 1070 | + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} | |
| 1071 | + | |
| 950 | 1072 | thread-stream@3.2.0: |
| 951 | 1073 | resolution: {integrity: sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==} |
| 952 | 1074 | |
| 1075 | + through2@2.0.5: | |
| 1076 | + resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} | |
| 1077 | + | |
| 953 | 1078 | tinybench@2.9.0: |
| 954 | 1079 | resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} |
| 955 | 1080 | |
@@ -1007,6 +1132,9 @@ packages: | ||
| 1007 | 1132 | resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} |
| 1008 | 1133 | engines: {node: '>=20.18.1'} |
| 1009 | 1134 | |
| 1135 | + util-deprecate@1.0.2: | |
| 1136 | + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} | |
| 1137 | + | |
| 1010 | 1138 | vite-node@2.1.9: |
| 1011 | 1139 | resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} |
| 1012 | 1140 | engines: {node: ^18.0.0 || >=20.0.0} |
@@ -1081,6 +1209,13 @@ packages: | ||
| 1081 | 1209 | engines: {node: '>=8'} |
| 1082 | 1210 | hasBin: true |
| 1083 | 1211 | |
| 1212 | + wrappy@1.0.2: | |
| 1213 | + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} | |
| 1214 | + | |
| 1215 | + xtend@4.0.2: | |
| 1216 | + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} | |
| 1217 | + engines: {node: '>=0.4'} | |
| 1218 | + | |
| 1084 | 1219 | zod@3.25.76: |
| 1085 | 1220 | resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} |
| 1086 | 1221 | |
@@ -1233,12 +1368,25 @@ snapshots: | ||
| 1233 | 1368 | '@esbuild/win32-x64@0.28.2': |
| 1234 | 1369 | optional: true |
| 1235 | 1370 | |
| 1371 | + '@fastify/accept-negotiator@2.1.0': {} | |
| 1372 | + | |
| 1236 | 1373 | '@fastify/ajv-compiler@4.0.6': |
| 1237 | 1374 | dependencies: |
| 1238 | 1375 | ajv: 8.20.0 |
| 1239 | 1376 | ajv-formats: 3.0.1(ajv@8.20.0) |
| 1240 | 1377 | fast-uri: 4.1.2 |
| 1241 | 1378 | |
| 1379 | + '@fastify/compress@8.3.1': | |
| 1380 | + dependencies: | |
| 1381 | + '@fastify/accept-negotiator': 2.1.0 | |
| 1382 | + fastify-plugin: 5.1.0 | |
| 1383 | + mime-db: 1.54.0 | |
| 1384 | + minipass: 7.1.3 | |
| 1385 | + peek-stream: 1.1.3 | |
| 1386 | + pump: 3.0.4 | |
| 1387 | + pumpify: 2.0.1 | |
| 1388 | + readable-stream: 4.7.0 | |
| 1389 | + | |
| 1242 | 1390 | '@fastify/error@4.2.0': {} |
| 1243 | 1391 | |
| 1244 | 1392 | '@fastify/fast-json-stringify-compiler@5.1.0': |
@@ -1390,6 +1538,10 @@ snapshots: | ||
| 1390 | 1538 | loupe: 3.2.1 |
| 1391 | 1539 | tinyrainbow: 1.2.0 |
| 1392 | 1540 | |
| 1541 | + abort-controller@3.0.0: | |
| 1542 | + dependencies: | |
| 1543 | + event-target-shim: 5.0.1 | |
| 1544 | + | |
| 1393 | 1545 | abstract-logging@2.0.1: {} |
| 1394 | 1546 | |
| 1395 | 1547 | ajv-formats@3.0.1(ajv@8.20.0): |
@@ -1412,8 +1564,17 @@ snapshots: | ||
| 1412 | 1564 | '@fastify/error': 4.2.0 |
| 1413 | 1565 | fastq: 1.20.1 |
| 1414 | 1566 | |
| 1567 | + base64-js@1.5.1: {} | |
| 1568 | + | |
| 1415 | 1569 | boolbase@2.0.0: {} |
| 1416 | 1570 | |
| 1571 | + buffer-from@1.1.2: {} | |
| 1572 | + | |
| 1573 | + buffer@6.0.3: | |
| 1574 | + dependencies: | |
| 1575 | + base64-js: 1.5.1 | |
| 1576 | + ieee754: 1.2.1 | |
| 1577 | + | |
| 1417 | 1578 | cac@6.7.14: {} |
| 1418 | 1579 | |
| 1419 | 1580 | chai@5.3.3: |
@@ -1428,6 +1589,8 @@ snapshots: | ||
| 1428 | 1589 | |
| 1429 | 1590 | cookie@1.1.1: {} |
| 1430 | 1591 | |
| 1592 | + core-util-is@1.0.3: {} | |
| 1593 | + | |
| 1431 | 1594 | css-select@7.0.0: |
| 1432 | 1595 | dependencies: |
| 1433 | 1596 | boolbase: 2.0.0 |
@@ -1484,6 +1647,24 @@ snapshots: | ||
| 1484 | 1647 | domelementtype: 3.0.0 |
| 1485 | 1648 | domhandler: 6.0.1 |
| 1486 | 1649 | |
| 1650 | + duplexify@3.7.1: | |
| 1651 | + dependencies: | |
| 1652 | + end-of-stream: 1.4.5 | |
| 1653 | + inherits: 2.0.4 | |
| 1654 | + readable-stream: 2.3.8 | |
| 1655 | + stream-shift: 1.0.3 | |
| 1656 | + | |
| 1657 | + duplexify@4.1.3: | |
| 1658 | + dependencies: | |
| 1659 | + end-of-stream: 1.4.5 | |
| 1660 | + inherits: 2.0.4 | |
| 1661 | + readable-stream: 3.6.2 | |
| 1662 | + stream-shift: 1.0.3 | |
| 1663 | + | |
| 1664 | + end-of-stream@1.4.5: | |
| 1665 | + dependencies: | |
| 1666 | + once: 1.4.0 | |
| 1667 | + | |
| 1487 | 1668 | entities@4.5.0: {} |
| 1488 | 1669 | |
| 1489 | 1670 | entities@7.0.1: {} |
@@ -1551,6 +1732,10 @@ snapshots: | ||
| 1551 | 1732 | dependencies: |
| 1552 | 1733 | '@types/estree': 1.0.9 |
| 1553 | 1734 | |
| 1735 | + event-target-shim@5.0.1: {} | |
| 1736 | + | |
| 1737 | + events@3.3.0: {} | |
| 1738 | + | |
| 1554 | 1739 | expect-type@1.4.0: {} |
| 1555 | 1740 | |
| 1556 | 1741 | fast-decode-uri-component@1.0.1: {} |
@@ -1574,6 +1759,8 @@ snapshots: | ||
| 1574 | 1759 | |
| 1575 | 1760 | fast-uri@4.1.2: {} |
| 1576 | 1761 | |
| 1762 | + fastify-plugin@5.1.0: {} | |
| 1763 | + | |
| 1577 | 1764 | fastify@5.11.3: |
| 1578 | 1765 | dependencies: |
| 1579 | 1766 | '@fastify/ajv-compiler': 4.0.6 |
@@ -1616,8 +1803,14 @@ snapshots: | ||
| 1616 | 1803 | domutils: 3.2.2 |
| 1617 | 1804 | entities: 7.0.1 |
| 1618 | 1805 | |
| 1806 | + ieee754@1.2.1: {} | |
| 1807 | + | |
| 1808 | + inherits@2.0.4: {} | |
| 1809 | + | |
| 1619 | 1810 | ipaddr.js@2.5.0: {} |
| 1620 | 1811 | |
| 1812 | + isarray@1.0.0: {} | |
| 1813 | + | |
| 1621 | 1814 | json-schema-ref-resolver@3.0.0: |
| 1622 | 1815 | dependencies: |
| 1623 | 1816 | dequal: 2.0.3 |
@@ -1644,6 +1837,10 @@ snapshots: | ||
| 1644 | 1837 | dependencies: |
| 1645 | 1838 | '@jridgewell/sourcemap-codec': 1.5.5 |
| 1646 | 1839 | |
| 1840 | + mime-db@1.54.0: {} | |
| 1841 | + | |
| 1842 | + minipass@7.1.3: {} | |
| 1843 | + | |
| 1647 | 1844 | ms@2.1.3: {} |
| 1648 | 1845 | |
| 1649 | 1846 | nanoid@3.3.18: {} |
@@ -1654,10 +1851,20 @@ snapshots: | ||
| 1654 | 1851 | |
| 1655 | 1852 | on-exit-leak-free@2.1.2: {} |
| 1656 | 1853 | |
| 1854 | + once@1.4.0: | |
| 1855 | + dependencies: | |
| 1856 | + wrappy: 1.0.2 | |
| 1857 | + | |
| 1657 | 1858 | pathe@1.1.2: {} |
| 1658 | 1859 | |
| 1659 | 1860 | pathval@2.0.1: {} |
| 1660 | 1861 | |
| 1862 | + peek-stream@1.1.3: | |
| 1863 | + dependencies: | |
| 1864 | + buffer-from: 1.1.2 | |
| 1865 | + duplexify: 3.7.1 | |
| 1866 | + through2: 2.0.5 | |
| 1867 | + | |
| 1661 | 1868 | picocolors@1.1.1: {} |
| 1662 | 1869 | |
| 1663 | 1870 | pino-abstract-transport@2.0.0: |
@@ -1686,12 +1893,51 @@ snapshots: | ||
| 1686 | 1893 | picocolors: 1.1.1 |
| 1687 | 1894 | source-map-js: 1.2.1 |
| 1688 | 1895 | |
| 1896 | + process-nextick-args@2.0.1: {} | |
| 1897 | + | |
| 1689 | 1898 | process-warning@4.0.1: {} |
| 1690 | 1899 | |
| 1691 | 1900 | process-warning@5.1.0: {} |
| 1692 | 1901 | |
| 1902 | + process@0.11.10: {} | |
| 1903 | + | |
| 1904 | + pump@3.0.4: | |
| 1905 | + dependencies: | |
| 1906 | + end-of-stream: 1.4.5 | |
| 1907 | + once: 1.4.0 | |
| 1908 | + | |
| 1909 | + pumpify@2.0.1: | |
| 1910 | + dependencies: | |
| 1911 | + duplexify: 4.1.3 | |
| 1912 | + inherits: 2.0.4 | |
| 1913 | + pump: 3.0.4 | |
| 1914 | + | |
| 1693 | 1915 | quick-format-unescaped@4.0.4: {} |
| 1694 | 1916 | |
| 1917 | + readable-stream@2.3.8: | |
| 1918 | + dependencies: | |
| 1919 | + core-util-is: 1.0.3 | |
| 1920 | + inherits: 2.0.4 | |
| 1921 | + isarray: 1.0.0 | |
| 1922 | + process-nextick-args: 2.0.1 | |
| 1923 | + safe-buffer: 5.1.2 | |
| 1924 | + string_decoder: 1.1.1 | |
| 1925 | + util-deprecate: 1.0.2 | |
| 1926 | + | |
| 1927 | + readable-stream@3.6.2: | |
| 1928 | + dependencies: | |
| 1929 | + inherits: 2.0.4 | |
| 1930 | + string_decoder: 1.3.0 | |
| 1931 | + util-deprecate: 1.0.2 | |
| 1932 | + | |
| 1933 | + readable-stream@4.7.0: | |
| 1934 | + dependencies: | |
| 1935 | + abort-controller: 3.0.0 | |
| 1936 | + buffer: 6.0.3 | |
| 1937 | + events: 3.3.0 | |
| 1938 | + process: 0.11.10 | |
| 1939 | + string_decoder: 1.3.0 | |
| 1940 | + | |
| 1695 | 1941 | real-require@0.2.0: {} |
| 1696 | 1942 | |
| 1697 | 1943 | require-from-string@2.0.2: {} |
@@ -1734,6 +1980,10 @@ snapshots: | ||
| 1734 | 1980 | '@rollup/rollup-win32-x64-msvc': 4.62.4 |
| 1735 | 1981 | fsevents: 2.3.3 |
| 1736 | 1982 | |
| 1983 | + safe-buffer@5.1.2: {} | |
| 1984 | + | |
| 1985 | + safe-buffer@5.2.1: {} | |
| 1986 | + | |
| 1737 | 1987 | safe-regex2@5.1.1: |
| 1738 | 1988 | dependencies: |
| 1739 | 1989 | ret: 0.5.0 |
@@ -1760,10 +2010,25 @@ snapshots: | ||
| 1760 | 2010 | |
| 1761 | 2011 | std-env@3.10.0: {} |
| 1762 | 2012 | |
| 2013 | + stream-shift@1.0.3: {} | |
| 2014 | + | |
| 2015 | + string_decoder@1.1.1: | |
| 2016 | + dependencies: | |
| 2017 | + safe-buffer: 5.1.2 | |
| 2018 | + | |
| 2019 | + string_decoder@1.3.0: | |
| 2020 | + dependencies: | |
| 2021 | + safe-buffer: 5.2.1 | |
| 2022 | + | |
| 1763 | 2023 | thread-stream@3.2.0: |
| 1764 | 2024 | dependencies: |
| 1765 | 2025 | real-require: 0.2.0 |
| 1766 | 2026 | |
| 2027 | + through2@2.0.5: | |
| 2028 | + dependencies: | |
| 2029 | + readable-stream: 2.3.8 | |
| 2030 | + xtend: 4.0.2 | |
| 2031 | + | |
| 1767 | 2032 | tinybench@2.9.0: {} |
| 1768 | 2033 | |
| 1769 | 2034 | tinyexec@0.3.2: {} |
@@ -1798,6 +2063,8 @@ snapshots: | ||
| 1798 | 2063 | |
| 1799 | 2064 | undici@7.29.0: {} |
| 1800 | 2065 | |
| 2066 | + util-deprecate@1.0.2: {} | |
| 2067 | + | |
| 1801 | 2068 | vite-node@2.1.9(@types/node@22.20.1): |
| 1802 | 2069 | dependencies: |
| 1803 | 2070 | cac: 6.7.14 |
@@ -1876,4 +2143,8 @@ snapshots: | ||
| 1876 | 2143 | siginfo: 2.0.0 |
| 1877 | 2144 | stackback: 0.0.2 |
| 1878 | 2145 | |
| 2146 | + wrappy@1.0.2: {} | |
| 2147 | + | |
| 2148 | + xtend@4.0.2: {} | |
| 2149 | + | |
| 1879 | 2150 | zod@3.25.76: {} |
added
scripts/deploy.sh
+31 −0
@@ -0,0 +1,31 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# author: simon-pierre boucher <contact@spboucher.ai> | |
| 3 | +# Deploy Tendril to the production node (§16.4). Idempotent: rsync source, | |
| 4 | +# install, build, (re)start PM2, then smoke-test through the public URL. | |
| 5 | +set -euo pipefail | |
| 6 | + | |
| 7 | +NODE="${TENDRIL_NODE:-M3U96a}" | |
| 8 | +DIR="${TENDRIL_DIR:-apps/tendril}" | |
| 9 | +PORT="${TENDRIL_PORT:-8092}" | |
| 10 | +PUBLIC_URL="${TENDRIL_PUBLIC_URL:-https://www.ten-dril.com}" | |
| 11 | +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" | |
| 12 | + | |
| 13 | +echo "==> rsync source to ${NODE}:${DIR}" | |
| 14 | +ssh "$NODE" "mkdir -p ~/${DIR}" | |
| 15 | +rsync -az --delete \ | |
| 16 | + --exclude node_modules --exclude '.git' --exclude 'dist' \ | |
| 17 | + --exclude '*.tsbuildinfo' --exclude 'coverage' --exclude 'blobs' \ | |
| 18 | + "$HERE"/ "${NODE}:${DIR}/" | |
| 19 | + | |
| 20 | +echo "==> install + build on ${NODE}" | |
| 21 | +ssh "$NODE" "cd ~/${DIR} && pnpm install --frozen-lockfile && pnpm build" | |
| 22 | + | |
| 23 | +echo "==> (re)start PM2" | |
| 24 | +ssh "$NODE" "cd ~/${DIR} && TENDRIL_DIR=\$HOME/${DIR} TENDRIL_PORT=${PORT} pm2 startOrReload deploy/ecosystem.config.cjs --update-env && pm2 save" | |
| 25 | + | |
| 26 | +echo "==> local health" | |
| 27 | +ssh "$NODE" "for i in \$(seq 1 40); do curl -sf http://127.0.0.1:${PORT}/healthz >/dev/null && { echo healthy; break; }; sleep 0.5; done" | |
| 28 | + | |
| 29 | +echo "==> smoke test through ${PUBLIC_URL}" | |
| 30 | +"$HERE/scripts/health.sh" "$PUBLIC_URL" | |
| 31 | +echo "==> done" | |
added
scripts/health.sh
+19 −0
@@ -0,0 +1,19 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# author: simon-pierre boucher <contact@spboucher.ai> | |
| 3 | +# Smoke-test a running Tendril deployment through its public URL (§16.4, §20). | |
| 4 | +set -euo pipefail | |
| 5 | +BASE="${1:-https://www.ten-dril.com}" | |
| 6 | + | |
| 7 | +echo "==> GET ${BASE}/healthz" | |
| 8 | +curl -sf "${BASE}/healthz" | grep -q '"status":"ok"' && echo " healthz ok" | |
| 9 | + | |
| 10 | +echo "==> POST ${BASE}/v1/scrape (iana.org)" | |
| 11 | +body="$(curl -sf -X POST "${BASE}/v1/scrape" -H 'content-type: application/json' \ | |
| 12 | + -d '{"url":"https://www.iana.org","formats":["markdown"]}')" | |
| 13 | +echo "$body" | grep -q "Internet Assigned Numbers Authority" && echo " scrape ok (found expected string)" | |
| 14 | + | |
| 15 | +echo "==> POST ${BASE}/v1/map (iana.org)" | |
| 16 | +curl -sf -X POST "${BASE}/v1/map" -H 'content-type: application/json' \ | |
| 17 | + -d '{"url":"https://www.iana.org","limit":5}' | grep -q '"success":true' && echo " map ok" | |
| 18 | + | |
| 19 | +echo "all smoke checks passed" | |
| 20 | ||