SPB Git

spb/tendril Public

Tendril — web ingestion platform (scrape/crawl/map/search) on macOS Apple Silicon: WebKit fidelity, authenticated pages, deterministic testable extraction. A self-hosted Firecrawl alternative.

JavaScript 82.6% TypeScript 11.8% HTML 5.3%

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>
Simon-Pierre Boucher committed 10 h ago (Aug 10, 2026) parent 80212a5

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&amp;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(/&lt;/g, "<")
16 + .replace(/&gt;/g, ">")
17 + .replace(/&quot;/g, '"')
18 + .replace(/&#39;/g, "'")
19 + .replace(/&apos;/g, "'")
20 + .replace(/&amp;/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