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(api): serve interactive playground + docs platform at /

Self-contained landing page served via @fastify/static (apps/api/public):
- hero with Tendril identity (botanical/terminal theme, animated vine SVG)
- live playground: scrape/map/status, formats & options, latency, rendered
  markdown preview, links/metadata/structured tabs, curl/python/js snippets
  with copy, live /v1/status pill
- collapsible API reference with param tables + examples, §15 error table
Explicit API routes keep priority over the static wildcard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 11 h ago (Aug 10, 2026) parent 62ea614

Showing 4 changed files with +780 and −0

modified apps/api/package.json +1 −0
@@ -16,6 +16,7 @@
16 16 "@tendril/extract": "workspace:*",
17 17 "@tendril/frontier": "workspace:*",
18 18 "@fastify/compress": "^8.0.1",
19 + "@fastify/static": "^8.0.3",
19 20 "fastify": "^5.2.0",
20 21 "zod": "^3.24.1"
21 22 }
added apps/api/public/index.html +552 −0
@@ -0,0 +1,552 @@
1 +<!doctype html>
2 +<!-- author: simon-pierre boucher <contact@spboucher.ai> -->
3 +<html lang="en" data-theme="dark">
4 +<head>
5 +<meta charset="utf-8" />
6 +<meta name="viewport" content="width=device-width, initial-scale=1" />
7 +<title>Tendril — web ingestion that reaches what others can't</title>
8 +<meta name="description" content="Tendril is a web ingestion platform (scrape · map) running on residential macOS in WebKit. Deterministic, testable extraction. Try it in the playground." />
9 +<meta property="og:title" content="Tendril" />
10 +<meta property="og:description" content="Web ingestion that reaches what others can't. Scrape and map the web with deterministic, testable extraction." />
11 +<meta property="og:type" content="website" />
12 +<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ctext y='26' font-size='26'%3E%F0%9F%8C%B1%3C/text%3E%3C/svg%3E" />
13 +<link rel="preconnect" href="https://fonts.googleapis.com" />
14 +<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
15 +<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Inter:wght@400;450;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
16 +<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github-dark.min.css" />
17 +<style>
18 + :root {
19 + --bg: #07100c;
20 + --bg-2: #0b1712;
21 + --panel: rgba(18, 32, 26, 0.72);
22 + --panel-solid: #101b16;
23 + --line: rgba(120, 200, 160, 0.16);
24 + --line-strong: rgba(120, 200, 160, 0.32);
25 + --ink: #e7f3ec;
26 + --muted: #8fae9f;
27 + --dim: #5f7a6d;
28 + --vine: #4ade80;
29 + --vine-2: #2dd4bf;
30 + --amber: #f5c451;
31 + --danger: #ff6b6b;
32 + --radius: 16px;
33 + --mono: "JetBrains Mono", ui-monospace, monospace;
34 + --sans: "Inter", system-ui, sans-serif;
35 + --display: "Space Grotesk", var(--sans);
36 + --shadow: 0 24px 60px -30px rgba(0, 0, 0, 0.9);
37 + }
38 + * { box-sizing: border-box; }
39 + html { scroll-behavior: smooth; }
40 + body {
41 + margin: 0; background: var(--bg); color: var(--ink); font-family: var(--sans);
42 + font-size: 15px; line-height: 1.6; -webkit-font-smoothing: antialiased;
43 + background-image:
44 + radial-gradient(1100px 600px at 82% -8%, rgba(45, 212, 191, 0.10), transparent 60%),
45 + radial-gradient(900px 520px at 4% 6%, rgba(74, 222, 128, 0.09), transparent 55%);
46 + background-attachment: fixed;
47 + }
48 + a { color: inherit; text-decoration: none; }
49 + .wrap { max-width: 1180px; margin: 0 auto; padding: 0 24px; }
50 +
51 + /* header */
52 + header {
53 + position: sticky; top: 0; z-index: 50; backdrop-filter: blur(14px);
54 + background: linear-gradient(to bottom, rgba(7,16,12,0.9), rgba(7,16,12,0.55));
55 + border-bottom: 1px solid var(--line);
56 + }
57 + .nav { display: flex; align-items: center; gap: 20px; height: 62px; }
58 + .brand { display: flex; align-items: center; gap: 10px; font-family: var(--display); font-weight: 700; letter-spacing: -0.01em; font-size: 18px; }
59 + .brand .leaf { font-size: 20px; filter: drop-shadow(0 0 10px rgba(74,222,128,0.5)); }
60 + .brand b { color: var(--vine); }
61 + .nav .spacer { flex: 1; }
62 + .nav a.link { color: var(--muted); font-weight: 500; font-size: 14px; padding: 8px 10px; border-radius: 9px; }
63 + .nav a.link:hover { color: var(--ink); background: rgba(120,200,160,0.08); }
64 + .pill {
65 + font-family: var(--mono); font-size: 12px; color: var(--vine);
66 + border: 1px solid var(--line-strong); border-radius: 999px; padding: 5px 11px;
67 + display: inline-flex; align-items: center; gap: 7px; background: rgba(74,222,128,0.06);
68 + }
69 + .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--vine); box-shadow: 0 0 9px var(--vine); animation: pulse 2s infinite; }
70 + @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.35} }
71 +
72 + /* hero */
73 + .hero { padding: 74px 0 40px; position: relative; overflow: hidden; }
74 + .kicker { font-family: var(--mono); font-size: 12.5px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--vine-2); margin-bottom: 18px; }
75 + h1 { font-family: var(--display); font-size: clamp(38px, 6vw, 68px); line-height: 1.02; letter-spacing: -0.03em; margin: 0 0 18px; font-weight: 700; }
76 + h1 .grad { background: linear-gradient(120deg, var(--vine), var(--vine-2) 60%, var(--amber)); -webkit-background-clip: text; background-clip: text; color: transparent; }
77 + .lede { font-size: 19px; color: var(--muted); max-width: 640px; margin: 0 0 28px; }
78 + .cta-row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
79 + .btn {
80 + font-family: var(--display); font-weight: 600; font-size: 15px; cursor: pointer;
81 + border-radius: 12px; padding: 12px 20px; border: 1px solid var(--line-strong);
82 + background: var(--panel-solid); color: var(--ink); transition: .15s; display: inline-flex; align-items: center; gap: 9px;
83 + }
84 + .btn:hover { transform: translateY(-1px); border-color: var(--vine); }
85 + .btn.primary { background: linear-gradient(120deg, var(--vine), var(--vine-2)); color: #05130c; border: none; box-shadow: 0 14px 34px -14px rgba(74,222,128,0.6); }
86 + .btn.primary:hover { filter: brightness(1.06); }
87 + .stats { display: flex; gap: 30px; flex-wrap: wrap; margin-top: 40px; }
88 + .stat .n { font-family: var(--display); font-size: 26px; font-weight: 700; color: var(--ink); }
89 + .stat .l { font-size: 12.5px; color: var(--dim); font-family: var(--mono); text-transform: uppercase; letter-spacing: 0.08em; }
90 + .vine-svg { position: absolute; right: -40px; top: 20px; width: 420px; max-width: 46%; opacity: 0.5; pointer-events: none; }
91 + .vine-svg path { fill: none; stroke: url(#vg); stroke-width: 2; stroke-linecap: round; stroke-dasharray: 1400; stroke-dashoffset: 1400; animation: grow 3.2s ease forwards; }
92 + @keyframes grow { to { stroke-dashoffset: 0; } }
93 +
94 + section { padding: 40px 0; }
95 + .sec-head { display: flex; align-items: baseline; gap: 14px; margin-bottom: 22px; }
96 + .sec-head h2 { font-family: var(--display); font-size: 26px; letter-spacing: -0.02em; margin: 0; }
97 + .sec-head .tag { font-family: var(--mono); font-size: 12px; color: var(--dim); }
98 +
99 + /* playground */
100 + .pg { display: grid; grid-template-columns: 1fr 1.15fr; gap: 20px; }
101 + @media (max-width: 900px){ .pg { grid-template-columns: 1fr; } .vine-svg{ display:none } }
102 + .card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); backdrop-filter: blur(8px); }
103 + .card .hd { padding: 15px 18px; border-bottom: 1px solid var(--line); display: flex; align-items: center; gap: 10px; }
104 + .card .hd h3 { font-family: var(--display); font-size: 15px; margin: 0; letter-spacing: -0.01em; }
105 + .card .bd { padding: 18px; }
106 + .seg { display: inline-flex; background: rgba(10,20,15,0.7); border: 1px solid var(--line); border-radius: 11px; padding: 4px; gap: 3px; }
107 + .seg button { font-family: var(--mono); font-size: 13px; color: var(--muted); background: none; border: 0; padding: 7px 14px; border-radius: 8px; cursor: pointer; }
108 + .seg button.on { background: linear-gradient(120deg, rgba(74,222,128,0.9), rgba(45,212,191,0.9)); color: #05130c; font-weight: 600; }
109 + label.f { display: block; font-size: 12.5px; color: var(--muted); font-family: var(--mono); margin: 16px 0 7px; letter-spacing: 0.02em; }
110 + input[type=text], input[type=number], select {
111 + width: 100%; background: rgba(6,14,10,0.85); border: 1px solid var(--line); color: var(--ink);
112 + border-radius: 10px; padding: 11px 13px; font-family: var(--mono); font-size: 13.5px; outline: none; transition: .15s;
113 + }
114 + input:focus, select:focus { border-color: var(--vine); box-shadow: 0 0 0 3px rgba(74,222,128,0.12); }
115 + .chips { display: flex; flex-wrap: wrap; gap: 8px; }
116 + .chk { font-family: var(--mono); font-size: 12.5px; display: inline-flex; align-items: center; gap: 7px; padding: 8px 12px; border: 1px solid var(--line); border-radius: 9px; cursor: pointer; color: var(--muted); user-select: none; }
117 + .chk input { accent-color: var(--vine); }
118 + .chk.on { border-color: var(--vine); color: var(--ink); background: rgba(74,222,128,0.07); }
119 + .row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
120 + .send-row { display: flex; align-items: center; gap: 12px; margin-top: 20px; }
121 + .meta-line { font-family: var(--mono); font-size: 12px; color: var(--dim); }
122 + .badge { font-family: var(--mono); font-size: 11px; padding: 3px 8px; border-radius: 7px; border: 1px solid var(--line); color: var(--muted); }
123 + .badge.ok { color: var(--vine); border-color: var(--line-strong); }
124 + .badge.err { color: var(--danger); border-color: rgba(255,107,107,0.4); }
125 +
126 + .tabs { display: flex; gap: 4px; flex-wrap: wrap; }
127 + .tabs button { font-family: var(--mono); font-size: 12.5px; color: var(--muted); background: none; border: 1px solid transparent; padding: 6px 11px; border-radius: 8px; cursor: pointer; }
128 + .tabs button.on { color: var(--ink); border-color: var(--line); background: rgba(10,20,15,0.6); }
129 + .out { min-height: 260px; max-height: 560px; overflow: auto; }
130 + .out pre { margin: 0; }
131 + pre code.hljs { background: transparent !important; font-family: var(--mono); font-size: 12.8px; line-height: 1.55; padding: 16px 18px; display: block; }
132 + .rendered { padding: 18px; font-size: 14.5px; }
133 + .rendered h1,.rendered h2,.rendered h3 { font-family: var(--display); line-height: 1.2; }
134 + .rendered a { color: var(--vine-2); border-bottom: 1px solid rgba(45,212,191,0.3); }
135 + .rendered pre { background: rgba(6,14,10,0.9); border: 1px solid var(--line); border-radius: 10px; padding: 12px; overflow: auto; }
136 + .rendered table { border-collapse: collapse; width: 100%; font-size: 13px; }
137 + .rendered th, .rendered td { border: 1px solid var(--line); padding: 6px 10px; text-align: left; }
138 + .rendered img { max-width: 100%; border-radius: 8px; }
139 + .placeholder { color: var(--dim); font-family: var(--mono); font-size: 13px; padding: 40px 18px; text-align: center; }
140 + .linkrow { display: flex; align-items: center; gap: 10px; padding: 9px 14px; border-bottom: 1px solid var(--line); font-family: var(--mono); font-size: 12.5px; }
141 + .linkrow a { color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
142 + .src { font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.06em; padding: 2px 7px; border-radius: 6px; border: 1px solid var(--line); color: var(--vine-2); }
143 + .copybtn { font-family: var(--mono); font-size: 11.5px; cursor: pointer; background: rgba(10,20,15,0.6); border: 1px solid var(--line); color: var(--muted); padding: 4px 9px; border-radius: 7px; }
144 + .copybtn:hover { color: var(--vine); border-color: var(--vine); }
145 + .spin { width: 15px; height: 15px; border: 2px solid rgba(255,255,255,0.2); border-top-color: var(--vine); border-radius: 50%; animation: sp .7s linear infinite; }
146 + @keyframes sp { to { transform: rotate(360deg); } }
147 +
148 + /* docs */
149 + .ep { border: 1px solid var(--line); border-radius: var(--radius); margin-bottom: 16px; overflow: hidden; background: var(--panel); }
150 + .ep .top { display: flex; align-items: center; gap: 12px; padding: 15px 18px; cursor: pointer; }
151 + .method { font-family: var(--mono); font-weight: 600; font-size: 12px; padding: 4px 9px; border-radius: 7px; background: rgba(74,222,128,0.14); color: var(--vine); }
152 + .method.get { background: rgba(45,212,191,0.14); color: var(--vine-2); }
153 + .path { font-family: var(--mono); font-size: 14px; }
154 + .ep .desc { color: var(--muted); font-size: 13px; margin-left: auto; }
155 + .ep .body { padding: 0 18px 18px; display: none; }
156 + .ep.open .body { display: block; }
157 + .ptable { width: 100%; border-collapse: collapse; font-size: 13px; margin: 8px 0 4px; }
158 + .ptable th { text-align: left; color: var(--dim); font-family: var(--mono); font-weight: 500; font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.06em; padding: 6px 8px; border-bottom: 1px solid var(--line); }
159 + .ptable td { padding: 7px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
160 + .ptable code { font-family: var(--mono); color: var(--vine-2); font-size: 12.5px; }
161 + .req-opt { font-size: 10.5px; color: var(--amber); font-family: var(--mono); }
162 + .errtbl { width: 100%; border-collapse: collapse; font-size: 12.5px; }
163 + .errtbl td, .errtbl th { padding: 6px 10px; border-bottom: 1px solid var(--line); text-align: left; }
164 + .errtbl code { color: var(--danger); font-family: var(--mono); }
165 + footer { border-top: 1px solid var(--line); padding: 34px 0 60px; color: var(--dim); font-size: 13px; margin-top: 30px; }
166 + footer a { color: var(--muted); } footer a:hover { color: var(--vine); }
167 + .grid3 { display: grid; grid-template-columns: repeat(3,1fr); gap: 16px; }
168 + @media (max-width:760px){ .grid3{ grid-template-columns:1fr } .row2{ grid-template-columns:1fr } }
169 + .feat { padding: 18px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); }
170 + .feat .i { font-size: 22px; margin-bottom: 10px; }
171 + .feat h4 { font-family: var(--display); margin: 0 0 6px; font-size: 16px; }
172 + .feat p { margin: 0; color: var(--muted); font-size: 13.5px; }
173 + .note { font-size: 12.5px; color: var(--amber); font-family: var(--mono); background: rgba(245,196,81,0.07); border: 1px solid rgba(245,196,81,0.25); border-radius: 10px; padding: 10px 13px; margin-top: 14px; }
174 +</style>
175 +</head>
176 +<body>
177 +<header>
178 + <div class="wrap nav">
179 + <a class="brand" href="#top"><span class="leaf">🌱</span> ten<b>·</b>dril</a>
180 + <span class="spacer"></span>
181 + <a class="link" href="#playground">Playground</a>
182 + <a class="link" href="#docs">Docs</a>
183 + <a class="link" href="https://git.spboucher.ai/tendril" target="_blank" rel="noopener">Source</a>
184 + <span class="pill" id="statusPill"><span class="dot"></span> checking…</span>
185 + </div>
186 +</header>
187 +
188 +<a id="top"></a>
189 +<div class="hero">
190 + <div class="wrap">
191 + <svg class="vine-svg" viewBox="0 0 300 300" aria-hidden="true">
192 + <defs><linearGradient id="vg" x1="0" y1="0" x2="1" y2="1">
193 + <stop offset="0" stop-color="#4ade80"/><stop offset="0.6" stop-color="#2dd4bf"/><stop offset="1" stop-color="#f5c451"/>
194 + </linearGradient></defs>
195 + <path d="M150 300 C150 240 120 220 150 180 C180 140 120 120 150 80 C170 50 150 30 150 0" />
196 + <path d="M150 220 C120 210 100 190 85 200 C70 210 80 230 100 225" />
197 + <path d="M150 150 C185 140 205 120 220 132 C235 144 224 165 202 158" />
198 + <path d="M150 95 C120 86 104 66 90 78 C78 88 86 108 106 102" />
199 + </svg>
200 + <div class="kicker">Web ingestion · scrape · map</div>
201 + <h1>Reach what other<br />crawlers <span class="grad">can't.</span></h1>
202 + <p class="lede">Tendril runs on a residential Mac, in WebKit — Safari's real engine — behind a real IP. Extraction is <strong>deterministic and testable</strong>: the same page always yields the same markdown. No cloud fingerprint, no LLM lottery.</p>
203 + <div class="cta-row">
204 + <a class="btn primary" href="#playground">▶ Open the playground</a>
205 + <a class="btn" href="#docs">Read the docs</a>
206 + </div>
207 + <div class="stats">
208 + <div class="stat"><div class="n">3</div><div class="l">escalation tiers</div></div>
209 + <div class="stat"><div class="n">0</div><div class="l">LLMs in extraction</div></div>
210 + <div class="stat"><div class="n">~50ms</div><div class="l">Tier 0 p50</div></div>
211 + <div class="stat"><div class="n">WebKit</div><div class="l">real Safari engine</div></div>
212 + </div>
213 + </div>
214 +</div>
215 +
216 +<section class="wrap">
217 + <div class="grid3">
218 + <div class="feat"><div class="i">🕸️</div><h4>Browser fidelity</h4><p>WebKit on macOS ARM behind a residential IP — not a headless Chromium signature. Sites that block others often don't block Tendril.</p></div>
219 + <div class="feat"><div class="i">📐</div><h4>Deterministic extraction</h4><p>JSON-LD, OpenGraph, Readability + density fallback, hand-written Turndown rules. Versioned, golden-file testable output.</p></div>
220 + <div class="feat"><div class="i">🧭</div><h4>Map without rendering</h4><p>Merge sitemaps, robots directives, homepage links and <code>/llms.txt</code> into one deduplicated URL list in seconds.</p></div>
221 + </div>
222 +</section>
223 +
224 +<section class="wrap" id="playground">
225 + <div class="sec-head"><h2>Playground</h2><span class="tag">// live requests against this deployment</span></div>
226 + <div class="pg">
227 + <div class="card">
228 + <div class="hd"><h3>Request</h3><span class="spacer" style="flex:1"></span>
229 + <div class="seg" id="epseg">
230 + <button data-ep="scrape" class="on">scrape</button>
231 + <button data-ep="map">map</button>
232 + <button data-ep="status">status</button>
233 + </div>
234 + </div>
235 + <div class="bd">
236 + <div id="urlblock">
237 + <label class="f">Target URL</label>
238 + <input type="text" id="url" value="https://www.iana.org" spellcheck="false" placeholder="https://example.com" />
239 + </div>
240 +
241 + <div id="scrapeOpts">
242 + <label class="f">Formats</label>
243 + <div class="chips" id="formats">
244 + <label class="chk on"><input type="checkbox" value="markdown" checked>markdown</label>
245 + <label class="chk"><input type="checkbox" value="links">links</label>
246 + <label class="chk"><input type="checkbox" value="structured">structured</label>
247 + <label class="chk"><input type="checkbox" value="html">html</label>
248 + </div>
249 + <div class="row2">
250 + <div><label class="f">Tier</label>
251 + <select id="tier"><option value="auto">auto</option><option value="http">http (Tier 0)</option><option value="webkit">webkit</option><option value="safari">safari</option></select>
252 + </div>
253 + <div><label class="f">Options</label>
254 + <div class="chips">
255 + <label class="chk on"><input type="checkbox" id="onlyMain" checked>onlyMainContent</label>
256 + <label class="chk on"><input type="checkbox" id="respRobots" checked>respectRobots</label>
257 + </div>
258 + </div>
259 + </div>
260 + </div>
261 +
262 + <div id="mapOpts" style="display:none">
263 + <div class="row2">
264 + <div><label class="f">Search filter</label><input type="text" id="mapSearch" placeholder="optional substring" spellcheck="false"></div>
265 + <div><label class="f">Limit</label><input type="number" id="mapLimit" value="30" min="1" max="50000"></div>
266 + </div>
267 + <label class="f">Options</label>
268 + <div class="chips">
269 + <label class="chk"><input type="checkbox" id="sitemapOnly">sitemapOnly</label>
270 + <label class="chk"><input type="checkbox" id="ignoreSitemap">ignoreSitemap</label>
271 + <label class="chk"><input type="checkbox" id="subdomains">includeSubdomains</label>
272 + </div>
273 + </div>
274 +
275 + <div id="statusOpts" style="display:none">
276 + <p class="meta-line">No parameters. Returns per-tier availability and pipeline version.</p>
277 + </div>
278 +
279 + <div class="send-row">
280 + <button class="btn primary" id="send">▶ Send request</button>
281 + <span id="reqmeta" class="meta-line"></span>
282 + </div>
283 + <div class="note" id="autoNote" style="display:none">Tiers 1 &amp; 2 (WebKit/Safari) aren't deployed yet — a page needing escalation under <b>tier: auto</b> returns <code>ERR_TARGET_BLOCKED</code>. Force <b>tier: http</b> for raw Tier 0 output.</div>
284 + </div>
285 + </div>
286 +
287 + <div class="card">
288 + <div class="hd"><h3>Response</h3><span class="spacer" style="flex:1"></span><span id="respStatus"></span></div>
289 + <div class="bd" style="padding-top:14px">
290 + <div class="tabs" id="tabs"></div>
291 + <div class="out" id="out"><div class="placeholder">Send a request to see the response, rendered markdown, extracted links and ready-to-copy snippets.</div></div>
292 + </div>
293 + </div>
294 + </div>
295 +</section>
296 +
297 +<section class="wrap" id="docs">
298 + <div class="sec-head"><h2>API reference</h2><span class="tag">// base: this origin · Bearer auth coming with accounts</span></div>
299 + <div id="epdocs"></div>
300 +
301 + <div class="sec-head" style="margin-top:30px"><h2>Errors</h2><span class="tag">// §15 taxonomy — one code per failure</span></div>
302 + <div class="card"><div class="bd">
303 + <table class="errtbl"><tbody>
304 + <tr><td><code>ERR_INVALID_URL</code></td><td>400</td><td>Malformed or unsupported scheme</td></tr>
305 + <tr><td><code>ERR_SSRF_BLOCKED</code></td><td>400</td><td>Resolved to a private/forbidden address</td></tr>
306 + <tr><td><code>ERR_ROBOTS_DENIED</code></td><td>403</td><td>robots.txt disallows the path</td></tr>
307 + <tr><td><code>ERR_TARGET_BLOCKED</code></td><td>502</td><td>Tier 0 insufficient; higher tiers unavailable</td></tr>
308 + <tr><td><code>ERR_UNSUPPORTED_TYPE</code></td><td>415</td><td>Content type not handled</td></tr>
309 + <tr><td><code>ERR_TIER_TIMEOUT</code></td><td>504</td><td>Render exceeded timeout</td></tr>
310 + </tbody></table>
311 + <p class="meta-line" style="margin-top:12px">Every response carries a <code>requestId</code> and an <code>X-Request-Id</code> header for support.</p>
312 + </div></div>
313 +</section>
314 +
315 +<footer>
316 + <div class="wrap">
317 + <div style="display:flex;gap:20px;flex-wrap:wrap;align-items:center">
318 + <span class="brand"><span class="leaf">🌱</span> ten·dril</span>
319 + <span class="spacer" style="flex:1"></span>
320 + <a href="https://git.spboucher.ai/tendril" target="_blank" rel="noopener">Source</a>
321 + <a href="/healthz">/healthz</a>
322 + <a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a>
323 + </div>
324 + <p style="margin-top:16px;max-width:640px">Tendril identifies itself with a bot User-Agent and a contact URL, honours <code>robots.txt</code> by default, and rate-limits per host. Stealth here means matching a real browser's fingerprint — not hiding who we are. Built by Simon-Pierre Boucher.</p>
325 + </div>
326 +</footer>
327 +
328 +<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"></script>
329 +<script src="https://cdn.jsdelivr.net/npm/marked@12.0.0/marked.min.js"></script>
330 +<script>
331 +const $ = (s, r=document) => r.querySelector(s);
332 +const $$ = (s, r=document) => [...r.querySelectorAll(s)];
333 +let endpoint = "scrape";
334 +let lastResult = null;
335 +let activeTab = "preview";
336 +
337 +// status pill
338 +fetch("/v1/status").then(r=>r.json()).then(j=>{
339 + const p = $("#statusPill");
340 + const http = j?.data?.tiers?.http?.available;
341 + p.innerHTML = `<span class="dot"></span> ${http ? "live" : "degraded"} · v${j?.data?.pipelineVersion||"?"}`;
342 +}).catch(()=>{ $("#statusPill").innerHTML = '<span class="dot" style="background:#ff6b6b;box-shadow:0 0 9px #ff6b6b"></span> offline'; });
343 +
344 +// endpoint switch
345 +$$("#epseg button").forEach(b => b.onclick = () => {
346 + $$("#epseg button").forEach(x=>x.classList.remove("on")); b.classList.add("on");
347 + endpoint = b.dataset.ep;
348 + $("#scrapeOpts").style.display = endpoint==="scrape" ? "" : "none";
349 + $("#mapOpts").style.display = endpoint==="map" ? "" : "none";
350 + $("#statusOpts").style.display = endpoint==="status" ? "" : "none";
351 + $("#urlblock").style.display = endpoint==="status" ? "none" : "";
352 +});
353 +
354 +// chip toggles
355 +$$(".chk").forEach(c => { const i = c.querySelector("input"); if(i) i.onchange = () => c.classList.toggle("on", i.checked); });
356 +$("#tier").onchange = e => $("#autoNote").style.display = e.target.value==="auto" ? "" : "none";
357 +
358 +function buildBody() {
359 + if (endpoint === "status") return null;
360 + const url = $("#url").value.trim();
361 + if (endpoint === "scrape") {
362 + const formats = $$("#formats input:checked").map(i=>i.value);
363 + return { url, formats, tier: $("#tier").value, onlyMainContent: $("#onlyMain").checked, respectRobots: $("#respRobots").checked };
364 + }
365 + const b = { url, limit: Number($("#mapLimit").value)||30, sitemapOnly: $("#sitemapOnly").checked, ignoreSitemap: $("#ignoreSitemap").checked, includeSubdomains: $("#subdomains").checked };
366 + const s = $("#mapSearch").value.trim(); if (s) b.search = s;
367 + return b;
368 +}
369 +
370 +function snippets(path, body) {
371 + const origin = location.origin;
372 + if (!body) return {
373 + curl: `curl -s ${origin}${path}`,
374 + python: `import requests\nprint(requests.get("${origin}${path}").json())`,
375 + js: `const r = await fetch("${origin}${path}");\nconsole.log(await r.json());`
376 + };
377 + const j = JSON.stringify(body, null, 2);
378 + return {
379 + curl: `curl -s -X POST ${origin}${path} \\\n -H 'content-type: application/json' \\\n -d '${JSON.stringify(body)}'`,
380 + python: `import requests\nr = requests.post("${origin}${path}", json=${j.replace(/true/g,"True").replace(/false/g,"False")})\nprint(r.json())`,
381 + js: `const r = await fetch("${origin}${path}", {\n method: "POST",\n headers: { "content-type": "application/json" },\n body: JSON.stringify(${j})\n});\nconsole.log(await r.json());`
382 + };
383 +}
384 +
385 +function setTabs(tabs) {
386 + const t = $("#tabs"); t.innerHTML = "";
387 + tabs.forEach(name => {
388 + const b = document.createElement("button");
389 + b.textContent = name; b.className = name===activeTab ? "on" : "";
390 + b.onclick = () => { activeTab = name; render(); };
391 + t.appendChild(b);
392 + });
393 +}
394 +
395 +function code(text, lang) {
396 + const pre = document.createElement("pre");
397 + const c = document.createElement("code");
398 + c.className = "hljs" + (lang ? " language-"+lang : "");
399 + c.textContent = text;
400 + pre.appendChild(c);
401 + try { hljs.highlightElement(c); } catch(e){}
402 + return pre;
403 +}
404 +function copyBar(text) {
405 + const bar = document.createElement("div");
406 + bar.style.cssText = "display:flex;justify-content:flex-end;padding:8px 12px 0";
407 + const b = document.createElement("button"); b.className="copybtn"; b.textContent="⧉ copy";
408 + b.onclick = () => { navigator.clipboard.writeText(text); b.textContent="✓ copied"; setTimeout(()=>b.textContent="⧉ copy",1200); };
409 + bar.appendChild(b); return bar;
410 +}
411 +
412 +function render() {
413 + const out = $("#out"); out.innerHTML = "";
414 + if (!lastResult) return;
415 + const { data, raw, snips } = lastResult;
416 +
417 + if (activeTab === "preview") {
418 + if (endpoint === "map") {
419 + const links = data?.data?.links || [];
420 + if (!links.length) { out.innerHTML = '<div class="placeholder">No links found.</div>'; return; }
421 + const head = document.createElement("div"); head.style.cssText="padding:10px 14px;color:var(--dim);font-family:var(--mono);font-size:12px";
422 + head.textContent = `${data.data.count} URLs discovered`;
423 + out.appendChild(head);
424 + links.forEach(l => {
425 + const r = document.createElement("div"); r.className="linkrow";
426 + r.innerHTML = `<span class="src">${l.source}</span><a href="${l.url}" target="_blank" rel="noopener">${l.url}</a>`;
427 + out.appendChild(r);
428 + });
429 + } else if (endpoint === "status") {
430 + out.appendChild(code(JSON.stringify(data, null, 2), "json"));
431 + } else {
432 + const d = data?.data;
433 + if (d?.markdown) {
434 + const div = document.createElement("div"); div.className="rendered";
435 + div.innerHTML = marked.parse(d.markdown);
436 + out.appendChild(div);
437 + } else if (d?.html) {
438 + out.appendChild(code(d.html.slice(0, 20000), "xml"));
439 + } else {
440 + out.appendChild(code(JSON.stringify(data, null, 2), "json"));
441 + }
442 + }
443 + } else if (activeTab === "links") {
444 + const links = lastResult.data?.data?.links || [];
445 + if (!links.length) { out.innerHTML = '<div class="placeholder">No links in this response.</div>'; return; }
446 + links.forEach(l => {
447 + const r = document.createElement("div"); r.className="linkrow";
448 + const badge = l.isInternal!==undefined ? `<span class="src">${l.isInternal?"internal":"external"}</span>` : "";
449 + r.innerHTML = `${badge}<a href="${l.url}" target="_blank" rel="noopener">${l.url}</a>`;
450 + out.appendChild(r);
451 + });
452 + } else if (activeTab === "metadata") {
453 + out.appendChild(code(JSON.stringify(lastResult.data?.data?.metadata || {}, null, 2), "json"));
454 + } else if (activeTab === "structured") {
455 + out.appendChild(code(JSON.stringify(lastResult.data?.data?.structured || {}, null, 2), "json"));
456 + } else if (activeTab === "json") {
457 + out.appendChild(code(raw, "json"));
458 + } else if (["curl","python","js"].includes(activeTab)) {
459 + out.appendChild(copyBar(snips[activeTab]));
460 + out.appendChild(code(snips[activeTab], activeTab==="curl"?"bash":activeTab==="python"?"python":"javascript"));
461 + }
462 +}
463 +
464 +async function send() {
465 + const path = "/v1/" + endpoint;
466 + const body = buildBody();
467 + const snips = snippets(path, body);
468 + const btn = $("#send"); btn.disabled = true; btn.innerHTML = '<span class="spin"></span> running';
469 + $("#reqmeta").textContent = "";
470 + const t0 = performance.now();
471 + try {
472 + const res = await fetch(path, endpoint==="status"
473 + ? { method:"GET" }
474 + : { method:"POST", headers:{ "content-type":"application/json" }, body: JSON.stringify(body) });
475 + const raw = await res.text();
476 + let data; try { data = JSON.parse(raw); } catch { data = raw; }
477 + const ms = Math.round(performance.now() - t0);
478 + const ok = res.ok && data?.success !== false;
479 + $("#respStatus").innerHTML = `<span class="badge ${ok?"ok":"err"}">${res.status}</span> <span class="meta-line">${ms} ms</span>`;
480 + $("#reqmeta").innerHTML = ok ? `<span class="badge ok">success</span>` : `<span class="badge err">${data?.error?.code||"error"}</span>`;
481 + const pretty = typeof data === "string" ? data : JSON.stringify(data, null, 2);
482 + lastResult = { data, raw: pretty, snips };
483 +
484 + let tabs;
485 + if (endpoint === "scrape") {
486 + tabs = ["preview"];
487 + if (data?.data?.links) tabs.push("links");
488 + if (data?.data?.metadata) tabs.push("metadata");
489 + if (data?.data?.structured) tabs.push("structured");
490 + tabs.push("json","curl","python","js");
491 + } else if (endpoint === "map") {
492 + tabs = ["preview","json","curl","python","js"];
493 + } else {
494 + tabs = ["preview","json","curl","python","js"];
495 + }
496 + activeTab = "preview";
497 + setTabs(tabs); render();
498 + } catch (e) {
499 + $("#respStatus").innerHTML = `<span class="badge err">network</span>`;
500 + $("#out").innerHTML = `<div class="placeholder">Request failed: ${e.message}</div>`;
501 + } finally {
502 + btn.disabled = false; btn.innerHTML = "▶ Send request";
503 + }
504 +}
505 +$("#send").onclick = send;
506 +
507 +// docs
508 +const EPS = [
509 + { m:"POST", path:"/v1/scrape", desc:"Fetch one page and extract it",
510 + params:[
511 + ["url","string","required","The page to fetch"],
512 + ["formats","string[]","markdown","Any of markdown · html · rawHtml · links · structured"],
513 + ["tier","enum","auto","auto · http · webkit · safari"],
514 + ["onlyMainContent","boolean","true","Strip nav/footer/ads, keep the article"],
515 + ["respectRobots","boolean","true","Honour robots.txt (refused if disallowed)"],
516 + ["timeout","number","30000","Milliseconds"],
517 + ],
518 + ex:{ url:"https://www.iana.org", formats:["markdown","structured"] } },
519 + { m:"POST", path:"/v1/map", desc:"Discover a site's URLs without rendering",
520 + params:[
521 + ["url","string","required","The site root to map"],
522 + ["search","string","—","Substring filter, ranked by match position"],
523 + ["limit","number","5000","Max URLs (cap 50000)"],
524 + ["sitemapOnly","boolean","false","Only sitemaps, skip homepage links"],
525 + ["ignoreSitemap","boolean","false","Skip sitemaps, links only"],
526 + ["includeSubdomains","boolean","false","Include subdomains of the apex"],
527 + ],
528 + ex:{ url:"https://www.iana.org", limit:20 } },
529 + { m:"GET", path:"/v1/status", desc:"Per-tier availability + pipeline version", params:[], ex:null },
530 +];
531 +const box = $("#epdocs");
532 +EPS.forEach(ep => {
533 + const el = document.createElement("div"); el.className = "ep";
534 + const rows = ep.params.map(p => `<tr><td><code>${p[0]}</code></td><td>${p[1]}</td><td>${p[2]==="required"?'<span class="req-opt">required</span>':p[2]}</td><td>${p[3]}</td></tr>`).join("");
535 + el.innerHTML = `
536 + <div class="top"><span class="method ${ep.m.toLowerCase()}">${ep.m}</span><span class="path">${ep.path}</span><span class="desc">${ep.desc}</span></div>
537 + <div class="body">
538 + ${ep.params.length ? `<table class="ptable"><thead><tr><th>Param</th><th>Type</th><th>Default</th><th>Notes</th></tr></thead><tbody>${rows}</tbody></table>` : '<p class="meta-line">No parameters.</p>'}
539 + ${ep.ex ? `<div style="margin-top:12px">${""}</div>` : ""}
540 + </div>`;
541 + el.querySelector(".top").onclick = () => el.classList.toggle("open");
542 + if (ep.ex) {
543 + const pre = code(`curl -s -X POST ${location.origin}${ep.path} \\\n -H 'content-type: application/json' \\\n -d '${JSON.stringify(ep.ex)}'`, "bash");
544 + el.querySelector(".body > div:last-child")?.appendChild(pre);
545 + el.querySelector(".body").appendChild(pre);
546 + }
547 + box.appendChild(el);
548 +});
549 +$$(".ep")[0]?.classList.add("open");
550 +</script>
551 +</body>
552 +</html>
modified apps/api/src/app.ts +10 −0
@@ -1,7 +1,9 @@
1 1 // author: simon-pierre boucher <contact@spboucher.ai>
2 2 import { randomUUID } from "node:crypto";
3 +import { fileURLToPath } from "node:url";
3 4 import Fastify, { type FastifyInstance } from "fastify";
4 5 import compress from "@fastify/compress";
6 +import fastifyStatic from "@fastify/static";
5 7 import { tendrilError } from "@tendril/shared";
6 8 import { mapSite } from "@tendril/frontier";
7 9 import { toApiError } from "./errors.js";
@@ -21,6 +23,14 @@ export function buildApp(): FastifyInstance {
21 23
22 24 app.register(compress, { global: true, encodings: ["br", "gzip", "deflate"], threshold: 1024 });
23 25
26 + app.register(fastifyStatic, {
27 + root: fileURLToPath(new URL("../public", import.meta.url)),
28 + prefix: "/",
29 + index: ["index.html"],
30 + cacheControl: true,
31 + maxAge: "5m",
32 + });
33 +
24 34 app.addHook("onSend", async (req, reply) => {
25 35 reply.header("X-Request-Id", req.id);
26 36 });
modified pnpm-lock.yaml +217 −0
@@ -54,6 +54,9 @@ importers:
54 54 '@fastify/compress':
55 55 specifier: ^8.0.1
56 56 version: 8.3.1
57 + '@fastify/static':
58 + specifier: ^8.0.3
59 + version: 8.3.0
57 60 '@tendril/egress':
58 61 specifier: workspace:*
59 62 version: link:../../packages/egress
@@ -459,9 +462,23 @@ packages:
459 462 '@fastify/proxy-addr@5.1.0':
460 463 resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
461 464
465 + '@fastify/send@4.1.1':
466 + resolution: {integrity: sha512-BYo+EiaKwlxH+WetGk6hAs1d39iP0y1gqB8lGF/qwkJ9ZZ/cBY1vx5NvExb9Sc3yRMFjD5X4Eyh4e4+TzRkzdw==}
467 +
468 + '@fastify/static@8.3.0':
469 + resolution: {integrity: sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==}
470 +
471 + '@isaacs/cliui@9.0.0':
472 + resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==}
473 + engines: {node: '>=18'}
474 +
462 475 '@jridgewell/sourcemap-codec@1.5.5':
463 476 resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
464 477
478 + '@lukeed/ms@2.0.2':
479 + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
480 + engines: {node: '>=8'}
481 +
465 482 '@mixmark-io/domino@2.2.0':
466 483 resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==}
467 484
@@ -684,6 +701,10 @@ packages:
684 701 avvio@9.3.0:
685 702 resolution: {integrity: sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==}
686 703
704 + balanced-match@4.0.4:
705 + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
706 + engines: {node: 18 || 20 || >=22}
707 +
687 708 base64-js@1.5.1:
688 709 resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
689 710
@@ -691,6 +712,10 @@ packages:
691 712 resolution: {integrity: sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==}
692 713 engines: {node: '>=20.19.0'}
693 714
715 + brace-expansion@5.0.9:
716 + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
717 + engines: {node: 20 || >=22}
718 +
694 719 buffer-from@1.1.2:
695 720 resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
696 721
@@ -709,6 +734,10 @@ packages:
709 734 resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
710 735 engines: {node: '>= 16'}
711 736
737 + content-disposition@0.5.4:
738 + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
739 + engines: {node: '>= 0.6'}
740 +
712 741 cookie@1.1.1:
713 742 resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
714 743 engines: {node: '>=18'}
@@ -716,6 +745,10 @@ packages:
716 745 core-util-is@1.0.3:
717 746 resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
718 747
748 + cross-spawn@7.0.6:
749 + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
750 + engines: {node: '>= 8'}
751 +
719 752 css-select@7.0.0:
720 753 resolution: {integrity: sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==}
721 754 engines: {node: '>=20.19.0'}
@@ -740,6 +773,10 @@ packages:
740 773 resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
741 774 engines: {node: '>=6'}
742 775
776 + depd@2.0.0:
777 + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
778 + engines: {node: '>= 0.8'}
779 +
743 780 dequal@2.0.3:
744 781 resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
745 782 engines: {node: '>=6'}
@@ -807,6 +844,9 @@ packages:
807 844 engines: {node: '>=18'}
808 845 hasBin: true
809 846
847 + escape-html@1.0.3:
848 + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
849 +
810 850 estree-walker@3.0.3:
811 851 resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
812 852
@@ -853,11 +893,21 @@ packages:
853 893 resolution: {integrity: sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==}
854 894 engines: {node: '>=20'}
855 895
896 + foreground-child@3.3.1:
897 + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
898 + engines: {node: '>=14'}
899 +
856 900 fsevents@2.3.3:
857 901 resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
858 902 engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
859 903 os: [darwin]
860 904
905 + glob@11.1.0:
906 + resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==}
907 + engines: {node: 20 || >=22}
908 + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
909 + hasBin: true
910 +
861 911 globrex@0.1.2:
862 912 resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==}
863 913
@@ -867,6 +917,10 @@ packages:
867 917 htmlparser2@10.1.0:
868 918 resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==}
869 919
920 + http-errors@2.0.1:
921 + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
922 + engines: {node: '>= 0.8'}
923 +
870 924 ieee754@1.2.1:
871 925 resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
872 926
@@ -880,6 +934,13 @@ packages:
880 934 isarray@1.0.0:
881 935 resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
882 936
937 + isexe@2.0.0:
938 + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
939 +
940 + jackspeak@4.2.3:
941 + resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==}
942 + engines: {node: 20 || >=22}
943 +
883 944 json-schema-ref-resolver@3.0.0:
884 945 resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==}
885 946
@@ -901,6 +962,10 @@ packages:
901 962 loupe@3.2.1:
902 963 resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
903 964
965 + lru-cache@11.5.2:
966 + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==}
967 + engines: {node: 20 || >=22}
968 +
904 969 magic-string@0.30.21:
905 970 resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
906 971
@@ -908,6 +973,15 @@ packages:
908 973 resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
909 974 engines: {node: '>= 0.6'}
910 975
976 + mime@3.0.0:
977 + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==}
978 + engines: {node: '>=10.0.0'}
979 + hasBin: true
980 +
981 + minimatch@10.2.6:
982 + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
983 + engines: {node: 18 || 20 || >=22}
984 +
911 985 minipass@7.1.3:
912 986 resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
913 987 engines: {node: '>=16 || 14 >=14.17'}
@@ -931,6 +1005,17 @@ packages:
931 1005 once@1.4.0:
932 1006 resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
933 1007
1008 + package-json-from-dist@1.0.1:
1009 + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
1010 +
1011 + path-key@3.1.1:
1012 + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
1013 + engines: {node: '>=8'}
1014 +
1015 + path-scurry@2.0.2:
1016 + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
1017 + engines: {node: 18 || 20 || >=22}
1018 +
934 1019 pathe@1.1.2:
935 1020 resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
936 1021
@@ -1040,9 +1125,24 @@ packages:
1040 1125 set-cookie-parser@2.7.2:
1041 1126 resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
1042 1127
1128 + setprototypeof@1.2.0:
1129 + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
1130 +
1131 + shebang-command@2.0.0:
1132 + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
1133 + engines: {node: '>=8'}
1134 +
1135 + shebang-regex@3.0.0:
1136 + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
1137 + engines: {node: '>=8'}
1138 +
1043 1139 siginfo@2.0.0:
1044 1140 resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
1045 1141
1142 + signal-exit@4.1.0:
1143 + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
1144 + engines: {node: '>=14'}
1145 +
1046 1146 sonic-boom@4.2.1:
1047 1147 resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
1048 1148
@@ -1057,6 +1157,10 @@ packages:
1057 1157 stackback@0.0.2:
1058 1158 resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
1059 1159
1160 + statuses@2.0.2:
1161 + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
1162 + engines: {node: '>= 0.8'}
1163 +
1060 1164 std-env@3.10.0:
1061 1165 resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
1062 1166
@@ -1097,6 +1201,10 @@ packages:
1097 1201 resolution: {integrity: sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==}
1098 1202 engines: {node: '>=20'}
1099 1203
1204 + toidentifier@1.0.1:
1205 + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
1206 + engines: {node: '>=0.6'}
1207 +
1100 1208 tsconfck@3.1.6:
1101 1209 resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==}
1102 1210 engines: {node: ^18 || >=20}
@@ -1204,6 +1312,11 @@ packages:
1204 1312 jsdom:
1205 1313 optional: true
1206 1314
1315 + which@2.0.2:
1316 + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
1317 + engines: {node: '>= 8'}
1318 + hasBin: true
1319 +
1207 1320 why-is-node-running@2.3.0:
1208 1321 resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
1209 1322 engines: {node: '>=8'}
@@ -1404,8 +1517,29 @@ snapshots:
1404 1517 '@fastify/forwarded': 3.0.2
1405 1518 ipaddr.js: 2.5.0
1406 1519
1520 + '@fastify/send@4.1.1':
1521 + dependencies:
1522 + '@lukeed/ms': 2.0.2
1523 + escape-html: 1.0.3
1524 + fast-decode-uri-component: 1.0.1
1525 + http-errors: 2.0.1
1526 + mime: 3.0.0
1527 +
1528 + '@fastify/static@8.3.0':
1529 + dependencies:
1530 + '@fastify/accept-negotiator': 2.1.0
1531 + '@fastify/send': 4.1.1
1532 + content-disposition: 0.5.4
1533 + fastify-plugin: 5.1.0
1534 + fastq: 1.20.1
1535 + glob: 11.1.0
1536 +
1537 + '@isaacs/cliui@9.0.0': {}
1538 +
1407 1539 '@jridgewell/sourcemap-codec@1.5.5': {}
1408 1540
1541 + '@lukeed/ms@2.0.2': {}
1542 +
1409 1543 '@mixmark-io/domino@2.2.0': {}
1410 1544
1411 1545 '@mozilla/readability@0.5.0': {}
@@ -1564,10 +1698,16 @@ snapshots:
1564 1698 '@fastify/error': 4.2.0
1565 1699 fastq: 1.20.1
1566 1700
1701 + balanced-match@4.0.4: {}
1702 +
1567 1703 base64-js@1.5.1: {}
1568 1704
1569 1705 boolbase@2.0.0: {}
1570 1706
1707 + brace-expansion@5.0.9:
1708 + dependencies:
1709 + balanced-match: 4.0.4
1710 +
1571 1711 buffer-from@1.1.2: {}
1572 1712
1573 1713 buffer@6.0.3:
@@ -1587,10 +1727,20 @@ snapshots:
1587 1727
1588 1728 check-error@2.1.3: {}
1589 1729
1730 + content-disposition@0.5.4:
1731 + dependencies:
1732 + safe-buffer: 5.2.1
1733 +
1590 1734 cookie@1.1.1: {}
1591 1735
1592 1736 core-util-is@1.0.3: {}
1593 1737
1738 + cross-spawn@7.0.6:
1739 + dependencies:
1740 + path-key: 3.1.1
1741 + shebang-command: 2.0.0
1742 + which: 2.0.2
1743 +
1594 1744 css-select@7.0.0:
1595 1745 dependencies:
1596 1746 boolbase: 2.0.0
@@ -1609,6 +1759,8 @@ snapshots:
1609 1759
1610 1760 deep-eql@5.0.2: {}
1611 1761
1762 + depd@2.0.0: {}
1763 +
1612 1764 dequal@2.0.3: {}
1613 1765
1614 1766 dom-serializer@2.0.0:
@@ -1728,6 +1880,8 @@ snapshots:
1728 1880 '@esbuild/win32-ia32': 0.28.2
1729 1881 '@esbuild/win32-x64': 0.28.2
1730 1882
1883 + escape-html@1.0.3: {}
1884 +
1731 1885 estree-walker@3.0.3:
1732 1886 dependencies:
1733 1887 '@types/estree': 1.0.9
@@ -1789,9 +1943,23 @@ snapshots:
1789 1943 fast-querystring: 1.1.2
1790 1944 safe-regex2: 5.1.1
1791 1945
1946 + foreground-child@3.3.1:
1947 + dependencies:
1948 + cross-spawn: 7.0.6
1949 + signal-exit: 4.1.0
1950 +
1792 1951 fsevents@2.3.3:
1793 1952 optional: true
1794 1953
1954 + glob@11.1.0:
1955 + dependencies:
1956 + foreground-child: 3.3.1
1957 + jackspeak: 4.2.3
1958 + minimatch: 10.2.6
1959 + minipass: 7.1.3
1960 + package-json-from-dist: 1.0.1
1961 + path-scurry: 2.0.2
1962 +
1795 1963 globrex@0.1.2: {}
1796 1964
1797 1965 html-escaper@3.0.3: {}
@@ -1803,6 +1971,14 @@ snapshots:
1803 1971 domutils: 3.2.2
1804 1972 entities: 7.0.1
1805 1973
1974 + http-errors@2.0.1:
1975 + dependencies:
1976 + depd: 2.0.0
1977 + inherits: 2.0.4
1978 + setprototypeof: 1.2.0
1979 + statuses: 2.0.2
1980 + toidentifier: 1.0.1
1981 +
1806 1982 ieee754@1.2.1: {}
1807 1983
1808 1984 inherits@2.0.4: {}
@@ -1811,6 +1987,12 @@ snapshots:
1811 1987
1812 1988 isarray@1.0.0: {}
1813 1989
1990 + isexe@2.0.0: {}
1991 +
1992 + jackspeak@4.2.3:
1993 + dependencies:
1994 + '@isaacs/cliui': 9.0.0
1995 +
1814 1996 json-schema-ref-resolver@3.0.0:
1815 1997 dependencies:
1816 1998 dequal: 2.0.3
@@ -1833,12 +2015,20 @@ snapshots:
1833 2015
1834 2016 loupe@3.2.1: {}
1835 2017
2018 + lru-cache@11.5.2: {}
2019 +
1836 2020 magic-string@0.30.21:
1837 2021 dependencies:
1838 2022 '@jridgewell/sourcemap-codec': 1.5.5
1839 2023
1840 2024 mime-db@1.54.0: {}
1841 2025
2026 + mime@3.0.0: {}
2027 +
2028 + minimatch@10.2.6:
2029 + dependencies:
2030 + brace-expansion: 5.0.9
2031 +
1842 2032 minipass@7.1.3: {}
1843 2033
1844 2034 ms@2.1.3: {}
@@ -1855,6 +2045,15 @@ snapshots:
1855 2045 dependencies:
1856 2046 wrappy: 1.0.2
1857 2047
2048 + package-json-from-dist@1.0.1: {}
2049 +
2050 + path-key@3.1.1: {}
2051 +
2052 + path-scurry@2.0.2:
2053 + dependencies:
2054 + lru-cache: 11.5.2
2055 + minipass: 7.1.3
2056 +
1858 2057 pathe@1.1.2: {}
1859 2058
1860 2059 pathval@2.0.1: {}
@@ -1996,8 +2195,18 @@ snapshots:
1996 2195
1997 2196 set-cookie-parser@2.7.2: {}
1998 2197
2198 + setprototypeof@1.2.0: {}
2199 +
2200 + shebang-command@2.0.0:
2201 + dependencies:
2202 + shebang-regex: 3.0.0
2203 +
2204 + shebang-regex@3.0.0: {}
2205 +
1999 2206 siginfo@2.0.0: {}
2000 2207
2208 + signal-exit@4.1.0: {}
2209 +
2001 2210 sonic-boom@4.2.1:
2002 2211 dependencies:
2003 2212 atomic-sleep: 1.0.0
@@ -2008,6 +2217,8 @@ snapshots:
2008 2217
2009 2218 stackback@0.0.2: {}
2010 2219
2220 + statuses@2.0.2: {}
2221 +
2011 2222 std-env@3.10.0: {}
2012 2223
2013 2224 stream-shift@1.0.3: {}
@@ -2041,6 +2252,8 @@ snapshots:
2041 2252
2042 2253 toad-cache@3.7.4: {}
2043 2254
2255 + toidentifier@1.0.1: {}
2256 +
2044 2257 tsconfck@3.1.6(typescript@5.9.3):
2045 2258 optionalDependencies:
2046 2259 typescript: 5.9.3
@@ -2138,6 +2351,10 @@ snapshots:
2138 2351 - supports-color
2139 2352 - terser
2140 2353
2354 + which@2.0.2:
2355 + dependencies:
2356 + isexe: 2.0.0
2357 +
2141 2358 why-is-node-running@2.3.0:
2142 2359 dependencies:
2143 2360 siginfo: 2.0.0
2144 2361