+8
.dockerignore
+8
.dockerignore
+1
.gitignore
+1
.gitignore
+22
.tangled/workflows/test.yml
+22
.tangled/workflows/test.yml
···
1
+
when:
2
+
- event: ["push", "pull_request"]
3
+
branch: main
4
+
5
+
engine: nixery
6
+
7
+
dependencies:
8
+
nixpkgs:
9
+
- git
10
+
github:NixOS/nixpkgs/nixpkgs-unstable:
11
+
- bun
12
+
13
+
steps:
14
+
- name: install dependencies
15
+
command: |
16
+
export PATH="$HOME/.nix-profile/bin:$PATH"
17
+
bun install
18
+
19
+
- name: run all tests
20
+
command: |
21
+
export PATH="$HOME/.nix-profile/bin:$PATH"
22
+
bun test
+10
-6
Dockerfile
+10
-6
Dockerfile
···
15
15
COPY public ./public
16
16
17
17
# Build the application (if needed)
18
-
# RUN bun run build
18
+
RUN bun build \
19
+
--compile \
20
+
--minify \
21
+
--outfile server \
22
+
src/index.ts
23
+
24
+
FROM scratch AS runtime
25
+
WORKDIR /app
26
+
COPY --from=base /app/server /app/server
19
27
20
28
# Set environment variables (can be overridden at runtime)
21
29
ENV PORT=3000
···
24
32
# Expose the application port
25
33
EXPOSE 3000
26
34
27
-
# Health check
28
-
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
29
-
CMD bun -e "fetch('http://localhost:3000/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
30
-
31
35
# Start the application
32
-
CMD ["bun", "src/index.ts"]
36
+
CMD ["./server"]
+7
-12
README.md
+7
-12
README.md
···
1
-
# Elysia with Bun runtime
1
+
# Wisp.place
2
+
A static site hosting service built on the AT Protocol. [https://wisp.place](https://wisp.place)
2
3
3
-
## Getting Started
4
-
To get started with this template, simply paste this command into your terminal:
5
-
```bash
6
-
bun create elysia ./elysia-example
7
-
```
4
+
/src is the main backend
5
+
6
+
/hosting-service is the microservice that serves on-disk caches of sites pulled from the firehose and pdses
8
7
9
-
## Development
10
-
To start the development server run:
11
-
```bash
12
-
bun run dev
13
-
```
8
+
/cli is the wisp-cli, a way to upload sites directly to the pds
14
9
15
-
Open http://localhost:3000/ with your browser to see the result.
10
+
full readme soon
-41
api.md
-41
api.md
···
1
-
/**
2
-
* AUTHENTICATION ROUTES
3
-
*
4
-
* Handles OAuth authentication flow for Bluesky/ATProto accounts
5
-
* All routes are on the editor.wisp.place subdomain
6
-
*
7
-
* Routes:
8
-
* POST /api/auth/signin - Initiate OAuth sign-in flow
9
-
* GET /api/auth/callback - OAuth callback handler (redirect from PDS)
10
-
* GET /api/auth/status - Check current authentication status
11
-
* POST /api/auth/logout - Sign out and clear session
12
-
*/
13
-
14
-
/**
15
-
* CUSTOM DOMAIN ROUTES
16
-
*
17
-
* Handles custom domain (BYOD - Bring Your Own Domain) management
18
-
* Users can claim custom domains with DNS verification (TXT + CNAME)
19
-
* and map them to their sites
20
-
*
21
-
* Routes:
22
-
* GET /api/check-domain - Fast verification check for routing (public)
23
-
* GET /api/custom-domains - List user's custom domains
24
-
* POST /api/custom-domains/check - Check domain availability and DNS config
25
-
* POST /api/custom-domains/claim - Claim a custom domain
26
-
* PUT /api/custom-domains/:id/site - Update site mapping
27
-
* DELETE /api/custom-domains/:id - Remove a custom domain
28
-
* POST /api/custom-domains/:id/verify - Manually trigger verification
29
-
*/
30
-
31
-
/**
32
-
* WISP SITE MANAGEMENT ROUTES
33
-
*
34
-
* API endpoints for managing user's Wisp sites stored in ATProto repos
35
-
* Handles reading site metadata, fetching content, updating sites, and uploads
36
-
* All routes are on the editor.wisp.place subdomain
37
-
*
38
-
* Routes:
39
-
* GET /wisp/sites - List all sites for authenticated user
40
-
* POST /wisp/upload-files - Upload and deploy files as a site
41
-
*/
+179
bun.lock
+179
bun.lock
···
26
26
"lucide-react": "^0.546.0",
27
27
"react": "^19.2.0",
28
28
"react-dom": "^19.2.0",
29
+
"react-shiki": "^0.9.0",
29
30
"tailwind-merge": "^3.3.1",
30
31
"tailwindcss": "4",
31
32
"tw-animate-css": "^1.4.0",
···
279
280
280
281
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
281
282
283
+
"@shikijs/core": ["@shikijs/core@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-8TOG6yG557q+fMsSVa8nkEDOZNTSxjbbR8l6lF2gyr6Np+jrPlslqDxQkN6rMXCECQ3isNPZAGszAfYoJOPGlg=="],
284
+
285
+
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-ZedbOFpopibdLmvTz2sJPJgns8Xvyabe2QbmqMTz07kt1pTzfEvKZc5IqPVO/XFiEbbNyaOpjPBkkr1vlwS+qg=="],
286
+
287
+
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-HnqFsV11skAHvOArMZdLBZZApRSYS4LSztk2K3016Y9VCyZISnlYUYsL2hzlS7tPqKHvNqmI5JSUJZprXloMvA=="],
288
+
289
+
"@shikijs/langs": ["@shikijs/langs@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0" } }, "sha512-WpRvEFvkVvO65uKYW4Rzxs+IG0gToyM8SARQMtGGsH4GDMNZrr60qdggXrFOsdfOVssG/QQGEl3FnJ3EZ+8w8A=="],
290
+
291
+
"@shikijs/themes": ["@shikijs/themes@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0" } }, "sha512-8ow2zWb1IDvCKjYb0KiLNrK4offFdkfNVPXb1OZykpLCzRU6j+efkY+Y7VQjNlNFXonSw+4AOdGYtmqykDbRiQ=="],
292
+
293
+
"@shikijs/types": ["@shikijs/types@3.15.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-BnP+y/EQnhihgHy4oIAN+6FFtmfTekwOLsQbRw9hOKwqgNy8Bdsjq8B05oAt/ZgvIWWFrshV71ytOrlPfYjIJw=="],
294
+
295
+
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
296
+
282
297
"@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="],
283
298
284
299
"@tanstack/query-core": ["@tanstack/query-core@5.90.2", "", {}, "sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ=="],
···
291
306
292
307
"@ts-morph/common": ["@ts-morph/common@0.25.0", "", { "dependencies": { "minimatch": "^9.0.4", "path-browserify": "^1.0.1", "tinyglobby": "^0.2.9" } }, "sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg=="],
293
308
309
+
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
310
+
311
+
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
312
+
313
+
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
314
+
315
+
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
316
+
317
+
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
318
+
319
+
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
320
+
294
321
"@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="],
295
322
296
323
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
···
298
325
"@types/react-dom": ["@types/react-dom@19.2.1", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A=="],
299
326
300
327
"@types/shimmer": ["@types/shimmer@1.2.0", "", {}, "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg=="],
328
+
329
+
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
330
+
331
+
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
301
332
302
333
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
303
334
···
347
378
348
379
"cborg": ["cborg@1.10.2", "", { "bin": { "cborg": "cli.js" } }, "sha512-b3tFPA9pUr2zCUiCfRd2+wok2/LBSNUMKOuRRok+WlvvAgEt/PlbgPTsZUcwCOs53IJvLgTp0eotwtosE6njug=="],
349
380
381
+
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
382
+
350
383
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
351
384
385
+
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
386
+
387
+
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
388
+
389
+
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
390
+
391
+
"character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
392
+
352
393
"cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="],
353
394
354
395
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
···
362
403
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
363
404
364
405
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
406
+
407
+
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
365
408
366
409
"commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
367
410
···
378
421
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
379
422
380
423
"debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
424
+
425
+
"decode-named-character-reference": ["decode-named-character-reference@1.2.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q=="],
381
426
382
427
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
383
428
429
+
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
430
+
384
431
"destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="],
385
432
386
433
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
387
434
388
435
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
436
+
437
+
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
389
438
390
439
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
391
440
···
406
455
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
407
456
408
457
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
458
+
459
+
"estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
409
460
410
461
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
411
462
···
453
504
454
505
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
455
506
507
+
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
508
+
509
+
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
510
+
511
+
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
512
+
513
+
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
514
+
456
515
"http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="],
457
516
458
517
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
···
463
522
464
523
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
465
524
525
+
"inline-style-parser": ["inline-style-parser@0.2.6", "", {}, "sha512-gtGXVaBdl5mAes3rPcMedEBm12ibjt1kDMFfheul1wUAOVEJW60voNdMVzVkfLN06O7ZaD/rxhfKgtlgtTbMjg=="],
526
+
466
527
"ipaddr.js": ["ipaddr.js@2.2.0", "", {}, "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA=="],
467
528
468
529
"iron-session": ["iron-session@8.0.4", "", { "dependencies": { "cookie": "^0.7.2", "iron-webcrypto": "^1.2.1", "uncrypto": "^0.1.3" } }, "sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA=="],
469
530
470
531
"iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="],
471
532
533
+
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
534
+
535
+
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
536
+
472
537
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
473
538
539
+
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
540
+
474
541
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
475
542
543
+
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
544
+
476
545
"iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="],
477
546
478
547
"jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
···
480
549
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
481
550
482
551
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
552
+
553
+
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
483
554
484
555
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
485
556
···
487
558
488
559
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
489
560
561
+
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="],
562
+
563
+
"mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
564
+
565
+
"mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
566
+
567
+
"mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
568
+
569
+
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
570
+
571
+
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA=="],
572
+
573
+
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
574
+
575
+
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
576
+
490
577
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
491
578
492
579
"merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="],
493
580
494
581
"methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="],
495
582
583
+
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
584
+
585
+
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
586
+
587
+
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
588
+
589
+
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
590
+
591
+
"micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
592
+
593
+
"micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
594
+
595
+
"micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
596
+
597
+
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
598
+
599
+
"micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
600
+
601
+
"micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
602
+
603
+
"micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
604
+
605
+
"micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
606
+
607
+
"micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
608
+
609
+
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
610
+
611
+
"micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
612
+
613
+
"micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
614
+
615
+
"micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
616
+
617
+
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
618
+
619
+
"micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
620
+
621
+
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
622
+
623
+
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
624
+
496
625
"mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
497
626
498
627
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
···
517
646
518
647
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
519
648
649
+
"oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="],
650
+
651
+
"oniguruma-to-es": ["oniguruma-to-es@4.3.3", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg=="],
652
+
520
653
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
654
+
655
+
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
521
656
522
657
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
523
658
···
541
676
542
677
"process-warning": ["process-warning@3.0.0", "", {}, "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ=="],
543
678
679
+
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
680
+
544
681
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
545
682
546
683
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
···
563
700
564
701
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
565
702
703
+
"react-shiki": ["react-shiki@0.9.0", "", { "dependencies": { "clsx": "^2.1.1", "dequal": "^2.0.3", "hast-util-to-jsx-runtime": "^2.3.6", "shiki": "^3.11.0", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "@types/react": ">=16.8.0", "@types/react-dom": ">=16.8.0", "react": ">= 16.8.0", "react-dom": ">= 16.8.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5t+vHGglJioG3LU6uTKFaiOC+KNW7haL8e22ZHSP7m174ZD/X2KgCVJcxvcUOM3FiqjPQD09AyS9/+RcOh3PmA=="],
704
+
566
705
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
567
706
568
707
"readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
569
708
570
709
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
571
710
711
+
"regex": ["regex@6.0.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA=="],
712
+
713
+
"regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
714
+
715
+
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
716
+
572
717
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
573
718
574
719
"require-in-the-middle": ["require-in-the-middle@7.5.2", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3", "resolve": "^1.22.8" } }, "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ=="],
···
589
734
590
735
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
591
736
737
+
"shiki": ["shiki@3.15.0", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/engine-javascript": "3.15.0", "@shikijs/engine-oniguruma": "3.15.0", "@shikijs/langs": "3.15.0", "@shikijs/themes": "3.15.0", "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kLdkY6iV3dYbtPwS9KXU7mjfmDm25f5m0IPNFnaXO7TBPcvbUOY72PYXSuSqDzwp+vlH/d7MXpHlKO/x+QoLXw=="],
738
+
592
739
"shimmer": ["shimmer@1.2.1", "", {}, "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw=="],
593
740
594
741
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
···
600
747
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
601
748
602
749
"sonic-boom": ["sonic-boom@3.8.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg=="],
750
+
751
+
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
603
752
604
753
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
605
754
···
609
758
610
759
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
611
760
761
+
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
762
+
612
763
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
613
764
614
765
"strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="],
615
766
767
+
"style-to-js": ["style-to-js@1.1.19", "", { "dependencies": { "style-to-object": "1.0.12" } }, "sha512-Ev+SgeqiNGT1ufsXyVC5RrJRXdrkRJ1Gol9Qw7Pb72YCKJXrBvP0ckZhBeVSrw2m06DJpei2528uIpjMb4TsoQ=="],
768
+
769
+
"style-to-object": ["style-to-object@1.0.12", "", { "dependencies": { "inline-style-parser": "0.2.6" } }, "sha512-ddJqYnoT4t97QvN2C95bCgt+m7AAgXjVnkk/jxAfmp7EAB8nnqqZYEbMd3em7/vEomDb2LAQKAy1RFfv41mdNw=="],
770
+
616
771
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
617
772
618
773
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
···
630
785
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
631
786
632
787
"token-types": ["token-types@6.1.1", "", { "dependencies": { "@borewit/text-codec": "^0.1.0", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ=="],
788
+
789
+
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
633
790
634
791
"ts-morph": ["ts-morph@24.0.0", "", { "dependencies": { "@ts-morph/common": "~0.25.0", "code-block-writer": "^13.0.3" } }, "sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw=="],
635
792
···
651
808
652
809
"undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="],
653
810
811
+
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
812
+
813
+
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
814
+
815
+
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
816
+
817
+
"unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="],
818
+
819
+
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
820
+
654
821
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
655
822
656
823
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
···
661
828
662
829
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
663
830
831
+
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
832
+
833
+
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
834
+
664
835
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
665
836
666
837
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
···
677
848
678
849
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
679
850
851
+
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
852
+
680
853
"@tokenizer/inflate/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
681
854
682
855
"express/cookie": ["cookie@0.7.1", "", {}, "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w=="],
683
856
684
857
"iron-session/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
685
858
859
+
"micromark/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
860
+
861
+
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
862
+
686
863
"proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
687
864
688
865
"require-in-the-middle/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
···
692
869
"send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
693
870
694
871
"@tokenizer/inflate/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
872
+
873
+
"micromark/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
695
874
696
875
"require-in-the-middle/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
697
876
}
+420
-25
claude.md
+420
-25
claude.md
···
1
-
Wisp.place - Decentralized Static Site Hosting
1
+
# Wisp.place - Codebase Overview
2
+
3
+
**Project URL**: https://wisp.place
4
+
5
+
A decentralized static site hosting service built on the AT Protocol (Bluesky). Users can host static websites directly in their AT Protocol accounts, keeping full control and ownership while benefiting from fast CDN distribution.
6
+
7
+
---
8
+
9
+
## ๐๏ธ Architecture Overview
10
+
11
+
### Multi-Part System
12
+
1. **Main Backend** (`/src`) - OAuth, site management, custom domains
13
+
2. **Hosting Service** (`/hosting-service`) - Microservice that serves cached sites
14
+
3. **CLI Tool** (`/cli`) - Rust CLI for direct site uploads to PDS
15
+
4. **Frontend** (`/public`) - React UI for onboarding, editor, admin
16
+
17
+
### Tech Stack
18
+
- **Backend**: Elysia (Bun) + TypeScript + PostgreSQL
19
+
- **Frontend**: React 19 + Tailwind CSS 4 + Radix UI
20
+
- **CLI**: Rust with Jacquard (AT Protocol library)
21
+
- **Database**: PostgreSQL for session/domain/site caching
22
+
- **AT Protocol**: OAuth 2.0 + custom lexicons for storage
23
+
24
+
---
25
+
26
+
## ๐ Directory Structure
27
+
28
+
### `/src` - Main Backend Server
29
+
**Purpose**: Core server handling OAuth, site management, custom domains, admin features
30
+
31
+
**Key Routes**:
32
+
- `/api/auth/*` - OAuth signin/callback/logout/status
33
+
- `/api/domain/*` - Custom domain management (BYOD)
34
+
- `/wisp/*` - Site upload and management
35
+
- `/api/user/*` - User info and site listing
36
+
- `/api/admin/*` - Admin console (logs, metrics, DNS verification)
37
+
38
+
**Key Files**:
39
+
- `index.ts` - Express-like Elysia app setup with middleware (CORS, CSP, security headers)
40
+
- `lib/oauth-client.ts` - OAuth client setup with session/state persistence
41
+
- `lib/db.ts` - PostgreSQL schema and queries for all tables
42
+
- `lib/wisp-auth.ts` - Cookie-based authentication middleware
43
+
- `lib/wisp-utils.ts` - File compression (gzip), manifest creation, blob handling
44
+
- `lib/sync-sites.ts` - Syncs user's place.wisp.fs records from PDS to database cache
45
+
- `lib/dns-verify.ts` - DNS verification for custom domains (TXT + CNAME)
46
+
- `lib/dns-verification-worker.ts` - Background worker that checks domain verification every 10 minutes
47
+
- `lib/admin-auth.ts` - Simple username/password admin authentication
48
+
- `lib/observability.ts` - Logging, error tracking, metrics collection
49
+
- `routes/auth.ts` - OAuth flow handlers
50
+
- `routes/wisp.ts` - File upload and site creation (/wisp/upload-files)
51
+
- `routes/domain.ts` - Domain claiming/verification API
52
+
- `routes/user.ts` - User status/info/sites listing
53
+
- `routes/site.ts` - Site metadata and file retrieval
54
+
- `routes/admin.ts` - Admin dashboard API (logs, system health, manual DNS trigger)
55
+
56
+
### `/lexicons` & `src/lexicons/`
57
+
**Purpose**: AT Protocol Lexicon definitions for custom data types
58
+
59
+
**Key File**: `fs.json` - Defines `place.wisp.fs` record format
60
+
- **structure**: Virtual filesystem manifest with tree structure
61
+
- **site**: string identifier
62
+
- **root**: directory object containing entries
63
+
- **file**: blob reference + metadata (encoding, mimeType, base64 flag)
64
+
- **directory**: array of entries (recursive)
65
+
- **entry**: name + node (file or directory)
66
+
67
+
**Important**: Files are gzip-compressed and base64-encoded before upload to bypass PDS content sniffing
68
+
69
+
### `/hosting-service`
70
+
**Purpose**: Lightweight microservice that serves cached sites from disk
71
+
72
+
**Architecture**:
73
+
- Routes by domain lookup in PostgreSQL
74
+
- Caches site content locally on first access or firehose event
75
+
- Listens to AT Protocol firehose for new site records
76
+
- Automatically downloads and caches files from PDS
77
+
- SSRF-protected fetch (timeout, size limits, private IP blocking)
78
+
79
+
**Routes**:
80
+
1. Custom domains (`/*`) โ lookup custom_domains table
81
+
2. Wisp subdomains (`/*.wisp.place/*`) โ lookup domains table
82
+
3. DNS hash routing (`/hash.dns.wisp.place/*`) โ lookup custom_domains by hash
83
+
4. Direct serving (`/s.wisp.place/:identifier/:site/*`) โ fetch from PDS if not cached
84
+
85
+
**HTML Path Rewriting**: Absolute paths in HTML (`/style.css`) automatically rewritten to relative (`/:identifier/:site/style.css`)
86
+
87
+
### `/cli`
88
+
**Purpose**: Rust CLI tool for direct site uploads using app password or OAuth
89
+
90
+
**Flow**:
91
+
1. Authenticate with handle + app password or OAuth
92
+
2. Walk directory tree, compress files
93
+
3. Upload blobs to PDS via agent
94
+
4. Create place.wisp.fs record with manifest
95
+
5. Store site in database cache
96
+
97
+
**Auth Methods**:
98
+
- `--password` flag for app password auth
99
+
- OAuth loopback server for browser-based auth
100
+
- Supports both (password preferred if provided)
101
+
102
+
---
103
+
104
+
## ๐ Key Concepts
105
+
106
+
### Custom Domains (BYOD - Bring Your Own Domain)
107
+
**Process**:
108
+
1. User claims custom domain via API
109
+
2. System generates hash (SHA256(domain + secret))
110
+
3. User adds DNS records:
111
+
- TXT at `_wisp.example.com` = their DID
112
+
- CNAME at `example.com` = `{hash}.dns.wisp.place`
113
+
4. Background worker checks verification every 10 minutes
114
+
5. Once verified, custom domain routes to their hosted sites
115
+
116
+
**Tables**: `custom_domains` (id, domain, did, rkey, verified, last_verified_at)
117
+
118
+
### Wisp Subdomains
119
+
**Process**:
120
+
1. Handle claimed on first signup (e.g., alice โ alice.wisp.place)
121
+
2. Stored in `domains` table mapping domain โ DID
122
+
3. Served by hosting service
123
+
124
+
### Site Storage
125
+
**Locations**:
126
+
- **Authoritative**: PDS (AT Protocol repo) as `place.wisp.fs` record
127
+
- **Cache**: PostgreSQL `sites` table (rkey, did, site_name, created_at)
128
+
- **File Cache**: Hosting service caches downloaded files on disk
129
+
130
+
**Limits**:
131
+
- MAX_SITE_SIZE: 300MB total
132
+
- MAX_FILE_SIZE: 100MB per file
133
+
- MAX_FILE_COUNT: 2000 files
134
+
135
+
### File Compression Strategy
136
+
**Why**: Bypass PDS content sniffing issues (was treating HTML as images)
137
+
138
+
**Process**:
139
+
1. All files gzip-compressed (level 9)
140
+
2. Compressed content base64-encoded
141
+
3. Uploaded as `application/octet-stream` MIME type
142
+
4. Blob metadata stores original MIME type + encoding flag
143
+
5. Hosting service decompresses on serve
144
+
145
+
---
146
+
147
+
## ๐ Data Flow
148
+
149
+
### User Registration โ Site Upload
150
+
```
151
+
1. OAuth signin โ state/session stored in DB
152
+
2. Cookie set with DID
153
+
3. Sync sites from PDS to cache DB
154
+
4. If no sites/domain โ redirect to onboarding
155
+
5. User creates site โ POST /wisp/upload-files
156
+
6. Files compressed, uploaded as blobs
157
+
7. place.wisp.fs record created
158
+
8. Site cached in DB
159
+
9. Hosting service notified via firehose
160
+
```
161
+
162
+
### Custom Domain Setup
163
+
```
164
+
1. User claims domain (DB check + allocation)
165
+
2. System generates hash
166
+
3. User adds DNS records (_wisp.domain TXT + CNAME)
167
+
4. Background worker verifies every 10 min
168
+
5. Hosting service routes based on verification status
169
+
```
170
+
171
+
### Site Access
172
+
```
173
+
Hosting Service:
174
+
1. Request arrives at custom domain or *.wisp.place
175
+
2. Domain lookup in PostgreSQL
176
+
3. Check cache for site files
177
+
4. If not cached:
178
+
- Fetch from PDS using DID + rkey
179
+
- Decompress files
180
+
- Save to disk cache
181
+
5. Serve files (with HTML path rewriting)
182
+
```
183
+
184
+
---
185
+
186
+
## ๐ ๏ธ Important Implementation Details
187
+
188
+
### OAuth Implementation
189
+
- **State & Session Storage**: PostgreSQL (with expiration)
190
+
- **Key Rotation**: Periodic rotation + expiration cleanup (hourly)
191
+
- **OAuth Flow**: Redirects to PDS, returns to /api/auth/callback
192
+
- **Session Timeout**: 30 days
193
+
- **State Timeout**: 1 hour
194
+
195
+
### Security Headers
196
+
- X-Frame-Options: DENY
197
+
- X-Content-Type-Options: nosniff
198
+
- Strict-Transport-Security: max-age=31536000
199
+
- Content-Security-Policy (configured for Elysia + React)
200
+
- X-XSS-Protection: 1; mode=block
201
+
- Referrer-Policy: strict-origin-when-cross-origin
202
+
203
+
### Admin Authentication
204
+
- Simple username/password (hashed with bcrypt)
205
+
- Session-based cookie auth (24hr expiration)
206
+
- Separate `admin_session` cookie
207
+
- Initial setup prompted on startup
208
+
209
+
### Observability
210
+
- **Logging**: Structured logging with service tags + event types
211
+
- **Error Tracking**: Captures error context (message, stack, etc.)
212
+
- **Metrics**: Request counts, latencies, error rates
213
+
- **Log Levels**: debug, info, warn, error
214
+
- **Collection**: Centralized log collector with in-memory buffer
215
+
216
+
---
217
+
218
+
## ๐ Database Schema
219
+
220
+
### oauth_states
221
+
- key (primary key)
222
+
- data (JSON)
223
+
- created_at, expires_at (timestamps)
2
224
3
-
Architecture Overview
225
+
### oauth_sessions
226
+
- sub (primary key - subject/DID)
227
+
- data (JSON with OAuth session)
228
+
- updated_at, expires_at
4
229
5
-
Wisp.Place a two-service application that provides static site hosting on the AT
6
-
Protocol. Wisp aims to be a CDN for static sites where the content is ultimately owned by the user at their repo. The microservice is responsbile for injesting firehose events and serving a on-disk cache of the latest site files.
230
+
### oauth_keys
231
+
- kid (primary key - key ID)
232
+
- jwk (JSON Web Key)
233
+
- created_at
234
+
235
+
### domains
236
+
- domain (primary key - e.g., alice.wisp.place)
237
+
- did (unique - user's DID)
238
+
- rkey (optional - record key)
239
+
- created_at
240
+
241
+
### custom_domains
242
+
- id (primary key - UUID)
243
+
- domain (unique - e.g., example.com)
244
+
- did (user's DID)
245
+
- rkey (optional)
246
+
- verified (boolean)
247
+
- last_verified_at (timestamp)
248
+
- created_at
249
+
250
+
### sites
251
+
- id, did, rkey, site_name
252
+
- created_at, updated_at
253
+
- Indexes on (did), (did, rkey), (rkey)
254
+
255
+
### admin_users
256
+
- username (primary key)
257
+
- password_hash (bcrypt)
258
+
- created_at
259
+
260
+
---
261
+
262
+
## ๐ Key Workflows
263
+
264
+
### Sign In Flow
265
+
1. POST /api/auth/signin with handle
266
+
2. System generates state token
267
+
3. Redirects to PDS OAuth endpoint
268
+
4. PDS redirects back to /api/auth/callback?code=X&state=Y
269
+
5. Validate state (CSRF protection)
270
+
6. Exchange code for session
271
+
7. Store session in DB, set DID cookie
272
+
8. Sync sites from PDS
273
+
9. Redirect to /editor or /onboarding
274
+
275
+
### File Upload Flow
276
+
1. POST /wisp/upload-files with siteName + files
277
+
2. Validate site name (rkey format rules)
278
+
3. For each file:
279
+
- Check size limits
280
+
- Read as ArrayBuffer
281
+
- Gzip compress
282
+
- Base64 encode
283
+
4. Upload all blobs in parallel via agent.com.atproto.repo.uploadBlob()
284
+
5. Create manifest with all blob refs
285
+
6. putRecord() for place.wisp.fs with manifest
286
+
7. Upsert to sites table
287
+
8. Return URI + CID
7
288
8
-
Service 1: Main App (Port 8000, Bun runtime, elysia.js)
9
-
- User-facing editor and API
10
-
- OAuth authentication (AT Protocol)
11
-
- File upload processing (gzip + base64 encoding)
12
-
- Domain management (subdomains + custom domains)
13
-
- DNS verification worker
14
-
- React frontend
289
+
### Domain Verification Flow
290
+
1. POST /api/custom-domains/claim
291
+
2. Generate hash = SHA256(domain + secret)
292
+
3. Store in custom_domains with verified=false
293
+
4. Return hash for user to configure DNS
294
+
5. Background worker periodically:
295
+
- Query custom_domains where verified=false
296
+
- Verify TXT record at _wisp.domain
297
+
- Verify CNAME points to hash.dns.wisp.place
298
+
- Update verified flag + last_verified_at
299
+
6. Hosting service routes when verified=true
15
300
16
-
Service 2: Hosting Service (Port 3001, Node.js runtime, hono.js)
17
-
- AT Protocol Firehose listener for real-time updates
18
-
- Serves hosted websites from local cache
19
-
- Multi-domain routing (custom domains, wisp.place subdomains, sites subdomain)
20
-
- Distributed locking for multi-instance coordination
301
+
---
21
302
22
-
Tech Stack
303
+
## ๐จ Frontend Structure
23
304
24
-
- Backend: Bun/Node.js, Elysia.js, PostgreSQL, AT Protocol SDK
25
-
- Frontend: React 19, Tailwind CSS v4, Shadcn UI
305
+
### `/public`
306
+
- **index.tsx** - Landing page with sign-in form
307
+
- **editor/editor.tsx** - Site editor/management UI
308
+
- **admin/admin.tsx** - Admin dashboard
309
+
- **components/ui/** - Reusable components (Button, Card, Dialog, etc.)
310
+
- **styles/global.css** - Tailwind + custom styles
26
311
27
-
Key Features
312
+
### Page Flow
313
+
1. `/` - Landing page (sign in / get started)
314
+
2. `/editor` - Main app (requires auth)
315
+
3. `/admin` - Admin console (requires admin auth)
316
+
4. `/onboarding` - First-time user setup
28
317
29
-
- AT Protocol Integration: Sites stored as place.wisp.fs records in user repos
30
-
- File Processing: Validates, compresses (gzip), encodes (base64), uploads to user's PDS
31
-
- Domain Management: wisp.place subdomains + custom BYOD domains with DNS verification
32
-
- Real-time Sync: Firehose worker listens for site updates and caches files locally
33
-
- Atomic Updates: Safe cache swapping without downtime
318
+
---
319
+
320
+
## ๐ Notable Implementation Patterns
321
+
322
+
### File Handling
323
+
- Files stored as base64-encoded gzip in PDS blobs
324
+
- Metadata preserves original MIME type
325
+
- Hosting service decompresses on serve
326
+
- Workaround for PDS image pipeline issues with HTML
327
+
328
+
### Error Handling
329
+
- Comprehensive logging with context
330
+
- Graceful degradation (e.g., site sync failure doesn't break auth)
331
+
- Structured error responses with details
332
+
333
+
### Performance
334
+
- Site sync: Batch fetch up to 100 records per request
335
+
- Blob upload: Parallel promises for all files
336
+
- DNS verification: Batched background worker (10 min intervals)
337
+
- Caching: Two-tier (DB + disk in hosting service)
338
+
339
+
### Validation
340
+
- Lexicon validation on manifest creation
341
+
- Record type checking
342
+
- Domain format validation
343
+
- Site name format validation (AT Protocol rkey rules)
344
+
- File size limits enforced before upload
345
+
346
+
---
347
+
348
+
## ๐ Known Quirks & Workarounds
349
+
350
+
1. **PDS Content Sniffing**: Files must be uploaded as `application/octet-stream` (even HTML) and base64-encoded to prevent PDS from misinterpreting content
351
+
352
+
2. **Max URL Query Size**: DNS verification worker queries in batch; may need pagination for users with many custom domains
353
+
354
+
3. **File Count Limits**: Max 500 entries per directory (Lexicon constraint); large sites split across multiple directories
355
+
356
+
4. **Blob Size Limits**: Individual blobs limited to 100MB by Lexicon; large files handled differently if needed
357
+
358
+
5. **HTML Path Rewriting**: Only in hosting service for `/s.wisp.place/:identifier/:site/*` routes; custom domains handled differently
359
+
360
+
---
361
+
362
+
## ๐ Environment Variables
363
+
364
+
- `DOMAIN` - Base domain with protocol (default: `https://wisp.place`)
365
+
- `CLIENT_NAME` - OAuth client name (default: `PDS-View`)
366
+
- `DATABASE_URL` - PostgreSQL connection (default: `postgres://postgres:postgres@localhost:5432/wisp`)
367
+
- `NODE_ENV` - production/development
368
+
- `HOSTING_PORT` - Hosting service port (default: 3001)
369
+
- `BASE_DOMAIN` - Domain for URLs (default: wisp.place)
370
+
371
+
---
372
+
373
+
## ๐งโ๐ป Development Notes
374
+
375
+
### Adding New Features
376
+
1. **New routes**: Add to `/src/routes/*.ts`, import in index.ts
377
+
2. **DB changes**: Add migration in db.ts
378
+
3. **New lexicons**: Update `/lexicons/*.json`, regenerate types
379
+
4. **Admin features**: Add to /api/admin endpoints
380
+
381
+
### Testing
382
+
- Run with `bun test`
383
+
- CSRF tests in lib/csrf.test.ts
384
+
- Utility tests in lib/wisp-utils.test.ts
385
+
386
+
### Debugging
387
+
- Check logs via `/api/admin/logs` (requires admin auth)
388
+
- DNS verification manual trigger: POST /api/admin/verify-dns
389
+
- Health check: GET /api/health (includes DNS verifier status)
390
+
391
+
---
392
+
393
+
## ๐ Deployment Considerations
394
+
395
+
1. **Secrets**: Admin password, OAuth keys, database credentials
396
+
2. **HTTPS**: Required (HSTS header enforces it)
397
+
3. **CDN**: Custom domains require DNS configuration
398
+
4. **Scaling**:
399
+
- Main server: Horizontal scaling with session DB
400
+
- Hosting service: Independent scaling, disk cache per instance
401
+
5. **Backups**: PostgreSQL database critical; firehose provides recovery
402
+
403
+
---
404
+
405
+
## ๐ Related Technologies
406
+
407
+
- **AT Protocol**: Decentralized identity, OAuth 2.0
408
+
- **Jacquard**: Rust library for AT Protocol interactions
409
+
- **Elysia**: Bun web framework (similar to Express/Hono)
410
+
- **Lexicon**: AT Protocol's schema definition language
411
+
- **Firehose**: Real-time event stream of repo changes
412
+
- **PDS**: Personal Data Server (where users' data stored)
413
+
414
+
---
415
+
416
+
## ๐ฏ Project Goals
417
+
418
+
โ
Decentralized site hosting (data owned by users)
419
+
โ
Custom domain support with DNS verification
420
+
โ
Fast CDN distribution via hosting service
421
+
โ
Developer tools (CLI + API)
422
+
โ
Admin dashboard for monitoring
423
+
โ
Zero user data retention (sites in PDS, sessions in DB only)
424
+
425
+
---
426
+
427
+
**Last Updated**: November 2025
428
+
**Status**: Active development
+24
cli/.gitignore
+24
cli/.gitignore
···
1
+
.DS_STORE
2
+
jacquard/
3
+
binaries/
4
+
# Generated by Cargo
5
+
# will have compiled files and executables
6
+
debug
7
+
target
8
+
9
+
# These are backup files generated by rustfmt
10
+
**/*.rs.bk
11
+
12
+
# MSVC Windows builds of rustc generate these, which store debugging information
13
+
*.pdb
14
+
15
+
# Generated by cargo mutants
16
+
# Contains mutation testing data
17
+
**/mutants.out*/
18
+
19
+
# RustRover
20
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
21
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
22
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
23
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
24
+
#.idea/
+9
-151
cli/Cargo.lock
+9
-151
cli/Cargo.lock
···
922
922
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
923
923
924
924
[[package]]
925
-
name = "foreign-types"
926
-
version = "0.3.2"
927
-
source = "registry+https://github.com/rust-lang/crates.io-index"
928
-
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
929
-
dependencies = [
930
-
"foreign-types-shared",
931
-
]
932
-
933
-
[[package]]
934
-
name = "foreign-types-shared"
935
-
version = "0.1.1"
936
-
source = "registry+https://github.com/rust-lang/crates.io-index"
937
-
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
938
-
939
-
[[package]]
940
925
name = "form_urlencoded"
941
926
version = "1.2.2"
942
927
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1340
1325
]
1341
1326
1342
1327
[[package]]
1343
-
name = "hyper-tls"
1344
-
version = "0.6.0"
1345
-
source = "registry+https://github.com/rust-lang/crates.io-index"
1346
-
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
1347
-
dependencies = [
1348
-
"bytes",
1349
-
"http-body-util",
1350
-
"hyper",
1351
-
"hyper-util",
1352
-
"native-tls",
1353
-
"tokio",
1354
-
"tokio-native-tls",
1355
-
"tower-service",
1356
-
]
1357
-
1358
-
[[package]]
1359
1328
name = "hyper-util"
1360
1329
version = "0.1.17"
1361
1330
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1614
1583
[[package]]
1615
1584
name = "jacquard"
1616
1585
version = "0.9.0"
1586
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
1617
1587
dependencies = [
1618
1588
"bytes",
1619
1589
"getrandom 0.2.16",
···
1641
1611
[[package]]
1642
1612
name = "jacquard-api"
1643
1613
version = "0.9.0"
1614
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
1644
1615
dependencies = [
1645
1616
"bon",
1646
1617
"bytes",
···
1658
1629
[[package]]
1659
1630
name = "jacquard-common"
1660
1631
version = "0.9.0"
1632
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
1661
1633
dependencies = [
1662
1634
"base64 0.22.1",
1663
1635
"bon",
···
1694
1666
[[package]]
1695
1667
name = "jacquard-derive"
1696
1668
version = "0.9.0"
1669
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
1697
1670
dependencies = [
1698
1671
"heck 0.5.0",
1699
1672
"jacquard-lexicon",
···
1704
1677
1705
1678
[[package]]
1706
1679
name = "jacquard-identity"
1707
-
version = "0.9.0"
1680
+
version = "0.9.1"
1681
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
1708
1682
dependencies = [
1709
1683
"bon",
1710
1684
"bytes",
···
1729
1703
1730
1704
[[package]]
1731
1705
name = "jacquard-lexicon"
1732
-
version = "0.9.0"
1706
+
version = "0.9.1"
1707
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
1733
1708
dependencies = [
1734
1709
"cid",
1735
1710
"dashmap",
···
1755
1730
[[package]]
1756
1731
name = "jacquard-oauth"
1757
1732
version = "0.9.0"
1733
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#b5cc9b35e38e24e1890ae55e700dcfad0d6d433a"
1758
1734
dependencies = [
1759
1735
"base64 0.22.1",
1760
1736
"bytes",
···
2132
2108
]
2133
2109
2134
2110
[[package]]
2135
-
name = "native-tls"
2136
-
version = "0.2.14"
2137
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2138
-
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
2139
-
dependencies = [
2140
-
"libc",
2141
-
"log",
2142
-
"openssl",
2143
-
"openssl-probe",
2144
-
"openssl-sys",
2145
-
"schannel",
2146
-
"security-framework",
2147
-
"security-framework-sys",
2148
-
"tempfile",
2149
-
]
2150
-
2151
-
[[package]]
2152
2111
name = "ndk-context"
2153
2112
version = "0.1.1"
2154
2113
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2288
2247
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
2289
2248
2290
2249
[[package]]
2291
-
name = "openssl"
2292
-
version = "0.10.74"
2293
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2294
-
checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654"
2295
-
dependencies = [
2296
-
"bitflags",
2297
-
"cfg-if",
2298
-
"foreign-types",
2299
-
"libc",
2300
-
"once_cell",
2301
-
"openssl-macros",
2302
-
"openssl-sys",
2303
-
]
2304
-
2305
-
[[package]]
2306
-
name = "openssl-macros"
2307
-
version = "0.1.1"
2308
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2309
-
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
2310
-
dependencies = [
2311
-
"proc-macro2",
2312
-
"quote",
2313
-
"syn 2.0.108",
2314
-
]
2315
-
2316
-
[[package]]
2317
-
name = "openssl-probe"
2318
-
version = "0.1.6"
2319
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2320
-
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
2321
-
2322
-
[[package]]
2323
-
name = "openssl-sys"
2324
-
version = "0.9.110"
2325
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2326
-
checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2"
2327
-
dependencies = [
2328
-
"cc",
2329
-
"libc",
2330
-
"pkg-config",
2331
-
"vcpkg",
2332
-
]
2333
-
2334
-
[[package]]
2335
2250
name = "option-ext"
2336
2251
version = "0.2.0"
2337
2252
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2497
2412
"der",
2498
2413
"spki",
2499
2414
]
2500
-
2501
-
[[package]]
2502
-
name = "pkg-config"
2503
-
version = "0.3.32"
2504
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2505
-
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
2506
2415
2507
2416
[[package]]
2508
2417
name = "potential_utf"
···
2827
2736
"http-body-util",
2828
2737
"hyper",
2829
2738
"hyper-rustls",
2830
-
"hyper-tls",
2831
2739
"hyper-util",
2832
2740
"js-sys",
2833
2741
"log",
2834
2742
"mime",
2835
-
"native-tls",
2836
2743
"percent-encoding",
2837
2744
"pin-project-lite",
2838
2745
"quinn",
···
2843
2750
"serde_urlencoded",
2844
2751
"sync_wrapper",
2845
2752
"tokio",
2846
-
"tokio-native-tls",
2847
2753
"tokio-rustls",
2848
2754
"tokio-util",
2849
2755
"tower",
···
3019
2925
]
3020
2926
3021
2927
[[package]]
3022
-
name = "schannel"
3023
-
version = "0.1.28"
3024
-
source = "registry+https://github.com/rust-lang/crates.io-index"
3025
-
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
3026
-
dependencies = [
3027
-
"windows-sys 0.61.2",
3028
-
]
3029
-
3030
-
[[package]]
3031
2928
name = "schemars"
3032
2929
version = "0.9.0"
3033
2930
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3069
2966
"pkcs8",
3070
2967
"subtle",
3071
2968
"zeroize",
3072
-
]
3073
-
3074
-
[[package]]
3075
-
name = "security-framework"
3076
-
version = "2.11.1"
3077
-
source = "registry+https://github.com/rust-lang/crates.io-index"
3078
-
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
3079
-
dependencies = [
3080
-
"bitflags",
3081
-
"core-foundation 0.9.4",
3082
-
"core-foundation-sys",
3083
-
"libc",
3084
-
"security-framework-sys",
3085
-
]
3086
-
3087
-
[[package]]
3088
-
name = "security-framework-sys"
3089
-
version = "2.15.0"
3090
-
source = "registry+https://github.com/rust-lang/crates.io-index"
3091
-
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
3092
-
dependencies = [
3093
-
"core-foundation-sys",
3094
-
"libc",
3095
2969
]
3096
2970
3097
2971
[[package]]
···
3698
3572
]
3699
3573
3700
3574
[[package]]
3701
-
name = "tokio-native-tls"
3702
-
version = "0.3.1"
3703
-
source = "registry+https://github.com/rust-lang/crates.io-index"
3704
-
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
3705
-
dependencies = [
3706
-
"native-tls",
3707
-
"tokio",
3708
-
]
3709
-
3710
-
[[package]]
3711
3575
name = "tokio-rustls"
3712
3576
version = "0.26.4"
3713
3577
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3927
3791
version = "0.2.2"
3928
3792
source = "registry+https://github.com/rust-lang/crates.io-index"
3929
3793
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
3930
-
3931
-
[[package]]
3932
-
name = "vcpkg"
3933
-
version = "0.2.15"
3934
-
source = "registry+https://github.com/rust-lang/crates.io-index"
3935
-
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
3936
3794
3937
3795
[[package]]
3938
3796
name = "version_check"
+9
-8
cli/Cargo.toml
+9
-8
cli/Cargo.toml
···
8
8
place_wisp = []
9
9
10
10
[dependencies]
11
-
jacquard = { path = "jacquard/crates/jacquard", features = ["loopback"] }
12
-
jacquard-oauth = { path = "jacquard/crates/jacquard-oauth" }
13
-
jacquard-api = { path = "jacquard/crates/jacquard-api" }
14
-
jacquard-common = { path = "jacquard/crates/jacquard-common" }
15
-
jacquard-identity = { path = "jacquard/crates/jacquard-identity", features = ["dns"] }
16
-
jacquard-derive = { path = "jacquard/crates/jacquard-derive" }
17
-
jacquard-lexicon = { path = "jacquard/crates/jacquard-lexicon" }
11
+
jacquard = { git = "https://tangled.org/@nonbinary.computer/jacquard", features = ["loopback"] }
12
+
jacquard-oauth = { git = "https://tangled.org/@nonbinary.computer/jacquard" }
13
+
jacquard-api = { git = "https://tangled.org/@nonbinary.computer/jacquard" }
14
+
jacquard-common = { git = "https://tangled.org/@nonbinary.computer/jacquard" }
15
+
jacquard-identity = { git = "https://tangled.org/@nonbinary.computer/jacquard", features = ["dns"] }
16
+
jacquard-derive = { git = "https://tangled.org/@nonbinary.computer/jacquard" }
17
+
jacquard-lexicon = { git = "https://tangled.org/@nonbinary.computer/jacquard" }
18
18
clap = { version = "4.5.51", features = ["derive"] }
19
19
tokio = { version = "1.48", features = ["full"] }
20
20
miette = { version = "7.6.0", features = ["fancy"] }
21
21
serde_json = "1.0.145"
22
22
serde = { version = "1.0", features = ["derive"] }
23
23
shellexpand = "3.1.1"
24
-
reqwest = "0.12"
24
+
#reqwest = "0.12"
25
+
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
25
26
rustversion = "1.0"
26
27
flate2 = "1.0"
27
28
base64 = "0.22"
+23
cli/build-linux.sh
+23
cli/build-linux.sh
···
1
+
#!/usr/bin/env bash
2
+
# Build Linux binaries (statically linked)
3
+
set -e
4
+
mkdir -p binaries
5
+
6
+
# Build Linux binaries
7
+
echo "Building Linux binaries..."
8
+
9
+
echo "Building Linux ARM64 (static)..."
10
+
nix-shell -p rustup --run '
11
+
rustup target add aarch64-unknown-linux-musl
12
+
RUSTFLAGS="-C target-feature=+crt-static" cargo zigbuild --release --target aarch64-unknown-linux-musl
13
+
'
14
+
cp target/aarch64-unknown-linux-musl/release/wisp-cli binaries/wisp-cli-aarch64-linux
15
+
16
+
echo "Building Linux x86_64 (static)..."
17
+
nix-shell -p rustup --run '
18
+
rustup target add x86_64-unknown-linux-musl
19
+
RUSTFLAGS="-C target-feature=+crt-static" cargo build --release --target x86_64-unknown-linux-musl
20
+
'
21
+
cp target/x86_64-unknown-linux-musl/release/wisp-cli binaries/wisp-cli-x86_64-linux
22
+
23
+
echo "Done! Binaries in ./binaries/"
+15
cli/build-macos.sh
+15
cli/build-macos.sh
···
1
+
#!/bin/bash
2
+
# Build Linux and macOS binaries
3
+
4
+
set -e
5
+
6
+
mkdir -p binaries
7
+
rm -rf target
8
+
9
+
# Build macOS binaries natively
10
+
echo "Building macOS binaries..."
11
+
rustup target add aarch64-apple-darwin
12
+
13
+
echo "Building macOS arm64 binary."
14
+
RUSTFLAGS="-C target-feature=+crt-static" cargo build --release --target aarch64-apple-darwin
15
+
cp target/aarch64-apple-darwin/release/wisp-cli binaries/wisp-cli-macos-arm64
-14
cli/test_headers.rs
-14
cli/test_headers.rs
···
1
-
use http::Request;
2
-
3
-
fn main() {
4
-
let builder = Request::builder()
5
-
.header(http::header::CONTENT_TYPE, "*/*")
6
-
.header(http::header::CONTENT_TYPE, "application/octet-stream");
7
-
8
-
let req = builder.body(()).unwrap();
9
-
10
-
println!("Content-Type headers:");
11
-
for value in req.headers().get_all(http::header::CONTENT_TYPE) {
12
-
println!(" {:?}", value);
13
-
}
14
-
}
+63
crates.nix
+63
crates.nix
···
1
+
{...}: {
2
+
perSystem = {
3
+
pkgs,
4
+
config,
5
+
lib,
6
+
inputs',
7
+
...
8
+
}: {
9
+
# declare projects
10
+
nci.projects."wisp-place-cli" = {
11
+
path = ./cli;
12
+
export = false;
13
+
};
14
+
nci.toolchains.mkBuild = _:
15
+
with inputs'.fenix.packages;
16
+
combine [
17
+
minimal.rustc
18
+
minimal.cargo
19
+
targets.x86_64-pc-windows-gnu.latest.rust-std
20
+
targets.x86_64-unknown-linux-gnu.latest.rust-std
21
+
targets.aarch64-apple-darwin.latest.rust-std
22
+
];
23
+
# configure crates
24
+
nci.crates."wisp-cli" = {
25
+
profiles = {
26
+
dev.runTests = false;
27
+
release.runTests = false;
28
+
};
29
+
targets."x86_64-unknown-linux-gnu" = {
30
+
default = true;
31
+
};
32
+
targets."x86_64-pc-windows-gnu" = let
33
+
targetPkgs = pkgs.pkgsCross.mingwW64;
34
+
targetCC = targetPkgs.stdenv.cc;
35
+
targetCargoEnvVarTarget = targetPkgs.stdenv.hostPlatform.rust.cargoEnvVarTarget;
36
+
in rec {
37
+
depsDrvConfig.mkDerivation = {
38
+
nativeBuildInputs = [targetCC];
39
+
buildInputs = with targetPkgs; [windows.pthreads];
40
+
};
41
+
depsDrvConfig.env = rec {
42
+
TARGET_CC = "${targetCC.targetPrefix}cc";
43
+
"CARGO_TARGET_${targetCargoEnvVarTarget}_LINKER" = TARGET_CC;
44
+
};
45
+
drvConfig = depsDrvConfig;
46
+
};
47
+
targets."aarch64-apple-darwin" = let
48
+
targetPkgs = pkgs.pkgsCross.aarch64-darwin;
49
+
targetCC = targetPkgs.stdenv.cc;
50
+
targetCargoEnvVarTarget = targetPkgs.stdenv.hostPlatform.rust.cargoEnvVarTarget;
51
+
in rec {
52
+
depsDrvConfig.mkDerivation = {
53
+
nativeBuildInputs = [targetCC];
54
+
};
55
+
depsDrvConfig.env = rec {
56
+
TARGET_CC = "${targetCC.targetPrefix}cc";
57
+
"CARGO_TARGET_${targetCargoEnvVarTarget}_LINKER" = TARGET_CC;
58
+
};
59
+
drvConfig = depsDrvConfig;
60
+
};
61
+
};
62
+
};
63
+
}
+318
flake.lock
+318
flake.lock
···
1
+
{
2
+
"nodes": {
3
+
"crane": {
4
+
"flake": false,
5
+
"locked": {
6
+
"lastModified": 1758758545,
7
+
"narHash": "sha256-NU5WaEdfwF6i8faJ2Yh+jcK9vVFrofLcwlD/mP65JrI=",
8
+
"owner": "ipetkov",
9
+
"repo": "crane",
10
+
"rev": "95d528a5f54eaba0d12102249ce42f4d01f4e364",
11
+
"type": "github"
12
+
},
13
+
"original": {
14
+
"owner": "ipetkov",
15
+
"ref": "v0.21.1",
16
+
"repo": "crane",
17
+
"type": "github"
18
+
}
19
+
},
20
+
"dream2nix": {
21
+
"inputs": {
22
+
"nixpkgs": [
23
+
"nci",
24
+
"nixpkgs"
25
+
],
26
+
"purescript-overlay": "purescript-overlay",
27
+
"pyproject-nix": "pyproject-nix"
28
+
},
29
+
"locked": {
30
+
"lastModified": 1754978539,
31
+
"narHash": "sha256-nrDovydywSKRbWim9Ynmgj8SBm8LK3DI2WuhIqzOHYI=",
32
+
"owner": "nix-community",
33
+
"repo": "dream2nix",
34
+
"rev": "fbec3263cb4895ac86ee9506cdc4e6919a1a2214",
35
+
"type": "github"
36
+
},
37
+
"original": {
38
+
"owner": "nix-community",
39
+
"repo": "dream2nix",
40
+
"type": "github"
41
+
}
42
+
},
43
+
"fenix": {
44
+
"inputs": {
45
+
"nixpkgs": [
46
+
"nixpkgs"
47
+
],
48
+
"rust-analyzer-src": "rust-analyzer-src"
49
+
},
50
+
"locked": {
51
+
"lastModified": 1762584108,
52
+
"narHash": "sha256-wZUW7dlXMXaRdvNbaADqhF8gg9bAfFiMV+iyFQiDv+Y=",
53
+
"owner": "nix-community",
54
+
"repo": "fenix",
55
+
"rev": "32f3ad3b6c690061173e1ac16708874975ec6056",
56
+
"type": "github"
57
+
},
58
+
"original": {
59
+
"owner": "nix-community",
60
+
"repo": "fenix",
61
+
"type": "github"
62
+
}
63
+
},
64
+
"flake-compat": {
65
+
"flake": false,
66
+
"locked": {
67
+
"lastModified": 1696426674,
68
+
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
69
+
"owner": "edolstra",
70
+
"repo": "flake-compat",
71
+
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
72
+
"type": "github"
73
+
},
74
+
"original": {
75
+
"owner": "edolstra",
76
+
"repo": "flake-compat",
77
+
"type": "github"
78
+
}
79
+
},
80
+
"mk-naked-shell": {
81
+
"flake": false,
82
+
"locked": {
83
+
"lastModified": 1681286841,
84
+
"narHash": "sha256-3XlJrwlR0nBiREnuogoa5i1b4+w/XPe0z8bbrJASw0g=",
85
+
"owner": "90-008",
86
+
"repo": "mk-naked-shell",
87
+
"rev": "7612f828dd6f22b7fb332cc69440e839d7ffe6bd",
88
+
"type": "github"
89
+
},
90
+
"original": {
91
+
"owner": "90-008",
92
+
"repo": "mk-naked-shell",
93
+
"type": "github"
94
+
}
95
+
},
96
+
"nci": {
97
+
"inputs": {
98
+
"crane": "crane",
99
+
"dream2nix": "dream2nix",
100
+
"mk-naked-shell": "mk-naked-shell",
101
+
"nixpkgs": [
102
+
"nixpkgs"
103
+
],
104
+
"parts": "parts",
105
+
"rust-overlay": "rust-overlay",
106
+
"treefmt": "treefmt"
107
+
},
108
+
"locked": {
109
+
"lastModified": 1762582646,
110
+
"narHash": "sha256-MMzE4xccG+8qbLhdaZoeFDUKWUOn3B4lhp5dZmgukmM=",
111
+
"owner": "90-008",
112
+
"repo": "nix-cargo-integration",
113
+
"rev": "0993c449377049fa8868a664e8290ac6658e0b9a",
114
+
"type": "github"
115
+
},
116
+
"original": {
117
+
"owner": "90-008",
118
+
"repo": "nix-cargo-integration",
119
+
"type": "github"
120
+
}
121
+
},
122
+
"nixpkgs": {
123
+
"locked": {
124
+
"lastModified": 1762361079,
125
+
"narHash": "sha256-lz718rr1BDpZBYk7+G8cE6wee3PiBUpn8aomG/vLLiY=",
126
+
"owner": "nixos",
127
+
"repo": "nixpkgs",
128
+
"rev": "ffcdcf99d65c61956d882df249a9be53e5902ea5",
129
+
"type": "github"
130
+
},
131
+
"original": {
132
+
"owner": "nixos",
133
+
"ref": "nixpkgs-unstable",
134
+
"repo": "nixpkgs",
135
+
"type": "github"
136
+
}
137
+
},
138
+
"parts": {
139
+
"inputs": {
140
+
"nixpkgs-lib": [
141
+
"nci",
142
+
"nixpkgs"
143
+
]
144
+
},
145
+
"locked": {
146
+
"lastModified": 1762440070,
147
+
"narHash": "sha256-xxdepIcb39UJ94+YydGP221rjnpkDZUlykKuF54PsqI=",
148
+
"owner": "hercules-ci",
149
+
"repo": "flake-parts",
150
+
"rev": "26d05891e14c88eb4a5d5bee659c0db5afb609d8",
151
+
"type": "github"
152
+
},
153
+
"original": {
154
+
"owner": "hercules-ci",
155
+
"repo": "flake-parts",
156
+
"type": "github"
157
+
}
158
+
},
159
+
"parts_2": {
160
+
"inputs": {
161
+
"nixpkgs-lib": [
162
+
"nixpkgs"
163
+
]
164
+
},
165
+
"locked": {
166
+
"lastModified": 1762440070,
167
+
"narHash": "sha256-xxdepIcb39UJ94+YydGP221rjnpkDZUlykKuF54PsqI=",
168
+
"owner": "hercules-ci",
169
+
"repo": "flake-parts",
170
+
"rev": "26d05891e14c88eb4a5d5bee659c0db5afb609d8",
171
+
"type": "github"
172
+
},
173
+
"original": {
174
+
"owner": "hercules-ci",
175
+
"repo": "flake-parts",
176
+
"type": "github"
177
+
}
178
+
},
179
+
"purescript-overlay": {
180
+
"inputs": {
181
+
"flake-compat": "flake-compat",
182
+
"nixpkgs": [
183
+
"nci",
184
+
"dream2nix",
185
+
"nixpkgs"
186
+
],
187
+
"slimlock": "slimlock"
188
+
},
189
+
"locked": {
190
+
"lastModified": 1728546539,
191
+
"narHash": "sha256-Sws7w0tlnjD+Bjck1nv29NjC5DbL6nH5auL9Ex9Iz2A=",
192
+
"owner": "thomashoneyman",
193
+
"repo": "purescript-overlay",
194
+
"rev": "4ad4c15d07bd899d7346b331f377606631eb0ee4",
195
+
"type": "github"
196
+
},
197
+
"original": {
198
+
"owner": "thomashoneyman",
199
+
"repo": "purescript-overlay",
200
+
"type": "github"
201
+
}
202
+
},
203
+
"pyproject-nix": {
204
+
"inputs": {
205
+
"nixpkgs": [
206
+
"nci",
207
+
"dream2nix",
208
+
"nixpkgs"
209
+
]
210
+
},
211
+
"locked": {
212
+
"lastModified": 1752481895,
213
+
"narHash": "sha256-luVj97hIMpCbwhx3hWiRwjP2YvljWy8FM+4W9njDhLA=",
214
+
"owner": "pyproject-nix",
215
+
"repo": "pyproject.nix",
216
+
"rev": "16ee295c25107a94e59a7fc7f2e5322851781162",
217
+
"type": "github"
218
+
},
219
+
"original": {
220
+
"owner": "pyproject-nix",
221
+
"repo": "pyproject.nix",
222
+
"type": "github"
223
+
}
224
+
},
225
+
"root": {
226
+
"inputs": {
227
+
"fenix": "fenix",
228
+
"nci": "nci",
229
+
"nixpkgs": "nixpkgs",
230
+
"parts": "parts_2"
231
+
}
232
+
},
233
+
"rust-analyzer-src": {
234
+
"flake": false,
235
+
"locked": {
236
+
"lastModified": 1762438844,
237
+
"narHash": "sha256-ApIKJf6CcMsV2nYBXhGF95BmZMO/QXPhgfSnkA/rVUo=",
238
+
"owner": "rust-lang",
239
+
"repo": "rust-analyzer",
240
+
"rev": "4bf516ee5a960c1e2eee9fedd9b1c9e976a19c86",
241
+
"type": "github"
242
+
},
243
+
"original": {
244
+
"owner": "rust-lang",
245
+
"ref": "nightly",
246
+
"repo": "rust-analyzer",
247
+
"type": "github"
248
+
}
249
+
},
250
+
"rust-overlay": {
251
+
"inputs": {
252
+
"nixpkgs": [
253
+
"nci",
254
+
"nixpkgs"
255
+
]
256
+
},
257
+
"locked": {
258
+
"lastModified": 1762569282,
259
+
"narHash": "sha256-vINZAJpXQTZd5cfh06Rcw7hesH7sGSvi+Tn+HUieJn8=",
260
+
"owner": "oxalica",
261
+
"repo": "rust-overlay",
262
+
"rev": "a35a6144b976f70827c2fe2f5c89d16d8f9179d8",
263
+
"type": "github"
264
+
},
265
+
"original": {
266
+
"owner": "oxalica",
267
+
"repo": "rust-overlay",
268
+
"type": "github"
269
+
}
270
+
},
271
+
"slimlock": {
272
+
"inputs": {
273
+
"nixpkgs": [
274
+
"nci",
275
+
"dream2nix",
276
+
"purescript-overlay",
277
+
"nixpkgs"
278
+
]
279
+
},
280
+
"locked": {
281
+
"lastModified": 1688756706,
282
+
"narHash": "sha256-xzkkMv3neJJJ89zo3o2ojp7nFeaZc2G0fYwNXNJRFlo=",
283
+
"owner": "thomashoneyman",
284
+
"repo": "slimlock",
285
+
"rev": "cf72723f59e2340d24881fd7bf61cb113b4c407c",
286
+
"type": "github"
287
+
},
288
+
"original": {
289
+
"owner": "thomashoneyman",
290
+
"repo": "slimlock",
291
+
"type": "github"
292
+
}
293
+
},
294
+
"treefmt": {
295
+
"inputs": {
296
+
"nixpkgs": [
297
+
"nci",
298
+
"nixpkgs"
299
+
]
300
+
},
301
+
"locked": {
302
+
"lastModified": 1762410071,
303
+
"narHash": "sha256-aF5fvoZeoXNPxT0bejFUBXeUjXfHLSL7g+mjR/p5TEg=",
304
+
"owner": "numtide",
305
+
"repo": "treefmt-nix",
306
+
"rev": "97a30861b13c3731a84e09405414398fbf3e109f",
307
+
"type": "github"
308
+
},
309
+
"original": {
310
+
"owner": "numtide",
311
+
"repo": "treefmt-nix",
312
+
"type": "github"
313
+
}
314
+
}
315
+
},
316
+
"root": "root",
317
+
"version": 7
318
+
}
+36
flake.nix
+36
flake.nix
···
1
+
{
2
+
inputs.nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
3
+
inputs.nci.url = "github:90-008/nix-cargo-integration";
4
+
inputs.nci.inputs.nixpkgs.follows = "nixpkgs";
5
+
inputs.parts.url = "github:hercules-ci/flake-parts";
6
+
inputs.parts.inputs.nixpkgs-lib.follows = "nixpkgs";
7
+
inputs.fenix = {
8
+
url = "github:nix-community/fenix";
9
+
inputs.nixpkgs.follows = "nixpkgs";
10
+
};
11
+
12
+
outputs = inputs @ {
13
+
parts,
14
+
nci,
15
+
...
16
+
}:
17
+
parts.lib.mkFlake {inherit inputs;} {
18
+
systems = ["x86_64-linux" "aarch64-darwin"];
19
+
imports = [
20
+
nci.flakeModule
21
+
./crates.nix
22
+
];
23
+
perSystem = {
24
+
pkgs,
25
+
config,
26
+
...
27
+
}: let
28
+
crateOutputs = config.nci.outputs."wisp-cli";
29
+
in {
30
+
devShells.default = crateOutputs.devShell;
31
+
packages.default = crateOutputs.packages.release;
32
+
packages.wisp-cli-windows = crateOutputs.allTargets."x86_64-pc-windows-gnu".packages.release;
33
+
packages.wisp-cli-darwin = crateOutputs.allTargets."aarch64-apple-darwin".packages.release;
34
+
};
35
+
};
36
+
}
+12
-190
hosting-service/bun.lock
+12
-190
hosting-service/bun.lock
···
9
9
"@atproto/lexicon": "^0.5.1",
10
10
"@atproto/sync": "^0.1.36",
11
11
"@atproto/xrpc": "^0.7.5",
12
-
"@elysiajs/node": "^1.4.2",
13
-
"@elysiajs/opentelemetry": "latest",
14
-
"elysia": "^1.4.15",
12
+
"@hono/node-server": "^1.19.6",
13
+
"hono": "^4.10.4",
15
14
"mime-types": "^2.1.35",
16
15
"multiformats": "^13.4.1",
17
16
"postgres": "^3.4.5",
18
17
},
19
18
"devDependencies": {
19
+
"@types/bun": "^1.3.1",
20
20
"@types/mime-types": "^2.1.4",
21
21
"@types/node": "^22.10.5",
22
22
"tsx": "^4.19.2",
···
46
46
47
47
"@atproto/xrpc-server": ["@atproto/xrpc-server@0.9.5", "", { "dependencies": { "@atproto/common": "^0.4.12", "@atproto/crypto": "^0.4.4", "@atproto/lexicon": "^0.5.1", "@atproto/xrpc": "^0.7.5", "cbor-x": "^1.5.1", "express": "^4.17.2", "http-errors": "^2.0.0", "mime-types": "^2.1.35", "rate-limiter-flexible": "^2.4.1", "uint8arrays": "3.0.0", "ws": "^8.12.0", "zod": "^3.23.8" } }, ""],
48
48
49
-
"@borewit/text-codec": ["@borewit/text-codec@0.1.1", "", {}, "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA=="],
50
-
51
49
"@cbor-extract/cbor-extract-darwin-arm64": ["@cbor-extract/cbor-extract-darwin-arm64@2.2.0", "", { "os": "darwin", "cpu": "arm64" }, ""],
52
-
53
-
"@elysiajs/node": ["@elysiajs/node@1.4.2", "", { "dependencies": { "crossws": "^0.4.1", "srvx": "^0.9.4" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-zqeBAV4/faCcmIEjCp3g6jRwsbaWsd5HqmlEf3CirD9HkTWQNo4T+GN/qGZi7zgd84D3Kzxsny7ZTMXEfrDSXQ=="],
54
-
55
-
"@elysiajs/opentelemetry": ["@elysiajs/opentelemetry@1.4.6", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/instrumentation": "^0.200.0", "@opentelemetry/sdk-node": "^0.200.0" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-jR7t4M6ZvMnBqzzHsNTL6y3sNq9jbGi2vKxbkizi/OO5tlvlKl/rnBGyFjZUjQ1Hte7rCz+2kfmgOQMhkjk+Og=="],
56
50
57
51
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.11", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg=="],
58
52
···
106
100
107
101
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.11", "", { "os": "win32", "cpu": "x64" }, "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA=="],
108
102
109
-
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.0", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-N8Jx6PaYzcTRNzirReJCtADVoq4z7+1KQ4E70jTg/koQiMoUSN1kbNjPOqpPbhMFhfU1/l7ixspPl8dNY+FoUg=="],
110
-
111
-
"@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="],
103
+
"@hono/node-server": ["@hono/node-server@1.19.6", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw=="],
112
104
113
105
"@ipld/dag-cbor": ["@ipld/dag-cbor@7.0.3", "", { "dependencies": { "cborg": "^1.6.0", "multiformats": "^9.5.4" } }, ""],
114
106
115
-
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
116
-
117
107
"@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, ""],
118
108
119
109
"@noble/hashes": ["@noble/hashes@1.8.0", "", {}, ""],
120
110
121
-
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
122
-
123
-
"@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.200.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IKJBQxh91qJ+3ssRly5hYEJ8NDHu9oY/B1PXVSCWf7zytmYO9RNLB0Ox9XQ/fJ8m6gY6Q6NtBWlmXfaXt5Uc4Q=="],
124
-
125
-
"@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.0.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-IEkJGzK1A9v3/EHjXh3s2IiFc6L4jfK+lNgKVgUjeUJQRRhnVFMIO3TAvKwonm9O1HebCuoOt98v8bZW7oVQHA=="],
126
-
127
-
"@opentelemetry/core": ["@opentelemetry/core@2.0.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-SLX36allrcnVaPYG3R78F/UZZsBsvbc7lMCLx37LyH5MJ1KAAZ2E3mW9OAD3zGz0G8q/BtoS5VUrjzDydhD6LQ=="],
128
-
129
-
"@opentelemetry/exporter-logs-otlp-grpc": ["@opentelemetry/exporter-logs-otlp-grpc@0.200.0", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-grpc-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/sdk-logs": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-+3MDfa5YQPGM3WXxW9kqGD85Q7s9wlEMVNhXXG7tYFLnIeaseUt9YtCeFhEDFzfEktacdFpOtXmJuNW8cHbU5A=="],
130
-
131
-
"@opentelemetry/exporter-logs-otlp-http": ["@opentelemetry/exporter-logs-otlp-http@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/sdk-logs": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-KfWw49htbGGp9s8N4KI8EQ9XuqKJ0VG+yVYVYFiCYSjEV32qpQ5qZ9UZBzOZ6xRb+E16SXOSCT3RkqBVSABZ+g=="],
132
-
133
-
"@opentelemetry/exporter-logs-otlp-proto": ["@opentelemetry/exporter-logs-otlp-proto@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-logs": "0.200.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-GmahpUU/55hxfH4TP77ChOfftADsCq/nuri73I/AVLe2s4NIglvTsaACkFVZAVmnXXyPS00Fk3x27WS3yO07zA=="],
134
-
135
-
"@opentelemetry/exporter-metrics-otlp-grpc": ["@opentelemetry/exporter-metrics-otlp-grpc@0.200.0", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "2.0.0", "@opentelemetry/exporter-metrics-otlp-http": "0.200.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-grpc-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-metrics": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-uHawPRvKIrhqH09GloTuYeq2BjyieYHIpiklOvxm9zhrCL2eRsnI/6g9v2BZTVtGp8tEgIa7rCQ6Ltxw6NBgew=="],
136
-
137
-
"@opentelemetry/exporter-metrics-otlp-http": ["@opentelemetry/exporter-metrics-otlp-http@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-metrics": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-5BiR6i8yHc9+qW7F6LqkuUnIzVNA7lt0qRxIKcKT+gq3eGUPHZ3DY29sfxI3tkvnwMgtnHDMNze5DdxW39HsAw=="],
138
-
139
-
"@opentelemetry/exporter-metrics-otlp-proto": ["@opentelemetry/exporter-metrics-otlp-proto@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/exporter-metrics-otlp-http": "0.200.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-metrics": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-E+uPj0yyvz81U9pvLZp3oHtFrEzNSqKGVkIViTQY1rH3TOobeJPSpLnTVXACnCwkPR5XeTvPnK3pZ2Kni8AFMg=="],
140
-
141
-
"@opentelemetry/exporter-prometheus": ["@opentelemetry/exporter-prometheus@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-metrics": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-ZYdlU9r0USuuYppiDyU2VFRA0kFl855ylnb3N/2aOlXrbA4PMCznen7gmPbetGQu7pz8Jbaf4fwvrDnVdQQXSw=="],
142
-
143
-
"@opentelemetry/exporter-trace-otlp-grpc": ["@opentelemetry/exporter-trace-otlp-grpc@0.200.0", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-grpc-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-hmeZrUkFl1YMsgukSuHCFPYeF9df0hHoKeHUthRKFCxiURs+GwF1VuabuHmBMZnjTbsuvNjOB+JSs37Csem/5Q=="],
144
-
145
-
"@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Goi//m/7ZHeUedxTGVmEzH19NgqJY+Bzr6zXo1Rni1+hwqaksEyJ44gdlEMREu6dzX1DlAaH/qSykSVzdrdafA=="],
146
-
147
-
"@opentelemetry/exporter-trace-otlp-proto": ["@opentelemetry/exporter-trace-otlp-proto@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-V9TDSD3PjK1OREw2iT9TUTzNYEVWJk4Nhodzhp9eiz4onDMYmPy3LaGbPv81yIR6dUb/hNp/SIhpiCHwFUq2Vg=="],
148
-
149
-
"@opentelemetry/exporter-zipkin": ["@opentelemetry/exporter-zipkin@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0" } }, "sha512-icxaKZ+jZL/NHXX8Aru4HGsrdhK0MLcuRXkX5G5IRmCgoRLw+Br6I/nMVozX2xjGGwV7hw2g+4Slj8K7s4HbVg=="],
150
-
151
-
"@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@types/shimmer": "^1.2.0", "import-in-the-middle": "^1.8.1", "require-in-the-middle": "^7.1.1", "shimmer": "^1.2.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-pmPlzfJd+vvgaZd/reMsC8RWgTXn2WY1OWT5RT42m3aOn5532TozwXNDhg1vzqJ+jnvmkREcdLr27ebJEQt0Jg=="],
152
-
153
-
"@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-transformer": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IxJgA3FD7q4V6gGq4bnmQM5nTIyMDkoGFGrBrrDjB6onEiq1pafma55V+bHvGYLWvcqbBbRfezr1GED88lacEQ=="],
154
-
155
-
"@opentelemetry/otlp-grpc-exporter-base": ["@opentelemetry/otlp-grpc-exporter-base@0.200.0", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CK2S+bFgOZ66Bsu5hlDeOX6cvW5FVtVjFFbWuaJP0ELxJKBB6HlbLZQ2phqz/uLj1cWap5xJr/PsR3iGoB7Vqw=="],
156
-
157
-
"@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-logs": "0.200.0", "@opentelemetry/sdk-metrics": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-+9YDZbYybOnv7sWzebWOeK6gKyt2XE7iarSyBFkwwnP559pEevKOUD8NyDHhRjCSp13ybh9iVXlMfcj/DwF/yw=="],
158
-
159
-
"@opentelemetry/propagator-b3": ["@opentelemetry/propagator-b3@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-blx9S2EI49Ycuw6VZq+bkpaIoiJFhsDuvFGhBIoH3vJ5oYjJ2U0s3fAM5jYft99xVIAv6HqoPtlP9gpVA2IZtA=="],
160
-
161
-
"@opentelemetry/propagator-jaeger": ["@opentelemetry/propagator-jaeger@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-Mbm/LSFyAtQKP0AQah4AfGgsD+vsZcyreZoQ5okFBk33hU7AquU4TltgyL9dvaO8/Zkoud8/0gEvwfOZ5d7EPA=="],
162
-
163
-
"@opentelemetry/resources": ["@opentelemetry/resources@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-rnZr6dML2z4IARI4zPGQV4arDikF/9OXZQzrC01dLmn0CZxU5U5OLd/m1T7YkGRj5UitjeoCtg/zorlgMQcdTg=="],
164
-
165
-
"@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-VZG870063NLfObmQQNtCVcdXXLzI3vOjjrRENmU37HYiPFa0ZXpXVDsTD02Nh3AT3xYJzQaWKl2X2lQ2l7TWJA=="],
166
-
167
-
"@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Bvy8QDjO05umd0+j+gDeWcTaVa1/R2lDj/eOvjzpm8VQj1K1vVZJuyjThpV5/lSHyYW2JaHF2IQ7Z8twJFAhjA=="],
168
-
169
-
"@opentelemetry/sdk-node": ["@opentelemetry/sdk-node@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/exporter-logs-otlp-grpc": "0.200.0", "@opentelemetry/exporter-logs-otlp-http": "0.200.0", "@opentelemetry/exporter-logs-otlp-proto": "0.200.0", "@opentelemetry/exporter-metrics-otlp-grpc": "0.200.0", "@opentelemetry/exporter-metrics-otlp-http": "0.200.0", "@opentelemetry/exporter-metrics-otlp-proto": "0.200.0", "@opentelemetry/exporter-prometheus": "0.200.0", "@opentelemetry/exporter-trace-otlp-grpc": "0.200.0", "@opentelemetry/exporter-trace-otlp-http": "0.200.0", "@opentelemetry/exporter-trace-otlp-proto": "0.200.0", "@opentelemetry/exporter-zipkin": "2.0.0", "@opentelemetry/instrumentation": "0.200.0", "@opentelemetry/propagator-b3": "2.0.0", "@opentelemetry/propagator-jaeger": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-logs": "0.200.0", "@opentelemetry/sdk-metrics": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0", "@opentelemetry/sdk-trace-node": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-S/YSy9GIswnhYoDor1RusNkmRughipvTCOQrlF1dzI70yQaf68qgf5WMnzUxdlCl3/et/pvaO75xfPfuEmCK5A=="],
170
-
171
-
"@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-qQnYdX+ZCkonM7tA5iU4fSRsVxbFGml8jbxOgipRGMFHKaXKHQ30js03rTobYjKjIfnOsZSbHKWF0/0v0OQGfw=="],
172
-
173
-
"@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.0.0", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.0.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-omdilCZozUjQwY3uZRBwbaRMJ3p09l4t187Lsdf0dGMye9WKD4NGcpgZRvqhI1dwcH6og+YXQEtoO9Wx3ykilg=="],
174
-
175
-
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.37.0", "", {}, "sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA=="],
176
-
177
-
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
178
-
179
-
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
180
-
181
-
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="],
182
-
183
-
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
184
-
185
-
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
186
-
187
-
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
188
-
189
-
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
190
-
191
-
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
192
-
193
-
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
194
-
195
-
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
196
-
197
-
"@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="],
198
-
199
-
"@tokenizer/inflate": ["@tokenizer/inflate@0.2.7", "", { "dependencies": { "debug": "^4.4.0", "fflate": "^0.8.2", "token-types": "^6.0.0" } }, "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg=="],
200
-
201
-
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
111
+
"@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="],
202
112
203
113
"@types/mime-types": ["@types/mime-types@2.1.4", "", {}, "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w=="],
204
114
205
115
"@types/node": ["@types/node@22.18.12", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BICHQ67iqxQGFSzfCFTT7MRQ5XcBjG5aeKh5Ok38UBbPe5fxTyE+aHFxwVrGyr8GNlqFMLKD1D3P2K/1ks8tog=="],
206
116
207
-
"@types/shimmer": ["@types/shimmer@1.2.0", "", {}, "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg=="],
117
+
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
208
118
209
119
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, ""],
210
120
211
121
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, ""],
212
122
213
-
"acorn": ["acorn@8.15.0", "", { "bin": "bin/acorn" }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
214
-
215
-
"acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="],
216
-
217
-
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
218
-
219
-
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
220
-
221
123
"array-flatten": ["array-flatten@1.1.1", "", {}, ""],
222
124
223
125
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, ""],
···
230
132
231
133
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, ""],
232
134
135
+
"bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="],
136
+
233
137
"bytes": ["bytes@3.1.2", "", {}, ""],
234
138
235
139
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, ""],
···
242
146
243
147
"cborg": ["cborg@1.10.2", "", { "bin": "cli.js" }, ""],
244
148
245
-
"cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="],
246
-
247
-
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
248
-
249
-
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
250
-
251
-
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
252
-
253
149
"content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, ""],
254
150
255
151
"content-type": ["content-type@1.0.5", "", {}, ""],
256
152
257
-
"cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
153
+
"cookie": ["cookie@0.7.1", "", {}, ""],
258
154
259
155
"cookie-signature": ["cookie-signature@1.0.6", "", {}, ""],
260
156
261
-
"crossws": ["crossws@0.4.1", "", { "peerDependencies": { "srvx": ">=0.7.1" }, "optionalPeers": ["srvx"] }, "sha512-E7WKBcHVhAVrY6JYD5kteNqVq1GSZxqGrdSiwXR9at+XHi43HJoCQKXcCczR5LBnBquFZPsB3o7HklulKoBU5w=="],
157
+
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
262
158
263
159
"debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, ""],
264
160
···
272
168
273
169
"ee-first": ["ee-first@1.1.1", "", {}, ""],
274
170
275
-
"elysia": ["elysia@1.4.15", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.2.2", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-RaDqqZdLuC4UJetfVRQ4Z5aVpGgEtQ+pZnsbI4ZzEaf3l/MzuHcqSVoL/Fue3d6qE4RV9HMB2rAZaHyPIxkyzg=="],
276
-
277
-
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
278
-
279
171
"encodeurl": ["encodeurl@2.0.0", "", {}, ""],
280
172
281
173
"es-define-property": ["es-define-property@1.0.1", "", {}, ""],
···
286
178
287
179
"esbuild": ["esbuild@0.25.11", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.11", "@esbuild/android-arm": "0.25.11", "@esbuild/android-arm64": "0.25.11", "@esbuild/android-x64": "0.25.11", "@esbuild/darwin-arm64": "0.25.11", "@esbuild/darwin-x64": "0.25.11", "@esbuild/freebsd-arm64": "0.25.11", "@esbuild/freebsd-x64": "0.25.11", "@esbuild/linux-arm": "0.25.11", "@esbuild/linux-arm64": "0.25.11", "@esbuild/linux-ia32": "0.25.11", "@esbuild/linux-loong64": "0.25.11", "@esbuild/linux-mips64el": "0.25.11", "@esbuild/linux-ppc64": "0.25.11", "@esbuild/linux-riscv64": "0.25.11", "@esbuild/linux-s390x": "0.25.11", "@esbuild/linux-x64": "0.25.11", "@esbuild/netbsd-arm64": "0.25.11", "@esbuild/netbsd-x64": "0.25.11", "@esbuild/openbsd-arm64": "0.25.11", "@esbuild/openbsd-x64": "0.25.11", "@esbuild/openharmony-arm64": "0.25.11", "@esbuild/sunos-x64": "0.25.11", "@esbuild/win32-arm64": "0.25.11", "@esbuild/win32-ia32": "0.25.11", "@esbuild/win32-x64": "0.25.11" }, "bin": "bin/esbuild" }, "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q=="],
288
180
289
-
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
290
-
291
181
"escape-html": ["escape-html@1.0.3", "", {}, ""],
292
182
293
183
"etag": ["etag@1.8.1", "", {}, ""],
···
298
188
299
189
"events": ["events@3.3.0", "", {}, ""],
300
190
301
-
"exact-mirror": ["exact-mirror@0.2.2", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" } }, "sha512-CrGe+4QzHZlnrXZVlo/WbUZ4qQZq8C0uATQVGVgXIrNXgHDBBNFD1VRfssRA2C9t3RYvh3MadZSdg2Wy7HBoQA=="],
302
-
303
191
"express": ["express@4.21.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "0.19.0", "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, ""],
304
-
305
-
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
306
192
307
193
"fast-redact": ["fast-redact@3.5.0", "", {}, ""],
308
194
309
-
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
310
-
311
-
"file-type": ["file-type@21.0.0", "", { "dependencies": { "@tokenizer/inflate": "^0.2.7", "strtok3": "^10.2.2", "token-types": "^6.0.0", "uint8array-extras": "^1.4.0" } }, "sha512-ek5xNX2YBYlXhiUXui3D/BXa3LdqPmoLJ7rqEx2bKJ7EAUEfmXgW0Das7Dc6Nr9MvqaOnIqiPV0mZk/r/UpNAg=="],
312
-
313
195
"finalhandler": ["finalhandler@1.3.1", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", "statuses": "2.0.1", "unpipe": "~1.0.0" } }, ""],
314
196
315
197
"forwarded": ["forwarded@0.2.0", "", {}, ""],
···
319
201
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
320
202
321
203
"function-bind": ["function-bind@1.1.2", "", {}, ""],
322
-
323
-
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
324
204
325
205
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, ""],
326
206
···
336
216
337
217
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, ""],
338
218
219
+
"hono": ["hono@4.10.4", "", {}, "sha512-YG/fo7zlU3KwrBL5vDpWKisLYiM+nVstBQqfr7gCPbSYURnNEP9BDxEMz8KfsDR9JX0lJWDRNc6nXX31v7ZEyg=="],
220
+
339
221
"http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, ""],
340
222
341
223
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, ""],
342
224
343
225
"ieee754": ["ieee754@1.2.1", "", {}, ""],
344
226
345
-
"import-in-the-middle": ["import-in-the-middle@1.15.0", "", { "dependencies": { "acorn": "^8.14.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^1.2.2", "module-details-from-path": "^1.0.3" } }, "sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA=="],
346
-
347
227
"inherits": ["inherits@2.0.4", "", {}, ""],
348
228
349
229
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, ""],
350
230
351
-
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
352
-
353
-
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
354
-
355
231
"iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, ""],
356
-
357
-
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
358
-
359
-
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
360
232
361
233
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, ""],
362
234
363
235
"media-typer": ["media-typer@0.3.0", "", {}, ""],
364
236
365
-
"memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
366
-
367
237
"merge-descriptors": ["merge-descriptors@1.0.3", "", {}, ""],
368
238
369
239
"methods": ["methods@1.1.2", "", {}, ""],
···
373
243
"mime-db": ["mime-db@1.52.0", "", {}, ""],
374
244
375
245
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, ""],
376
-
377
-
"module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="],
378
246
379
247
"ms": ["ms@2.0.0", "", {}, ""],
380
248
···
390
258
391
259
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, ""],
392
260
393
-
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
394
-
395
261
"p-finally": ["p-finally@1.0.0", "", {}, ""],
396
262
397
263
"p-queue": ["p-queue@6.6.2", "", { "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" } }, ""],
···
400
266
401
267
"parseurl": ["parseurl@1.3.3", "", {}, ""],
402
268
403
-
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
404
-
405
269
"path-to-regexp": ["path-to-regexp@0.1.12", "", {}, ""],
406
270
407
271
"pino": ["pino@8.21.0", "", { "dependencies": { "atomic-sleep": "^1.0.0", "fast-redact": "^3.1.1", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^1.2.0", "pino-std-serializers": "^6.0.0", "process-warning": "^3.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^3.7.0", "thread-stream": "^2.6.0" }, "bin": "bin.js" }, ""],
···
415
279
"process": ["process@0.11.10", "", {}, ""],
416
280
417
281
"process-warning": ["process-warning@3.0.0", "", {}, ""],
418
-
419
-
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
420
282
421
283
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, ""],
422
284
···
434
296
435
297
"real-require": ["real-require@0.2.0", "", {}, ""],
436
298
437
-
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
438
-
439
-
"require-in-the-middle": ["require-in-the-middle@7.5.2", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3", "resolve": "^1.22.8" } }, "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ=="],
440
-
441
-
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
442
-
443
299
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
444
300
445
301
"safe-buffer": ["safe-buffer@5.2.1", "", {}, ""],
···
454
310
455
311
"setprototypeof": ["setprototypeof@1.2.0", "", {}, ""],
456
312
457
-
"shimmer": ["shimmer@1.2.1", "", {}, "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw=="],
458
-
459
313
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, ""],
460
314
461
315
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, ""],
···
468
322
469
323
"split2": ["split2@4.2.0", "", {}, ""],
470
324
471
-
"srvx": ["srvx@0.9.5", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-nQsA2c8q3XwbSn6kTxVQjz0zS096rV+Be2pzJwrYEAdtnYszLw4MTy8JWJjz1XEGBZwP0qW51SUIX3WdjdRemQ=="],
472
-
473
325
"statuses": ["statuses@2.0.1", "", {}, ""],
474
-
475
-
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
476
326
477
327
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, ""],
478
328
479
-
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
480
-
481
-
"strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="],
482
-
483
-
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
484
-
485
329
"thread-stream": ["thread-stream@2.7.0", "", { "dependencies": { "real-require": "^0.2.0" } }, ""],
486
330
487
331
"tlds": ["tlds@1.261.0", "", { "bin": "bin.js" }, ""],
488
332
489
333
"toidentifier": ["toidentifier@1.0.1", "", {}, ""],
490
334
491
-
"token-types": ["token-types@6.1.1", "", { "dependencies": { "@borewit/text-codec": "^0.1.0", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ=="],
492
-
493
335
"tsx": ["tsx@4.20.6", "", { "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": "dist/cli.mjs" }, "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg=="],
494
336
495
337
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, ""],
496
-
497
-
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
498
338
499
339
"uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, ""],
500
340
···
508
348
509
349
"vary": ["vary@1.1.2", "", {}, ""],
510
350
511
-
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
512
-
513
351
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, ""],
514
352
515
-
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
516
-
517
-
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
518
-
519
-
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
520
-
521
353
"zod": ["zod@3.25.76", "", {}, ""],
522
354
523
355
"@atproto/api/multiformats": ["multiformats@9.9.0", "", {}, ""],
···
534
366
535
367
"@ipld/dag-cbor/multiformats": ["multiformats@9.9.0", "", {}, ""],
536
368
537
-
"@tokenizer/inflate/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
538
-
539
-
"express/cookie": ["cookie@0.7.1", "", {}, ""],
540
-
541
-
"require-in-the-middle/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
542
-
543
369
"send/encodeurl": ["encodeurl@1.0.2", "", {}, ""],
544
370
545
371
"send/ms": ["ms@2.1.3", "", {}, ""],
546
372
547
373
"uint8arrays/multiformats": ["multiformats@9.9.0", "", {}, ""],
548
-
549
-
"@tokenizer/inflate/debug/ms": ["ms@2.1.3", "", {}, ""],
550
-
551
-
"require-in-the-middle/debug/ms": ["ms@2.1.3", "", {}, ""],
552
374
}
553
375
}
+4
-2
hosting-service/package.json
+4
-2
hosting-service/package.json
···
3
3
"version": "1.0.0",
4
4
"type": "module",
5
5
"scripts": {
6
-
"dev": "tsx watch src/index.ts",
6
+
"dev": "tsx --env-file=.env watch src/index.ts",
7
7
"build": "tsc",
8
-
"start": "tsx src/index.ts"
8
+
"start": "tsx --env-file=.env src/index.ts",
9
+
"backfill": "tsx --env-file=.env src/index.ts --backfill"
9
10
},
10
11
"dependencies": {
11
12
"@atproto/api": "^0.17.4",
···
20
21
"postgres": "^3.4.5"
21
22
},
22
23
"devDependencies": {
24
+
"@types/bun": "^1.3.1",
23
25
"@types/mime-types": "^2.1.4",
24
26
"@types/node": "^22.10.5",
25
27
"tsx": "^4.19.2"
+20
-1
hosting-service/src/index.ts
+20
-1
hosting-service/src/index.ts
···
3
3
import { FirehoseWorker } from './lib/firehose';
4
4
import { logger } from './lib/observability';
5
5
import { mkdirSync, existsSync } from 'fs';
6
+
import { backfillCache } from './lib/backfill';
6
7
7
8
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3001;
8
-
const CACHE_DIR = './cache/sites';
9
+
const CACHE_DIR = process.env.CACHE_DIR || './cache/sites';
10
+
11
+
// Parse CLI arguments
12
+
const args = process.argv.slice(2);
13
+
const hasBackfillFlag = args.includes('--backfill');
14
+
const backfillOnStartup = hasBackfillFlag || process.env.BACKFILL_ON_STARTUP === 'true';
9
15
10
16
// Ensure cache directory exists
11
17
if (!existsSync(CACHE_DIR)) {
···
19
25
});
20
26
21
27
firehose.start();
28
+
29
+
// Run backfill if requested
30
+
if (backfillOnStartup) {
31
+
console.log('๐ Backfill requested, starting cache backfill...');
32
+
backfillCache({
33
+
skipExisting: true,
34
+
concurrency: 3,
35
+
}).then((stats) => {
36
+
console.log('โ
Cache backfill completed');
37
+
}).catch((err) => {
38
+
console.error('โ Cache backfill error:', err);
39
+
});
40
+
}
22
41
23
42
// Add health check endpoint
24
43
app.get('/health', (c) => {
+136
hosting-service/src/lib/backfill.ts
+136
hosting-service/src/lib/backfill.ts
···
1
+
import { getAllSites } from './db';
2
+
import { fetchSiteRecord, getPdsForDid, downloadAndCacheSite, isCached } from './utils';
3
+
import { logger } from './observability';
4
+
5
+
export interface BackfillOptions {
6
+
skipExisting?: boolean; // Skip sites already in cache
7
+
concurrency?: number; // Number of sites to cache concurrently
8
+
maxSites?: number; // Maximum number of sites to backfill (for testing)
9
+
}
10
+
11
+
export interface BackfillStats {
12
+
total: number;
13
+
cached: number;
14
+
skipped: number;
15
+
failed: number;
16
+
duration: number;
17
+
}
18
+
19
+
/**
20
+
* Backfill all sites from the database into the local cache
21
+
*/
22
+
export async function backfillCache(options: BackfillOptions = {}): Promise<BackfillStats> {
23
+
const {
24
+
skipExisting = true,
25
+
concurrency = 3,
26
+
maxSites,
27
+
} = options;
28
+
29
+
const startTime = Date.now();
30
+
const stats: BackfillStats = {
31
+
total: 0,
32
+
cached: 0,
33
+
skipped: 0,
34
+
failed: 0,
35
+
duration: 0,
36
+
};
37
+
38
+
logger.info('Starting cache backfill', { skipExisting, concurrency, maxSites });
39
+
console.log(`
40
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
41
+
โ CACHE BACKFILL STARTING โ
42
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
43
+
`);
44
+
45
+
try {
46
+
// Get all sites from database
47
+
let sites = await getAllSites();
48
+
stats.total = sites.length;
49
+
50
+
logger.info(`Found ${sites.length} sites in database`);
51
+
console.log(`๐ Found ${sites.length} sites in database`);
52
+
53
+
// Limit if specified
54
+
if (maxSites && maxSites > 0) {
55
+
sites = sites.slice(0, maxSites);
56
+
console.log(`โ๏ธ Limited to ${maxSites} sites for backfill`);
57
+
}
58
+
59
+
// Process sites in batches
60
+
const batches: typeof sites[] = [];
61
+
for (let i = 0; i < sites.length; i += concurrency) {
62
+
batches.push(sites.slice(i, i + concurrency));
63
+
}
64
+
65
+
let processed = 0;
66
+
for (const batch of batches) {
67
+
await Promise.all(
68
+
batch.map(async (site) => {
69
+
try {
70
+
// Check if already cached
71
+
if (skipExisting && isCached(site.did, site.rkey)) {
72
+
stats.skipped++;
73
+
processed++;
74
+
logger.debug(`Skipping already cached site`, { did: site.did, rkey: site.rkey });
75
+
console.log(`โญ๏ธ [${processed}/${sites.length}] Skipped (cached): ${site.display_name || site.rkey}`);
76
+
return;
77
+
}
78
+
79
+
// Fetch site record
80
+
const siteData = await fetchSiteRecord(site.did, site.rkey);
81
+
if (!siteData) {
82
+
stats.failed++;
83
+
processed++;
84
+
logger.error('Site record not found during backfill', null, { did: site.did, rkey: site.rkey });
85
+
console.log(`โ [${processed}/${sites.length}] Failed (not found): ${site.display_name || site.rkey}`);
86
+
return;
87
+
}
88
+
89
+
// Get PDS endpoint
90
+
const pdsEndpoint = await getPdsForDid(site.did);
91
+
if (!pdsEndpoint) {
92
+
stats.failed++;
93
+
processed++;
94
+
logger.error('PDS not found during backfill', null, { did: site.did });
95
+
console.log(`โ [${processed}/${sites.length}] Failed (no PDS): ${site.display_name || site.rkey}`);
96
+
return;
97
+
}
98
+
99
+
// Download and cache site
100
+
await downloadAndCacheSite(site.did, site.rkey, siteData.record, pdsEndpoint, siteData.cid);
101
+
stats.cached++;
102
+
processed++;
103
+
logger.info('Successfully cached site during backfill', { did: site.did, rkey: site.rkey });
104
+
console.log(`โ
[${processed}/${sites.length}] Cached: ${site.display_name || site.rkey}`);
105
+
} catch (err) {
106
+
stats.failed++;
107
+
processed++;
108
+
logger.error('Failed to cache site during backfill', err, { did: site.did, rkey: site.rkey });
109
+
console.log(`โ [${processed}/${sites.length}] Failed: ${site.display_name || site.rkey}`);
110
+
}
111
+
})
112
+
);
113
+
}
114
+
115
+
stats.duration = Date.now() - startTime;
116
+
117
+
console.log(`
118
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
119
+
โ CACHE BACKFILL COMPLETED โ
120
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
121
+
122
+
๐ Total Sites: ${stats.total}
123
+
โ
Cached: ${stats.cached}
124
+
โญ๏ธ Skipped: ${stats.skipped}
125
+
โ Failed: ${stats.failed}
126
+
โฑ๏ธ Duration: ${(stats.duration / 1000).toFixed(2)}s
127
+
`);
128
+
129
+
logger.info('Cache backfill completed', stats);
130
+
} catch (err) {
131
+
logger.error('Cache backfill failed', err);
132
+
console.error('โ Cache backfill failed:', err);
133
+
}
134
+
135
+
return stats;
136
+
}
+19
hosting-service/src/lib/db.ts
+19
hosting-service/src/lib/db.ts
···
81
81
}
82
82
}
83
83
84
+
export interface SiteRecord {
85
+
did: string;
86
+
rkey: string;
87
+
display_name?: string;
88
+
}
89
+
90
+
export async function getAllSites(): Promise<SiteRecord[]> {
91
+
try {
92
+
const result = await sql<SiteRecord[]>`
93
+
SELECT did, rkey, display_name FROM sites
94
+
ORDER BY created_at DESC
95
+
`;
96
+
return result;
97
+
} catch (err) {
98
+
console.error('Failed to get all sites', err);
99
+
return [];
100
+
}
101
+
}
102
+
84
103
/**
85
104
* Generate a numeric lock ID from a string key
86
105
* PostgreSQL advisory locks use bigint (64-bit signed integer)
+259
-219
hosting-service/src/lib/firehose.ts
+259
-219
hosting-service/src/lib/firehose.ts
···
1
-
import { existsSync, rmSync } from 'fs';
2
-
import { getPdsForDid, downloadAndCacheSite, extractBlobCid, fetchSiteRecord } from './utils';
3
-
import { upsertSite, tryAcquireLock, releaseLock } from './db';
4
-
import { safeFetch } from './safe-fetch';
5
-
import { isRecord, validateRecord } from '../lexicon/types/place/wisp/fs';
6
-
import { Firehose } from '@atproto/sync';
7
-
import { IdResolver } from '@atproto/identity';
1
+
import { existsSync, rmSync } from 'fs'
2
+
import {
3
+
getPdsForDid,
4
+
downloadAndCacheSite,
5
+
extractBlobCid,
6
+
fetchSiteRecord
7
+
} from './utils'
8
+
import { upsertSite, tryAcquireLock, releaseLock } from './db'
9
+
import { safeFetch } from './safe-fetch'
10
+
import { isRecord, validateRecord } from '../lexicon/types/place/wisp/fs'
11
+
import { Firehose } from '@atproto/sync'
12
+
import { IdResolver } from '@atproto/identity'
8
13
9
-
const CACHE_DIR = './cache/sites';
14
+
const CACHE_DIR = './cache/sites'
10
15
11
16
export class FirehoseWorker {
12
-
private firehose: Firehose | null = null;
13
-
private idResolver: IdResolver;
14
-
private isShuttingDown = false;
15
-
private lastEventTime = Date.now();
17
+
private firehose: Firehose | null = null
18
+
private idResolver: IdResolver
19
+
private isShuttingDown = false
20
+
private lastEventTime = Date.now()
16
21
17
-
constructor(
18
-
private logger?: (msg: string, data?: Record<string, unknown>) => void,
19
-
) {
20
-
this.idResolver = new IdResolver();
21
-
}
22
+
constructor(
23
+
private logger?: (msg: string, data?: Record<string, unknown>) => void
24
+
) {
25
+
this.idResolver = new IdResolver()
26
+
}
22
27
23
-
private log(msg: string, data?: Record<string, unknown>) {
24
-
const log = this.logger || console.log;
25
-
log(`[FirehoseWorker] ${msg}`, data || {});
26
-
}
28
+
private log(msg: string, data?: Record<string, unknown>) {
29
+
const log = this.logger || console.log
30
+
log(`[FirehoseWorker] ${msg}`, data || {})
31
+
}
27
32
28
-
start() {
29
-
this.log('Starting firehose worker');
30
-
this.connect();
31
-
}
33
+
start() {
34
+
this.log('Starting firehose worker')
35
+
this.connect()
36
+
}
32
37
33
-
stop() {
34
-
this.log('Stopping firehose worker');
35
-
this.isShuttingDown = true;
38
+
stop() {
39
+
this.log('Stopping firehose worker')
40
+
this.isShuttingDown = true
36
41
37
-
if (this.firehose) {
38
-
this.firehose.destroy();
39
-
this.firehose = null;
40
-
}
41
-
}
42
+
if (this.firehose) {
43
+
this.firehose.destroy()
44
+
this.firehose = null
45
+
}
46
+
}
42
47
43
-
private connect() {
44
-
if (this.isShuttingDown) return;
48
+
private connect() {
49
+
if (this.isShuttingDown) return
45
50
46
-
this.log('Connecting to AT Protocol firehose');
51
+
this.log('Connecting to AT Protocol firehose')
47
52
48
-
this.firehose = new Firehose({
49
-
idResolver: this.idResolver,
50
-
service: 'wss://bsky.network',
51
-
filterCollections: ['place.wisp.fs'],
52
-
handleEvent: async (evt: any) => {
53
-
this.lastEventTime = Date.now();
53
+
this.firehose = new Firehose({
54
+
idResolver: this.idResolver,
55
+
service: 'wss://bsky.network',
56
+
filterCollections: ['place.wisp.fs'],
57
+
handleEvent: async (evt: any) => {
58
+
this.lastEventTime = Date.now()
54
59
55
-
// Watch for write events
56
-
if (evt.event === 'create' || evt.event === 'update') {
57
-
const record = evt.record;
60
+
// Watch for write events
61
+
if (evt.event === 'create' || evt.event === 'update') {
62
+
const record = evt.record
58
63
59
-
// If the write is a valid place.wisp.fs record
60
-
if (
61
-
evt.collection === 'place.wisp.fs' &&
62
-
isRecord(record) &&
63
-
validateRecord(record).success
64
-
) {
65
-
this.log('Received place.wisp.fs event', {
66
-
did: evt.did,
67
-
event: evt.event,
68
-
rkey: evt.rkey,
69
-
});
64
+
// If the write is a valid place.wisp.fs record
65
+
if (
66
+
evt.collection === 'place.wisp.fs' &&
67
+
isRecord(record) &&
68
+
validateRecord(record).success
69
+
) {
70
+
this.log('Received place.wisp.fs event', {
71
+
did: evt.did,
72
+
event: evt.event,
73
+
rkey: evt.rkey
74
+
})
70
75
71
-
try {
72
-
await this.handleCreateOrUpdate(evt.did, evt.rkey, record, evt.cid?.toString());
73
-
} catch (err) {
74
-
this.log('Error handling event', {
75
-
did: evt.did,
76
-
event: evt.event,
77
-
rkey: evt.rkey,
78
-
error: err instanceof Error ? err.message : String(err),
79
-
});
80
-
}
81
-
}
82
-
} else if (evt.event === 'delete' && evt.collection === 'place.wisp.fs') {
83
-
this.log('Received delete event', {
84
-
did: evt.did,
85
-
rkey: evt.rkey,
86
-
});
76
+
try {
77
+
await this.handleCreateOrUpdate(
78
+
evt.did,
79
+
evt.rkey,
80
+
record,
81
+
evt.cid?.toString()
82
+
)
83
+
} catch (err) {
84
+
this.log('Error handling event', {
85
+
did: evt.did,
86
+
event: evt.event,
87
+
rkey: evt.rkey,
88
+
error:
89
+
err instanceof Error
90
+
? err.message
91
+
: String(err)
92
+
})
93
+
}
94
+
}
95
+
} else if (
96
+
evt.event === 'delete' &&
97
+
evt.collection === 'place.wisp.fs'
98
+
) {
99
+
this.log('Received delete event', {
100
+
did: evt.did,
101
+
rkey: evt.rkey
102
+
})
87
103
88
-
try {
89
-
await this.handleDelete(evt.did, evt.rkey);
90
-
} catch (err) {
91
-
this.log('Error handling delete', {
92
-
did: evt.did,
93
-
rkey: evt.rkey,
94
-
error: err instanceof Error ? err.message : String(err),
95
-
});
96
-
}
97
-
}
98
-
},
99
-
onError: (err: any) => {
100
-
this.log('Firehose error', {
101
-
error: err instanceof Error ? err.message : String(err),
102
-
stack: err instanceof Error ? err.stack : undefined,
103
-
fullError: err,
104
-
});
105
-
console.error('Full firehose error:', err);
106
-
},
107
-
});
104
+
try {
105
+
await this.handleDelete(evt.did, evt.rkey)
106
+
} catch (err) {
107
+
this.log('Error handling delete', {
108
+
did: evt.did,
109
+
rkey: evt.rkey,
110
+
error:
111
+
err instanceof Error ? err.message : String(err)
112
+
})
113
+
}
114
+
}
115
+
},
116
+
onError: (err: any) => {
117
+
this.log('Firehose error', {
118
+
error: err instanceof Error ? err.message : String(err),
119
+
stack: err instanceof Error ? err.stack : undefined,
120
+
fullError: err
121
+
})
122
+
console.error('Full firehose error:', err)
123
+
}
124
+
})
108
125
109
-
this.firehose.start();
110
-
this.log('Firehose started');
111
-
}
126
+
this.firehose.start()
127
+
this.log('Firehose started')
128
+
}
112
129
113
-
private async handleCreateOrUpdate(did: string, site: string, record: any, eventCid?: string) {
114
-
this.log('Processing create/update', { did, site });
130
+
private async handleCreateOrUpdate(
131
+
did: string,
132
+
site: string,
133
+
record: any,
134
+
eventCid?: string
135
+
) {
136
+
this.log('Processing create/update', { did, site })
115
137
116
-
// Record is already validated in handleEvent
117
-
const fsRecord = record;
138
+
// Record is already validated in handleEvent
139
+
const fsRecord = record
118
140
119
-
const pdsEndpoint = await getPdsForDid(did);
120
-
if (!pdsEndpoint) {
121
-
this.log('Could not resolve PDS for DID', { did });
122
-
return;
123
-
}
141
+
const pdsEndpoint = await getPdsForDid(did)
142
+
if (!pdsEndpoint) {
143
+
this.log('Could not resolve PDS for DID', { did })
144
+
return
145
+
}
124
146
125
-
this.log('Resolved PDS', { did, pdsEndpoint });
147
+
this.log('Resolved PDS', { did, pdsEndpoint })
126
148
127
-
// Verify record exists on PDS and fetch its CID
128
-
let verifiedCid: string;
129
-
try {
130
-
const result = await fetchSiteRecord(did, site);
149
+
// Verify record exists on PDS and fetch its CID
150
+
let verifiedCid: string
151
+
try {
152
+
const result = await fetchSiteRecord(did, site)
131
153
132
-
if (!result) {
133
-
this.log('Record not found on PDS, skipping cache', { did, site });
134
-
return;
135
-
}
154
+
if (!result) {
155
+
this.log('Record not found on PDS, skipping cache', {
156
+
did,
157
+
site
158
+
})
159
+
return
160
+
}
136
161
137
-
verifiedCid = result.cid;
162
+
verifiedCid = result.cid
138
163
139
-
// Verify event CID matches PDS CID (prevent cache poisoning)
140
-
if (eventCid && eventCid !== verifiedCid) {
141
-
this.log('CID mismatch detected - potential spoofed event', {
142
-
did,
143
-
site,
144
-
eventCid,
145
-
verifiedCid
146
-
});
147
-
return;
148
-
}
164
+
// Verify event CID matches PDS CID (prevent cache poisoning)
165
+
if (eventCid && eventCid !== verifiedCid) {
166
+
this.log('CID mismatch detected - potential spoofed event', {
167
+
did,
168
+
site,
169
+
eventCid,
170
+
verifiedCid
171
+
})
172
+
return
173
+
}
149
174
150
-
this.log('Record verified on PDS', { did, site, cid: verifiedCid });
151
-
} catch (err) {
152
-
this.log('Failed to verify record on PDS', {
153
-
did,
154
-
site,
155
-
error: err instanceof Error ? err.message : String(err),
156
-
});
157
-
return;
158
-
}
175
+
this.log('Record verified on PDS', { did, site, cid: verifiedCid })
176
+
} catch (err) {
177
+
this.log('Failed to verify record on PDS', {
178
+
did,
179
+
site,
180
+
error: err instanceof Error ? err.message : String(err)
181
+
})
182
+
return
183
+
}
159
184
160
-
// Cache the record with verified CID (uses atomic swap internally)
161
-
// All instances cache locally for edge serving
162
-
await downloadAndCacheSite(did, site, fsRecord, pdsEndpoint, verifiedCid);
185
+
// Cache the record with verified CID (uses atomic swap internally)
186
+
// All instances cache locally for edge serving
187
+
await downloadAndCacheSite(
188
+
did,
189
+
site,
190
+
fsRecord,
191
+
pdsEndpoint,
192
+
verifiedCid
193
+
)
163
194
164
-
// Acquire distributed lock only for database write to prevent duplicate writes
165
-
const lockKey = `db:upsert:${did}:${site}`;
166
-
const lockAcquired = await tryAcquireLock(lockKey);
195
+
// Acquire distributed lock only for database write to prevent duplicate writes
196
+
const lockKey = `db:upsert:${did}:${site}`
197
+
const lockAcquired = await tryAcquireLock(lockKey)
167
198
168
-
if (!lockAcquired) {
169
-
this.log('Another instance is writing to DB, skipping upsert', { did, site });
170
-
this.log('Successfully processed create/update (cached locally)', { did, site });
171
-
return;
172
-
}
199
+
if (!lockAcquired) {
200
+
this.log('Another instance is writing to DB, skipping upsert', {
201
+
did,
202
+
site
203
+
})
204
+
this.log('Successfully processed create/update (cached locally)', {
205
+
did,
206
+
site
207
+
})
208
+
return
209
+
}
173
210
174
-
try {
175
-
// Upsert site to database (only one instance does this)
176
-
await upsertSite(did, site, fsRecord.site);
177
-
this.log('Successfully processed create/update (cached + DB updated)', { did, site });
178
-
} finally {
179
-
// Always release lock, even if DB write fails
180
-
await releaseLock(lockKey);
181
-
}
182
-
}
211
+
try {
212
+
// Upsert site to database (only one instance does this)
213
+
await upsertSite(did, site, fsRecord.site)
214
+
this.log(
215
+
'Successfully processed create/update (cached + DB updated)',
216
+
{ did, site }
217
+
)
218
+
} finally {
219
+
// Always release lock, even if DB write fails
220
+
await releaseLock(lockKey)
221
+
}
222
+
}
183
223
184
-
private async handleDelete(did: string, site: string) {
185
-
this.log('Processing delete', { did, site });
224
+
private async handleDelete(did: string, site: string) {
225
+
this.log('Processing delete', { did, site })
186
226
187
-
// All instances should delete their local cache (no lock needed)
188
-
const pdsEndpoint = await getPdsForDid(did);
189
-
if (!pdsEndpoint) {
190
-
this.log('Could not resolve PDS for DID', { did });
191
-
return;
192
-
}
227
+
// All instances should delete their local cache (no lock needed)
228
+
const pdsEndpoint = await getPdsForDid(did)
229
+
if (!pdsEndpoint) {
230
+
this.log('Could not resolve PDS for DID', { did })
231
+
return
232
+
}
193
233
194
-
// Verify record is actually deleted from PDS
195
-
try {
196
-
const recordUrl = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(site)}`;
197
-
const recordRes = await safeFetch(recordUrl);
234
+
// Verify record is actually deleted from PDS
235
+
try {
236
+
const recordUrl = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(site)}`
237
+
const recordRes = await safeFetch(recordUrl)
198
238
199
-
if (recordRes.ok) {
200
-
this.log('Record still exists on PDS, not deleting cache', {
201
-
did,
202
-
site,
203
-
});
204
-
return;
205
-
}
239
+
if (recordRes.ok) {
240
+
this.log('Record still exists on PDS, not deleting cache', {
241
+
did,
242
+
site
243
+
})
244
+
return
245
+
}
206
246
207
-
this.log('Verified record is deleted from PDS', {
208
-
did,
209
-
site,
210
-
status: recordRes.status,
211
-
});
212
-
} catch (err) {
213
-
this.log('Error verifying deletion on PDS', {
214
-
did,
215
-
site,
216
-
error: err instanceof Error ? err.message : String(err),
217
-
});
218
-
}
247
+
this.log('Verified record is deleted from PDS', {
248
+
did,
249
+
site,
250
+
status: recordRes.status
251
+
})
252
+
} catch (err) {
253
+
this.log('Error verifying deletion on PDS', {
254
+
did,
255
+
site,
256
+
error: err instanceof Error ? err.message : String(err)
257
+
})
258
+
}
219
259
220
-
// Delete cache
221
-
this.deleteCache(did, site);
260
+
// Delete cache
261
+
this.deleteCache(did, site)
222
262
223
-
this.log('Successfully processed delete', { did, site });
224
-
}
263
+
this.log('Successfully processed delete', { did, site })
264
+
}
225
265
226
-
private deleteCache(did: string, site: string) {
227
-
const cacheDir = `${CACHE_DIR}/${did}/${site}`;
266
+
private deleteCache(did: string, site: string) {
267
+
const cacheDir = `${CACHE_DIR}/${did}/${site}`
228
268
229
-
if (!existsSync(cacheDir)) {
230
-
this.log('Cache directory does not exist, nothing to delete', {
231
-
did,
232
-
site,
233
-
});
234
-
return;
235
-
}
269
+
if (!existsSync(cacheDir)) {
270
+
this.log('Cache directory does not exist, nothing to delete', {
271
+
did,
272
+
site
273
+
})
274
+
return
275
+
}
236
276
237
-
try {
238
-
rmSync(cacheDir, { recursive: true, force: true });
239
-
this.log('Cache deleted', { did, site, path: cacheDir });
240
-
} catch (err) {
241
-
this.log('Failed to delete cache', {
242
-
did,
243
-
site,
244
-
path: cacheDir,
245
-
error: err instanceof Error ? err.message : String(err),
246
-
});
247
-
}
248
-
}
277
+
try {
278
+
rmSync(cacheDir, { recursive: true, force: true })
279
+
this.log('Cache deleted', { did, site, path: cacheDir })
280
+
} catch (err) {
281
+
this.log('Failed to delete cache', {
282
+
did,
283
+
site,
284
+
path: cacheDir,
285
+
error: err instanceof Error ? err.message : String(err)
286
+
})
287
+
}
288
+
}
249
289
250
-
getHealth() {
251
-
const isConnected = this.firehose !== null;
252
-
const timeSinceLastEvent = Date.now() - this.lastEventTime;
290
+
getHealth() {
291
+
const isConnected = this.firehose !== null
292
+
const timeSinceLastEvent = Date.now() - this.lastEventTime
253
293
254
-
return {
255
-
connected: isConnected,
256
-
lastEventTime: this.lastEventTime,
257
-
timeSinceLastEvent,
258
-
healthy: isConnected && timeSinceLastEvent < 300000, // 5 minutes
259
-
};
260
-
}
294
+
return {
295
+
connected: isConnected,
296
+
lastEventTime: this.lastEventTime,
297
+
timeSinceLastEvent,
298
+
healthy: isConnected && timeSinceLastEvent < 300000 // 5 minutes
299
+
}
300
+
}
261
301
}
+457
hosting-service/src/lib/html-rewriter.test.ts
+457
hosting-service/src/lib/html-rewriter.test.ts
···
1
+
import { describe, test, expect } from 'bun:test'
2
+
import { rewriteHtmlPaths, isHtmlContent } from './html-rewriter'
3
+
4
+
describe('rewriteHtmlPaths', () => {
5
+
const basePath = '/identifier/site/'
6
+
7
+
describe('absolute paths', () => {
8
+
test('rewrites absolute paths with leading slash', () => {
9
+
const html = '<img src="/image.png">'
10
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
11
+
expect(result).toBe('<img src="/identifier/site/image.png">')
12
+
})
13
+
14
+
test('rewrites nested absolute paths', () => {
15
+
const html = '<link href="/css/style.css">'
16
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
17
+
expect(result).toBe('<link href="/identifier/site/css/style.css">')
18
+
})
19
+
})
20
+
21
+
describe('relative paths from root document', () => {
22
+
test('rewrites relative paths with ./ prefix', () => {
23
+
const html = '<img src="./image.png">'
24
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
25
+
expect(result).toBe('<img src="/identifier/site/image.png">')
26
+
})
27
+
28
+
test('rewrites relative paths without prefix', () => {
29
+
const html = '<img src="image.png">'
30
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
31
+
expect(result).toBe('<img src="/identifier/site/image.png">')
32
+
})
33
+
34
+
test('rewrites relative paths with ../ (should stay at root)', () => {
35
+
const html = '<img src="../image.png">'
36
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
37
+
expect(result).toBe('<img src="/identifier/site/image.png">')
38
+
})
39
+
})
40
+
41
+
describe('relative paths from nested documents', () => {
42
+
test('rewrites relative path from nested document', () => {
43
+
const html = '<img src="./photo.jpg">'
44
+
const result = rewriteHtmlPaths(
45
+
html,
46
+
basePath,
47
+
'folder1/folder2/index.html'
48
+
)
49
+
expect(result).toBe(
50
+
'<img src="/identifier/site/folder1/folder2/photo.jpg">'
51
+
)
52
+
})
53
+
54
+
test('rewrites plain filename from nested document', () => {
55
+
const html = '<script src="app.js"></script>'
56
+
const result = rewriteHtmlPaths(
57
+
html,
58
+
basePath,
59
+
'folder1/folder2/index.html'
60
+
)
61
+
expect(result).toBe(
62
+
'<script src="/identifier/site/folder1/folder2/app.js"></script>'
63
+
)
64
+
})
65
+
66
+
test('rewrites ../ to go up one level', () => {
67
+
const html = '<img src="../image.png">'
68
+
const result = rewriteHtmlPaths(
69
+
html,
70
+
basePath,
71
+
'folder1/folder2/folder3/index.html'
72
+
)
73
+
expect(result).toBe(
74
+
'<img src="/identifier/site/folder1/folder2/image.png">'
75
+
)
76
+
})
77
+
78
+
test('rewrites multiple ../ to go up multiple levels', () => {
79
+
const html = '<link href="../../css/style.css">'
80
+
const result = rewriteHtmlPaths(
81
+
html,
82
+
basePath,
83
+
'folder1/folder2/folder3/index.html'
84
+
)
85
+
expect(result).toBe(
86
+
'<link href="/identifier/site/folder1/css/style.css">'
87
+
)
88
+
})
89
+
90
+
test('rewrites ../ with additional path segments', () => {
91
+
const html = '<img src="../assets/logo.png">'
92
+
const result = rewriteHtmlPaths(
93
+
html,
94
+
basePath,
95
+
'pages/about/index.html'
96
+
)
97
+
expect(result).toBe(
98
+
'<img src="/identifier/site/pages/assets/logo.png">'
99
+
)
100
+
})
101
+
102
+
test('handles complex nested relative paths', () => {
103
+
const html = '<script src="../../lib/vendor/jquery.js"></script>'
104
+
const result = rewriteHtmlPaths(
105
+
html,
106
+
basePath,
107
+
'pages/blog/post/index.html'
108
+
)
109
+
expect(result).toBe(
110
+
'<script src="/identifier/site/pages/lib/vendor/jquery.js"></script>'
111
+
)
112
+
})
113
+
114
+
test('handles ../ going past root (stays at root)', () => {
115
+
const html = '<img src="../../../image.png">'
116
+
const result = rewriteHtmlPaths(html, basePath, 'folder1/index.html')
117
+
expect(result).toBe('<img src="/identifier/site/image.png">')
118
+
})
119
+
})
120
+
121
+
describe('external URLs and special schemes', () => {
122
+
test('does not rewrite http URLs', () => {
123
+
const html = '<img src="http://example.com/image.png">'
124
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
125
+
expect(result).toBe('<img src="http://example.com/image.png">')
126
+
})
127
+
128
+
test('does not rewrite https URLs', () => {
129
+
const html = '<link href="https://cdn.example.com/style.css">'
130
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
131
+
expect(result).toBe(
132
+
'<link href="https://cdn.example.com/style.css">'
133
+
)
134
+
})
135
+
136
+
test('does not rewrite protocol-relative URLs', () => {
137
+
const html = '<script src="//cdn.example.com/script.js"></script>'
138
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
139
+
expect(result).toBe(
140
+
'<script src="//cdn.example.com/script.js"></script>'
141
+
)
142
+
})
143
+
144
+
test('does not rewrite data URIs', () => {
145
+
const html =
146
+
'<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA">'
147
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
148
+
expect(result).toBe(
149
+
'<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA">'
150
+
)
151
+
})
152
+
153
+
test('does not rewrite mailto links', () => {
154
+
const html = '<a href="mailto:test@example.com">Email</a>'
155
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
156
+
expect(result).toBe('<a href="mailto:test@example.com">Email</a>')
157
+
})
158
+
159
+
test('does not rewrite tel links', () => {
160
+
const html = '<a href="tel:+1234567890">Call</a>'
161
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
162
+
expect(result).toBe('<a href="tel:+1234567890">Call</a>')
163
+
})
164
+
})
165
+
166
+
describe('different HTML attributes', () => {
167
+
test('rewrites src attribute', () => {
168
+
const html = '<img src="/image.png">'
169
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
170
+
expect(result).toBe('<img src="/identifier/site/image.png">')
171
+
})
172
+
173
+
test('rewrites href attribute', () => {
174
+
const html = '<a href="/page.html">Link</a>'
175
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
176
+
expect(result).toBe('<a href="/identifier/site/page.html">Link</a>')
177
+
})
178
+
179
+
test('rewrites action attribute', () => {
180
+
const html = '<form action="/submit"></form>'
181
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
182
+
expect(result).toBe('<form action="/identifier/site/submit"></form>')
183
+
})
184
+
185
+
test('rewrites data attribute', () => {
186
+
const html = '<object data="/document.pdf"></object>'
187
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
188
+
expect(result).toBe(
189
+
'<object data="/identifier/site/document.pdf"></object>'
190
+
)
191
+
})
192
+
193
+
test('rewrites poster attribute', () => {
194
+
const html = '<video poster="/thumbnail.jpg"></video>'
195
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
196
+
expect(result).toBe(
197
+
'<video poster="/identifier/site/thumbnail.jpg"></video>'
198
+
)
199
+
})
200
+
201
+
test('rewrites srcset attribute with single URL', () => {
202
+
const html = '<img srcset="/image.png 1x">'
203
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
204
+
expect(result).toBe(
205
+
'<img srcset="/identifier/site/image.png 1x">'
206
+
)
207
+
})
208
+
209
+
test('rewrites srcset attribute with multiple URLs', () => {
210
+
const html = '<img srcset="/image-1x.png 1x, /image-2x.png 2x">'
211
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
212
+
expect(result).toBe(
213
+
'<img srcset="/identifier/site/image-1x.png 1x, /identifier/site/image-2x.png 2x">'
214
+
)
215
+
})
216
+
217
+
test('rewrites srcset with width descriptors', () => {
218
+
const html = '<img srcset="/small.jpg 320w, /large.jpg 1024w">'
219
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
220
+
expect(result).toBe(
221
+
'<img srcset="/identifier/site/small.jpg 320w, /identifier/site/large.jpg 1024w">'
222
+
)
223
+
})
224
+
225
+
test('rewrites srcset with relative paths from nested document', () => {
226
+
const html = '<img srcset="../img1.png 1x, ../img2.png 2x">'
227
+
const result = rewriteHtmlPaths(
228
+
html,
229
+
basePath,
230
+
'folder1/folder2/index.html'
231
+
)
232
+
expect(result).toBe(
233
+
'<img srcset="/identifier/site/folder1/img1.png 1x, /identifier/site/folder1/img2.png 2x">'
234
+
)
235
+
})
236
+
})
237
+
238
+
describe('quote handling', () => {
239
+
test('handles double quotes', () => {
240
+
const html = '<img src="/image.png">'
241
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
242
+
expect(result).toBe('<img src="/identifier/site/image.png">')
243
+
})
244
+
245
+
test('handles single quotes', () => {
246
+
const html = "<img src='/image.png'>"
247
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
248
+
expect(result).toBe("<img src='/identifier/site/image.png'>")
249
+
})
250
+
251
+
test('handles mixed quotes in same document', () => {
252
+
const html = '<img src="/img1.png"><link href=\'/style.css\'>'
253
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
254
+
expect(result).toBe(
255
+
'<img src="/identifier/site/img1.png"><link href=\'/identifier/site/style.css\'>'
256
+
)
257
+
})
258
+
})
259
+
260
+
describe('multiple rewrites in same document', () => {
261
+
test('rewrites multiple attributes in complex HTML', () => {
262
+
const html = `
263
+
<!DOCTYPE html>
264
+
<html>
265
+
<head>
266
+
<link href="/css/style.css" rel="stylesheet">
267
+
<script src="/js/app.js"></script>
268
+
</head>
269
+
<body>
270
+
<img src="/images/logo.png" alt="Logo">
271
+
<a href="/about.html">About</a>
272
+
<form action="/submit">
273
+
<button type="submit">Submit</button>
274
+
</form>
275
+
</body>
276
+
</html>
277
+
`
278
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
279
+
expect(result).toContain('href="/identifier/site/css/style.css"')
280
+
expect(result).toContain('src="/identifier/site/js/app.js"')
281
+
expect(result).toContain('src="/identifier/site/images/logo.png"')
282
+
expect(result).toContain('href="/identifier/site/about.html"')
283
+
expect(result).toContain('action="/identifier/site/submit"')
284
+
})
285
+
286
+
test('handles mix of relative and absolute paths', () => {
287
+
const html = `
288
+
<img src="/abs/image.png">
289
+
<img src="./rel/image.png">
290
+
<img src="../parent/image.png">
291
+
<img src="https://external.com/image.png">
292
+
`
293
+
const result = rewriteHtmlPaths(
294
+
html,
295
+
basePath,
296
+
'folder1/folder2/page.html'
297
+
)
298
+
expect(result).toContain('src="/identifier/site/abs/image.png"')
299
+
expect(result).toContain(
300
+
'src="/identifier/site/folder1/folder2/rel/image.png"'
301
+
)
302
+
expect(result).toContain(
303
+
'src="/identifier/site/folder1/parent/image.png"'
304
+
)
305
+
expect(result).toContain('src="https://external.com/image.png"')
306
+
})
307
+
})
308
+
309
+
describe('edge cases', () => {
310
+
test('handles empty src attribute', () => {
311
+
const html = '<img src="">'
312
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
313
+
expect(result).toBe('<img src="">')
314
+
})
315
+
316
+
test('handles basePath without trailing slash', () => {
317
+
const html = '<img src="/image.png">'
318
+
const result = rewriteHtmlPaths(html, '/identifier/site', 'index.html')
319
+
expect(result).toBe('<img src="/identifier/site/image.png">')
320
+
})
321
+
322
+
test('handles basePath with trailing slash', () => {
323
+
const html = '<img src="/image.png">'
324
+
const result = rewriteHtmlPaths(
325
+
html,
326
+
'/identifier/site/',
327
+
'index.html'
328
+
)
329
+
expect(result).toBe('<img src="/identifier/site/image.png">')
330
+
})
331
+
332
+
test('handles whitespace around equals sign', () => {
333
+
const html = '<img src = "/image.png">'
334
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
335
+
expect(result).toBe('<img src="/identifier/site/image.png">')
336
+
})
337
+
338
+
test('preserves query strings in URLs', () => {
339
+
const html = '<img src="/image.png?v=123">'
340
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
341
+
expect(result).toBe('<img src="/identifier/site/image.png?v=123">')
342
+
})
343
+
344
+
test('preserves hash fragments in URLs', () => {
345
+
const html = '<a href="/page.html#section">Link</a>'
346
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
347
+
expect(result).toBe(
348
+
'<a href="/identifier/site/page.html#section">Link</a>'
349
+
)
350
+
})
351
+
352
+
test('handles paths with special characters', () => {
353
+
const html = '<img src="/folder-name/file_name.png">'
354
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
355
+
expect(result).toBe(
356
+
'<img src="/identifier/site/folder-name/file_name.png">'
357
+
)
358
+
})
359
+
})
360
+
361
+
describe('real-world scenario', () => {
362
+
test('handles the example from the bug report', () => {
363
+
// HTML file at: /folder1/folder2/folder3/index.html
364
+
// Image at: /folder1/folder2/img.png
365
+
// Reference: src="../img.png"
366
+
const html = '<img src="../img.png">'
367
+
const result = rewriteHtmlPaths(
368
+
html,
369
+
basePath,
370
+
'folder1/folder2/folder3/index.html'
371
+
)
372
+
expect(result).toBe(
373
+
'<img src="/identifier/site/folder1/folder2/img.png">'
374
+
)
375
+
})
376
+
377
+
test('handles deeply nested static site structure', () => {
378
+
// A typical static site with nested pages and shared assets
379
+
const html = `
380
+
<!DOCTYPE html>
381
+
<html>
382
+
<head>
383
+
<link href="../../css/style.css" rel="stylesheet">
384
+
<link href="../../css/theme.css" rel="stylesheet">
385
+
<script src="../../js/main.js"></script>
386
+
</head>
387
+
<body>
388
+
<img src="../../images/logo.png" alt="Logo">
389
+
<img src="./post-image.jpg" alt="Post">
390
+
<a href="../index.html">Back to Blog</a>
391
+
<a href="../../index.html">Home</a>
392
+
</body>
393
+
</html>
394
+
`
395
+
const result = rewriteHtmlPaths(
396
+
html,
397
+
basePath,
398
+
'blog/posts/my-post.html'
399
+
)
400
+
401
+
// Assets two levels up
402
+
expect(result).toContain('href="/identifier/site/css/style.css"')
403
+
expect(result).toContain('href="/identifier/site/css/theme.css"')
404
+
expect(result).toContain('src="/identifier/site/js/main.js"')
405
+
expect(result).toContain('src="/identifier/site/images/logo.png"')
406
+
407
+
// Same directory
408
+
expect(result).toContain(
409
+
'src="/identifier/site/blog/posts/post-image.jpg"'
410
+
)
411
+
412
+
// One level up
413
+
expect(result).toContain('href="/identifier/site/blog/index.html"')
414
+
415
+
// Two levels up
416
+
expect(result).toContain('href="/identifier/site/index.html"')
417
+
})
418
+
})
419
+
})
420
+
421
+
describe('isHtmlContent', () => {
422
+
test('identifies HTML by content type', () => {
423
+
expect(isHtmlContent('file.txt', 'text/html')).toBe(true)
424
+
expect(isHtmlContent('file.txt', 'text/html; charset=utf-8')).toBe(
425
+
true
426
+
)
427
+
})
428
+
429
+
test('identifies HTML by .html extension', () => {
430
+
expect(isHtmlContent('index.html')).toBe(true)
431
+
expect(isHtmlContent('page.html', undefined)).toBe(true)
432
+
expect(isHtmlContent('/path/to/file.html')).toBe(true)
433
+
})
434
+
435
+
test('identifies HTML by .htm extension', () => {
436
+
expect(isHtmlContent('index.htm')).toBe(true)
437
+
expect(isHtmlContent('page.htm', undefined)).toBe(true)
438
+
})
439
+
440
+
test('handles case-insensitive extensions', () => {
441
+
expect(isHtmlContent('INDEX.HTML')).toBe(true)
442
+
expect(isHtmlContent('page.HTM')).toBe(true)
443
+
expect(isHtmlContent('File.HtMl')).toBe(true)
444
+
})
445
+
446
+
test('returns false for non-HTML files', () => {
447
+
expect(isHtmlContent('script.js')).toBe(false)
448
+
expect(isHtmlContent('style.css')).toBe(false)
449
+
expect(isHtmlContent('image.png')).toBe(false)
450
+
expect(isHtmlContent('data.json')).toBe(false)
451
+
})
452
+
453
+
test('returns false for files with no extension', () => {
454
+
expect(isHtmlContent('README')).toBe(false)
455
+
expect(isHtmlContent('Makefile')).toBe(false)
456
+
})
457
+
})
+178
-104
hosting-service/src/lib/html-rewriter.ts
+178
-104
hosting-service/src/lib/html-rewriter.ts
···
4
4
*/
5
5
6
6
const REWRITABLE_ATTRIBUTES = [
7
-
'src',
8
-
'href',
9
-
'action',
10
-
'data',
11
-
'poster',
12
-
'srcset',
13
-
] as const;
7
+
'src',
8
+
'href',
9
+
'action',
10
+
'data',
11
+
'poster',
12
+
'srcset'
13
+
] as const
14
14
15
15
/**
16
16
* Check if a path should be rewritten
17
17
*/
18
18
function shouldRewritePath(path: string): boolean {
19
-
// Don't rewrite empty paths
20
-
if (!path) return false;
19
+
// Don't rewrite empty paths
20
+
if (!path) return false
21
+
22
+
// Don't rewrite external URLs (http://, https://, //)
23
+
if (
24
+
path.startsWith('http://') ||
25
+
path.startsWith('https://') ||
26
+
path.startsWith('//')
27
+
) {
28
+
return false
29
+
}
30
+
31
+
// Don't rewrite data URIs or other schemes (except file paths)
32
+
if (
33
+
path.includes(':') &&
34
+
!path.startsWith('./') &&
35
+
!path.startsWith('../')
36
+
) {
37
+
return false
38
+
}
39
+
40
+
// Rewrite absolute paths (/) and relative paths (./ or ../ or plain filenames)
41
+
return true
42
+
}
21
43
22
-
// Don't rewrite external URLs (http://, https://, //)
23
-
if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('//')) {
24
-
return false;
25
-
}
44
+
/**
45
+
* Normalize a path by resolving . and .. segments
46
+
*/
47
+
function normalizePath(path: string): string {
48
+
const parts = path.split('/')
49
+
const result: string[] = []
26
50
27
-
// Don't rewrite data URIs or other schemes (except file paths)
28
-
if (path.includes(':') && !path.startsWith('./') && !path.startsWith('../')) {
29
-
return false;
30
-
}
51
+
for (const part of parts) {
52
+
if (part === '.' || part === '') {
53
+
// Skip current directory and empty parts (but keep leading empty for absolute paths)
54
+
if (part === '' && result.length === 0) {
55
+
result.push(part)
56
+
}
57
+
continue
58
+
}
59
+
if (part === '..') {
60
+
// Go up one directory (but not past root)
61
+
if (result.length > 0 && result[result.length - 1] !== '..') {
62
+
result.pop()
63
+
}
64
+
continue
65
+
}
66
+
result.push(part)
67
+
}
31
68
32
-
// Don't rewrite pure anchors
33
-
if (path.startsWith('#')) return false;
69
+
return result.join('/')
70
+
}
34
71
35
-
// Rewrite absolute paths (/) and relative paths (./ or ../ or plain filenames)
36
-
return true;
72
+
/**
73
+
* Get the directory path from a file path
74
+
* e.g., "folder1/folder2/file.html" -> "folder1/folder2/"
75
+
*/
76
+
function getDirectory(filepath: string): string {
77
+
const lastSlash = filepath.lastIndexOf('/')
78
+
if (lastSlash === -1) {
79
+
return ''
80
+
}
81
+
return filepath.substring(0, lastSlash + 1)
37
82
}
38
83
39
84
/**
40
85
* Rewrite a single path
41
86
*/
42
-
function rewritePath(path: string, basePath: string): string {
43
-
if (!shouldRewritePath(path)) {
44
-
return path;
45
-
}
87
+
function rewritePath(
88
+
path: string,
89
+
basePath: string,
90
+
documentPath: string
91
+
): string {
92
+
if (!shouldRewritePath(path)) {
93
+
return path
94
+
}
46
95
47
-
// Handle absolute paths: /file.js -> /base/file.js
48
-
if (path.startsWith('/')) {
49
-
return basePath + path.slice(1);
50
-
}
96
+
// Handle absolute paths: /file.js -> /base/file.js
97
+
if (path.startsWith('/')) {
98
+
return basePath + path.slice(1)
99
+
}
51
100
52
-
// Handle relative paths: ./file.js or ../file.js or file.js -> /base/file.js
53
-
// Strip leading ./ or ../ and just use the base path
54
-
let cleanPath = path;
55
-
if (cleanPath.startsWith('./')) {
56
-
cleanPath = cleanPath.slice(2);
57
-
} else if (cleanPath.startsWith('../')) {
58
-
// For sites.wisp.place, we can't go up from the site root, so just use base path
59
-
cleanPath = cleanPath.replace(/^(\.\.\/)+/, '');
60
-
}
101
+
// Handle relative paths by resolving against document directory
102
+
const documentDir = getDirectory(documentPath)
103
+
let resolvedPath: string
104
+
105
+
if (path.startsWith('./')) {
106
+
// ./file.js relative to current directory
107
+
resolvedPath = documentDir + path.slice(2)
108
+
} else if (path.startsWith('../')) {
109
+
// ../file.js relative to parent directory
110
+
resolvedPath = documentDir + path
111
+
} else {
112
+
// file.js (no prefix) - treat as relative to current directory
113
+
resolvedPath = documentDir + path
114
+
}
115
+
116
+
// Normalize the path to resolve .. and .
117
+
resolvedPath = normalizePath(resolvedPath)
61
118
62
-
return basePath + cleanPath;
119
+
return basePath + resolvedPath
63
120
}
64
121
65
122
/**
66
123
* Rewrite srcset attribute (can contain multiple URLs)
67
124
* Format: "url1 1x, url2 2x" or "url1 100w, url2 200w"
68
125
*/
69
-
function rewriteSrcset(srcset: string, basePath: string): string {
70
-
return srcset
71
-
.split(',')
72
-
.map(part => {
73
-
const trimmed = part.trim();
74
-
const spaceIndex = trimmed.indexOf(' ');
126
+
function rewriteSrcset(
127
+
srcset: string,
128
+
basePath: string,
129
+
documentPath: string
130
+
): string {
131
+
return srcset
132
+
.split(',')
133
+
.map((part) => {
134
+
const trimmed = part.trim()
135
+
const spaceIndex = trimmed.indexOf(' ')
75
136
76
-
if (spaceIndex === -1) {
77
-
// No descriptor, just URL
78
-
return rewritePath(trimmed, basePath);
79
-
}
137
+
if (spaceIndex === -1) {
138
+
// No descriptor, just URL
139
+
return rewritePath(trimmed, basePath, documentPath)
140
+
}
80
141
81
-
const url = trimmed.substring(0, spaceIndex);
82
-
const descriptor = trimmed.substring(spaceIndex);
83
-
return rewritePath(url, basePath) + descriptor;
84
-
})
85
-
.join(', ');
142
+
const url = trimmed.substring(0, spaceIndex)
143
+
const descriptor = trimmed.substring(spaceIndex)
144
+
return rewritePath(url, basePath, documentPath) + descriptor
145
+
})
146
+
.join(', ')
86
147
}
87
148
88
149
/**
89
-
* Rewrite absolute paths in HTML content
150
+
* Rewrite absolute and relative paths in HTML content
90
151
* Uses simple regex matching for safety (no full HTML parsing)
91
152
*/
92
-
export function rewriteHtmlPaths(html: string, basePath: string): string {
93
-
// Ensure base path ends with /
94
-
const normalizedBase = basePath.endsWith('/') ? basePath : basePath + '/';
153
+
export function rewriteHtmlPaths(
154
+
html: string,
155
+
basePath: string,
156
+
documentPath: string
157
+
): string {
158
+
// Ensure base path ends with /
159
+
const normalizedBase = basePath.endsWith('/') ? basePath : basePath + '/'
95
160
96
-
let rewritten = html;
161
+
let rewritten = html
97
162
98
-
// Rewrite each attribute type
99
-
// Use more specific patterns to prevent ReDoS attacks
100
-
for (const attr of REWRITABLE_ATTRIBUTES) {
101
-
if (attr === 'srcset') {
102
-
// Special handling for srcset - use possessive quantifiers via atomic grouping simulation
103
-
// Limit whitespace to reasonable amount (max 5 spaces) to prevent ReDoS
104
-
const srcsetRegex = new RegExp(
105
-
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`,
106
-
'gi'
107
-
);
108
-
rewritten = rewritten.replace(srcsetRegex, (match, value) => {
109
-
const rewrittenValue = rewriteSrcset(value, normalizedBase);
110
-
return `${attr}="${rewrittenValue}"`;
111
-
});
112
-
} else {
113
-
// Regular attributes with quoted values
114
-
// Limit whitespace to prevent catastrophic backtracking
115
-
const doubleQuoteRegex = new RegExp(
116
-
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`,
117
-
'gi'
118
-
);
119
-
const singleQuoteRegex = new RegExp(
120
-
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}'([^']*)'`,
121
-
'gi'
122
-
);
163
+
// Rewrite each attribute type
164
+
// Use more specific patterns to prevent ReDoS attacks
165
+
for (const attr of REWRITABLE_ATTRIBUTES) {
166
+
if (attr === 'srcset') {
167
+
// Special handling for srcset - use possessive quantifiers via atomic grouping simulation
168
+
// Limit whitespace to reasonable amount (max 5 spaces) to prevent ReDoS
169
+
const srcsetRegex = new RegExp(
170
+
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`,
171
+
'gi'
172
+
)
173
+
rewritten = rewritten.replace(srcsetRegex, (match, value) => {
174
+
const rewrittenValue = rewriteSrcset(
175
+
value,
176
+
normalizedBase,
177
+
documentPath
178
+
)
179
+
return `${attr}="${rewrittenValue}"`
180
+
})
181
+
} else {
182
+
// Regular attributes with quoted values
183
+
// Limit whitespace to prevent catastrophic backtracking
184
+
const doubleQuoteRegex = new RegExp(
185
+
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`,
186
+
'gi'
187
+
)
188
+
const singleQuoteRegex = new RegExp(
189
+
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}'([^']*)'`,
190
+
'gi'
191
+
)
123
192
124
-
rewritten = rewritten.replace(doubleQuoteRegex, (match, value) => {
125
-
const rewrittenValue = rewritePath(value, normalizedBase);
126
-
return `${attr}="${rewrittenValue}"`;
127
-
});
193
+
rewritten = rewritten.replace(doubleQuoteRegex, (match, value) => {
194
+
const rewrittenValue = rewritePath(
195
+
value,
196
+
normalizedBase,
197
+
documentPath
198
+
)
199
+
return `${attr}="${rewrittenValue}"`
200
+
})
128
201
129
-
rewritten = rewritten.replace(singleQuoteRegex, (match, value) => {
130
-
const rewrittenValue = rewritePath(value, normalizedBase);
131
-
return `${attr}='${rewrittenValue}'`;
132
-
});
133
-
}
134
-
}
202
+
rewritten = rewritten.replace(singleQuoteRegex, (match, value) => {
203
+
const rewrittenValue = rewritePath(
204
+
value,
205
+
normalizedBase,
206
+
documentPath
207
+
)
208
+
return `${attr}='${rewrittenValue}'`
209
+
})
210
+
}
211
+
}
135
212
136
-
return rewritten;
213
+
return rewritten
137
214
}
138
215
139
216
/**
140
217
* Check if content is HTML based on content or filename
141
218
*/
142
-
export function isHtmlContent(
143
-
filepath: string,
144
-
contentType?: string
145
-
): boolean {
146
-
if (contentType && contentType.includes('text/html')) {
147
-
return true;
148
-
}
219
+
export function isHtmlContent(filepath: string, contentType?: string): boolean {
220
+
if (contentType && contentType.includes('text/html')) {
221
+
return true
222
+
}
149
223
150
-
const ext = filepath.toLowerCase().split('.').pop();
151
-
return ext === 'html' || ext === 'htm';
224
+
const ext = filepath.toLowerCase().split('.').pop()
225
+
return ext === 'html' || ext === 'htm'
152
226
}
+8
-3
hosting-service/src/lib/safe-fetch.ts
+8
-3
hosting-service/src/lib/safe-fetch.ts
···
24
24
const FETCH_TIMEOUT = 120000; // 120 seconds
25
25
const FETCH_TIMEOUT_BLOB = 120000; // 2 minutes for blob downloads
26
26
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
27
+
const MAX_JSON_SIZE = 1024 * 1024; // 1MB
28
+
const MAX_BLOB_SIZE = 100 * 1024 * 1024; // 100MB
29
+
const MAX_REDIRECTS = 10;
27
30
28
31
function isBlockedHost(hostname: string): boolean {
29
32
const lowerHost = hostname.toLowerCase();
···
72
75
const response = await fetch(url, {
73
76
...options,
74
77
signal: controller.signal,
78
+
redirect: 'follow',
75
79
});
76
80
77
81
const contentLength = response.headers.get('content-length');
···
94
98
url: string,
95
99
options?: RequestInit & { maxSize?: number; timeout?: number }
96
100
): Promise<T> {
97
-
const maxJsonSize = options?.maxSize ?? 1024 * 1024; // 1MB default for JSON
101
+
const maxJsonSize = options?.maxSize ?? MAX_JSON_SIZE;
98
102
const response = await safeFetch(url, { ...options, maxSize: maxJsonSize });
99
103
100
104
if (!response.ok) {
···
140
144
url: string,
141
145
options?: RequestInit & { maxSize?: number; timeout?: number }
142
146
): Promise<Uint8Array> {
143
-
const maxBlobSize = options?.maxSize ?? MAX_RESPONSE_SIZE;
144
-
const response = await safeFetch(url, { ...options, maxSize: maxBlobSize });
147
+
const maxBlobSize = options?.maxSize ?? MAX_BLOB_SIZE;
148
+
const timeoutMs = options?.timeout ?? FETCH_TIMEOUT_BLOB;
149
+
const response = await safeFetch(url, { ...options, maxSize: maxBlobSize, timeout: timeoutMs });
145
150
146
151
if (!response.ok) {
147
152
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+169
hosting-service/src/lib/utils.test.ts
+169
hosting-service/src/lib/utils.test.ts
···
1
+
import { describe, test, expect } from 'bun:test'
2
+
import { sanitizePath, extractBlobCid } from './utils'
3
+
import { CID } from 'multiformats'
4
+
5
+
describe('sanitizePath', () => {
6
+
test('allows normal file paths', () => {
7
+
expect(sanitizePath('index.html')).toBe('index.html')
8
+
expect(sanitizePath('css/styles.css')).toBe('css/styles.css')
9
+
expect(sanitizePath('images/logo.png')).toBe('images/logo.png')
10
+
expect(sanitizePath('js/app.js')).toBe('js/app.js')
11
+
})
12
+
13
+
test('allows deeply nested paths', () => {
14
+
expect(sanitizePath('assets/images/icons/favicon.ico')).toBe('assets/images/icons/favicon.ico')
15
+
expect(sanitizePath('a/b/c/d/e/f.txt')).toBe('a/b/c/d/e/f.txt')
16
+
})
17
+
18
+
test('removes leading slashes', () => {
19
+
expect(sanitizePath('/index.html')).toBe('index.html')
20
+
expect(sanitizePath('//index.html')).toBe('index.html')
21
+
expect(sanitizePath('///index.html')).toBe('index.html')
22
+
expect(sanitizePath('/css/styles.css')).toBe('css/styles.css')
23
+
})
24
+
25
+
test('blocks parent directory traversal', () => {
26
+
expect(sanitizePath('../etc/passwd')).toBe('etc/passwd')
27
+
expect(sanitizePath('../../etc/passwd')).toBe('etc/passwd')
28
+
expect(sanitizePath('../../../etc/passwd')).toBe('etc/passwd')
29
+
expect(sanitizePath('css/../../../etc/passwd')).toBe('css/etc/passwd')
30
+
})
31
+
32
+
test('blocks directory traversal in middle of path', () => {
33
+
expect(sanitizePath('images/../../../etc/passwd')).toBe('images/etc/passwd')
34
+
// Note: sanitizePath only filters out ".." segments, doesn't resolve paths
35
+
expect(sanitizePath('a/b/../c')).toBe('a/b/c')
36
+
expect(sanitizePath('a/../b/../c')).toBe('a/b/c')
37
+
})
38
+
39
+
test('removes current directory references', () => {
40
+
expect(sanitizePath('./index.html')).toBe('index.html')
41
+
expect(sanitizePath('././index.html')).toBe('index.html')
42
+
expect(sanitizePath('css/./styles.css')).toBe('css/styles.css')
43
+
expect(sanitizePath('./css/./styles.css')).toBe('css/styles.css')
44
+
})
45
+
46
+
test('removes empty path segments', () => {
47
+
expect(sanitizePath('css//styles.css')).toBe('css/styles.css')
48
+
expect(sanitizePath('css///styles.css')).toBe('css/styles.css')
49
+
expect(sanitizePath('a//b//c')).toBe('a/b/c')
50
+
})
51
+
52
+
test('blocks null bytes', () => {
53
+
// Null bytes cause the entire segment to be filtered out
54
+
expect(sanitizePath('index.html\0.txt')).toBe('')
55
+
expect(sanitizePath('test\0')).toBe('')
56
+
// Null byte in middle segment
57
+
expect(sanitizePath('css/bad\0name/styles.css')).toBe('css/styles.css')
58
+
})
59
+
60
+
test('handles mixed attacks', () => {
61
+
expect(sanitizePath('/../../../etc/passwd')).toBe('etc/passwd')
62
+
expect(sanitizePath('/./././../etc/passwd')).toBe('etc/passwd')
63
+
expect(sanitizePath('//../../.\0./etc/passwd')).toBe('etc/passwd')
64
+
})
65
+
66
+
test('handles edge cases', () => {
67
+
expect(sanitizePath('')).toBe('')
68
+
expect(sanitizePath('/')).toBe('')
69
+
expect(sanitizePath('//')).toBe('')
70
+
expect(sanitizePath('.')).toBe('')
71
+
expect(sanitizePath('..')).toBe('')
72
+
expect(sanitizePath('../..')).toBe('')
73
+
})
74
+
75
+
test('preserves valid special characters in filenames', () => {
76
+
expect(sanitizePath('file-name.html')).toBe('file-name.html')
77
+
expect(sanitizePath('file_name.html')).toBe('file_name.html')
78
+
expect(sanitizePath('file.name.html')).toBe('file.name.html')
79
+
expect(sanitizePath('file (1).html')).toBe('file (1).html')
80
+
expect(sanitizePath('file@2x.png')).toBe('file@2x.png')
81
+
})
82
+
83
+
test('handles Unicode characters', () => {
84
+
expect(sanitizePath('ๆไปถ.html')).toBe('ๆไปถ.html')
85
+
expect(sanitizePath('ัะฐะนะป.html')).toBe('ัะฐะนะป.html')
86
+
expect(sanitizePath('ใใกใคใซ.html')).toBe('ใใกใคใซ.html')
87
+
})
88
+
})
89
+
90
+
describe('extractBlobCid', () => {
91
+
const TEST_CID = 'bafkreid7ybejd5s2vv2j7d4aajjlmdgazguemcnuliiyfn6coxpwp2mi6y'
92
+
93
+
test('extracts CID from IPLD link', () => {
94
+
const blobRef = { $link: TEST_CID }
95
+
expect(extractBlobCid(blobRef)).toBe(TEST_CID)
96
+
})
97
+
98
+
test('extracts CID from typed BlobRef with CID object', () => {
99
+
const cid = CID.parse(TEST_CID)
100
+
const blobRef = { ref: cid }
101
+
const result = extractBlobCid(blobRef)
102
+
expect(result).toBe(TEST_CID)
103
+
})
104
+
105
+
test('extracts CID from typed BlobRef with IPLD link', () => {
106
+
const blobRef = {
107
+
ref: { $link: TEST_CID }
108
+
}
109
+
expect(extractBlobCid(blobRef)).toBe(TEST_CID)
110
+
})
111
+
112
+
test('extracts CID from untyped BlobRef', () => {
113
+
const blobRef = { cid: TEST_CID }
114
+
expect(extractBlobCid(blobRef)).toBe(TEST_CID)
115
+
})
116
+
117
+
test('returns null for invalid blob ref', () => {
118
+
expect(extractBlobCid(null)).toBe(null)
119
+
expect(extractBlobCid(undefined)).toBe(null)
120
+
expect(extractBlobCid({})).toBe(null)
121
+
expect(extractBlobCid('not-an-object')).toBe(null)
122
+
expect(extractBlobCid(123)).toBe(null)
123
+
})
124
+
125
+
test('returns null for malformed objects', () => {
126
+
expect(extractBlobCid({ wrongKey: 'value' })).toBe(null)
127
+
expect(extractBlobCid({ ref: 'not-a-cid' })).toBe(null)
128
+
expect(extractBlobCid({ ref: {} })).toBe(null)
129
+
})
130
+
131
+
test('handles nested structures from AT Proto API', () => {
132
+
// Real structure from AT Proto
133
+
const blobRef = {
134
+
$type: 'blob',
135
+
ref: CID.parse(TEST_CID),
136
+
mimeType: 'text/html',
137
+
size: 1234
138
+
}
139
+
expect(extractBlobCid(blobRef)).toBe(TEST_CID)
140
+
})
141
+
142
+
test('handles BlobRef with additional properties', () => {
143
+
const blobRef = {
144
+
ref: { $link: TEST_CID },
145
+
mimeType: 'image/png',
146
+
size: 5678,
147
+
someOtherField: 'value'
148
+
}
149
+
expect(extractBlobCid(blobRef)).toBe(TEST_CID)
150
+
})
151
+
152
+
test('prioritizes checking IPLD link first', () => {
153
+
// Direct $link takes precedence
154
+
const directLink = { $link: TEST_CID }
155
+
expect(extractBlobCid(directLink)).toBe(TEST_CID)
156
+
})
157
+
158
+
test('handles CID v0 format', () => {
159
+
const cidV0 = 'QmZ4tDuvesekSs4qM5ZBKpXiZGun7S2CYtEZRB3DYXkjGx'
160
+
const blobRef = { $link: cidV0 }
161
+
expect(extractBlobCid(blobRef)).toBe(cidV0)
162
+
})
163
+
164
+
test('handles CID v1 format', () => {
165
+
const cidV1 = 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi'
166
+
const blobRef = { $link: cidV1 }
167
+
expect(extractBlobCid(blobRef)).toBe(cidV1)
168
+
})
169
+
})
+83
-6
hosting-service/src/lib/utils.ts
+83
-6
hosting-service/src/lib/utils.ts
···
5
5
import { safeFetchJson, safeFetchBlob } from './safe-fetch';
6
6
import { CID } from 'multiformats';
7
7
8
-
const CACHE_DIR = './cache/sites';
8
+
const CACHE_DIR = process.env.CACHE_DIR || './cache/sites';
9
9
const CACHE_TTL = 14 * 24 * 60 * 60 * 1000; // 14 days cache TTL
10
10
11
11
interface CacheMetadata {
···
13
13
cachedAt: number;
14
14
did: string;
15
15
rkey: string;
16
+
}
17
+
18
+
/**
19
+
* Determines if a MIME type should benefit from gzip compression.
20
+
* Returns true for text-based web assets (HTML, CSS, JS, JSON, XML, SVG).
21
+
* Returns false for already-compressed formats (images, video, audio, PDFs).
22
+
*
23
+
*/
24
+
export function shouldCompressMimeType(mimeType: string | undefined): boolean {
25
+
if (!mimeType) return false;
26
+
27
+
const mime = mimeType.toLowerCase();
28
+
29
+
// Text-based web assets that benefit from compression
30
+
const compressibleTypes = [
31
+
'text/html',
32
+
'text/css',
33
+
'text/javascript',
34
+
'application/javascript',
35
+
'application/x-javascript',
36
+
'text/xml',
37
+
'application/xml',
38
+
'application/json',
39
+
'text/plain',
40
+
'image/svg+xml',
41
+
];
42
+
43
+
if (compressibleTypes.some(type => mime === type || mime.startsWith(type))) {
44
+
return true;
45
+
}
46
+
47
+
// Already-compressed formats that should NOT be double-compressed
48
+
const alreadyCompressedPrefixes = [
49
+
'video/',
50
+
'audio/',
51
+
'image/',
52
+
'application/pdf',
53
+
'application/zip',
54
+
'application/gzip',
55
+
];
56
+
57
+
if (alreadyCompressedPrefixes.some(prefix => mime.startsWith(prefix))) {
58
+
return false;
59
+
}
60
+
61
+
// Default to not compressing for unknown types
62
+
return false;
16
63
}
17
64
18
65
interface IpldLink {
···
268
315
// Allow up to 100MB per file blob, with 2 minute timeout
269
316
let content = await safeFetchBlob(blobUrl, { maxSize: 100 * 1024 * 1024, timeout: 120000 });
270
317
271
-
// If content is base64-encoded, decode it back to binary (gzipped or not)
318
+
console.log(`[DEBUG] ${filePath}: fetched ${content.length} bytes, base64=${base64}, encoding=${encoding}, mimeType=${mimeType}`);
319
+
320
+
// If content is base64-encoded, decode it back to raw binary (gzipped or not)
272
321
if (base64) {
273
-
// The content from the blob is base64 text, decode it directly to binary
274
-
const buffer = Buffer.from(content);
275
-
const base64String = buffer.toString('ascii'); // Use ascii for base64 text, not utf-8
322
+
const originalSize = content.length;
323
+
// Decode base64 directly from raw bytes - no string conversion
324
+
// The blob contains base64-encoded text as raw bytes, decode it in-place
325
+
const textDecoder = new TextDecoder();
326
+
const base64String = textDecoder.decode(content);
276
327
content = Buffer.from(base64String, 'base64');
328
+
console.log(`[DEBUG] ${filePath}: decoded base64 from ${originalSize} bytes to ${content.length} bytes`);
329
+
330
+
// Check if it's actually gzipped by looking at magic bytes
331
+
if (content.length >= 2) {
332
+
const hasGzipMagic = content[0] === 0x1f && content[1] === 0x8b;
333
+
console.log(`[DEBUG] ${filePath}: has gzip magic bytes: ${hasGzipMagic}`);
334
+
}
277
335
}
278
336
279
337
const cacheFile = `${CACHE_DIR}/${did}/${site}${dirSuffix}/${filePath}`;
···
283
341
mkdirSync(fileDir, { recursive: true });
284
342
}
285
343
344
+
// Use the shared function to determine if this should remain compressed
345
+
const shouldStayCompressed = shouldCompressMimeType(mimeType);
346
+
347
+
// Decompress files that shouldn't be stored compressed
348
+
if (encoding === 'gzip' && !shouldStayCompressed && content.length >= 2 &&
349
+
content[0] === 0x1f && content[1] === 0x8b) {
350
+
console.log(`[DEBUG] ${filePath}: decompressing non-compressible type (${mimeType}) before caching`);
351
+
try {
352
+
const { gunzipSync } = await import('zlib');
353
+
const decompressed = gunzipSync(content);
354
+
console.log(`[DEBUG] ${filePath}: decompressed from ${content.length} to ${decompressed.length} bytes`);
355
+
content = decompressed;
356
+
// Clear the encoding flag since we're storing decompressed
357
+
encoding = undefined;
358
+
} catch (error) {
359
+
console.log(`[DEBUG] ${filePath}: failed to decompress, storing original gzipped content. Error:`, error);
360
+
}
361
+
}
362
+
286
363
await writeFile(cacheFile, content);
287
364
288
-
// Store metadata if file is compressed
365
+
// Store metadata only if file is still compressed
289
366
if (encoding === 'gzip' && mimeType) {
290
367
const metaFile = `${cacheFile}.meta`;
291
368
await writeFile(metaFile, JSON.stringify({ encoding, mimeType }));
+62
-9
hosting-service/src/server.ts
+62
-9
hosting-service/src/server.ts
···
1
1
import { Hono } from 'hono';
2
2
import { getWispDomain, getCustomDomain, getCustomDomainByHash } from './lib/db';
3
-
import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath } from './lib/utils';
3
+
import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath, shouldCompressMimeType } from './lib/utils';
4
4
import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter';
5
5
import { existsSync, readFileSync } from 'fs';
6
6
import { lookup } from 'mime-types';
···
35
35
const content = readFileSync(cachedFile);
36
36
const metaFile = `${cachedFile}.meta`;
37
37
38
+
console.log(`[DEBUG SERVE] ${requestPath}: file size=${content.length} bytes, metaFile exists=${existsSync(metaFile)}`);
39
+
38
40
// Check if file has compression metadata
39
41
if (existsSync(metaFile)) {
40
42
const meta = JSON.parse(readFileSync(metaFile, 'utf-8'));
43
+
console.log(`[DEBUG SERVE] ${requestPath}: meta=${JSON.stringify(meta)}`);
44
+
45
+
// Check actual content for gzip magic bytes
46
+
if (content.length >= 2) {
47
+
const hasGzipMagic = content[0] === 0x1f && content[1] === 0x8b;
48
+
console.log(`[DEBUG SERVE] ${requestPath}: has gzip magic bytes=${hasGzipMagic}`);
49
+
}
50
+
41
51
if (meta.encoding === 'gzip' && meta.mimeType) {
42
-
// Serve gzipped content with proper headers
52
+
// Use shared function to determine if this should be served compressed
53
+
const shouldServeCompressed = shouldCompressMimeType(meta.mimeType);
54
+
55
+
if (!shouldServeCompressed) {
56
+
// This shouldn't happen if caching is working correctly, but handle it gracefully
57
+
console.log(`[DEBUG SERVE] ${requestPath}: decompressing file that shouldn't be compressed (${meta.mimeType})`);
58
+
const { gunzipSync } = await import('zlib');
59
+
const decompressed = gunzipSync(content);
60
+
console.log(`[DEBUG SERVE] ${requestPath}: decompressed from ${content.length} to ${decompressed.length} bytes`);
61
+
return new Response(decompressed, {
62
+
headers: {
63
+
'Content-Type': meta.mimeType,
64
+
},
65
+
});
66
+
}
67
+
68
+
// Serve gzipped content with proper headers (for HTML, CSS, JS, etc.)
69
+
console.log(`[DEBUG SERVE] ${requestPath}: serving as gzipped with Content-Encoding header`);
43
70
return new Response(content, {
44
71
headers: {
45
72
'Content-Type': meta.mimeType,
···
119
146
}
120
147
121
148
// Check if this is HTML content that needs rewriting
122
-
// Note: For gzipped HTML with path rewriting, we need to decompress, rewrite, and serve uncompressed
123
-
// This is a trade-off for the sites.wisp.place domain which needs path rewriting
149
+
// We decompress, rewrite paths, then recompress for efficient delivery
124
150
if (isHtmlContent(requestPath, mimeType)) {
125
151
let content: string;
126
152
if (isGzipped) {
···
130
156
} else {
131
157
content = readFileSync(cachedFile, 'utf-8');
132
158
}
133
-
const rewritten = rewriteHtmlPaths(content, basePath);
134
-
return new Response(rewritten, {
159
+
const rewritten = rewriteHtmlPaths(content, basePath, requestPath);
160
+
161
+
// Recompress the HTML for efficient delivery
162
+
const { gzipSync } = await import('zlib');
163
+
const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8'));
164
+
165
+
return new Response(recompressed, {
135
166
headers: {
136
167
'Content-Type': 'text/html; charset=utf-8',
168
+
'Content-Encoding': 'gzip',
137
169
},
138
170
});
139
171
}
···
141
173
// Non-HTML files: serve gzipped content as-is with proper headers
142
174
const content = readFileSync(cachedFile);
143
175
if (isGzipped) {
176
+
// Use shared function to determine if this should be served compressed
177
+
const shouldServeCompressed = shouldCompressMimeType(mimeType);
178
+
179
+
if (!shouldServeCompressed) {
180
+
// This shouldn't happen if caching is working correctly, but handle it gracefully
181
+
const { gunzipSync } = await import('zlib');
182
+
const decompressed = gunzipSync(content);
183
+
return new Response(decompressed, {
184
+
headers: {
185
+
'Content-Type': mimeType,
186
+
},
187
+
});
188
+
}
189
+
144
190
return new Response(content, {
145
191
headers: {
146
192
'Content-Type': mimeType,
···
169
215
}
170
216
}
171
217
172
-
// HTML needs path rewriting, so decompress if needed
218
+
// HTML needs path rewriting, decompress, rewrite, then recompress
173
219
let content: string;
174
220
if (isGzipped) {
175
221
const { gunzipSync } = await import('zlib');
···
178
224
} else {
179
225
content = readFileSync(indexFile, 'utf-8');
180
226
}
181
-
const rewritten = rewriteHtmlPaths(content, basePath);
182
-
return new Response(rewritten, {
227
+
const indexPath = `${requestPath}/index.html`;
228
+
const rewritten = rewriteHtmlPaths(content, basePath, indexPath);
229
+
230
+
// Recompress the HTML for efficient delivery
231
+
const { gzipSync } = await import('zlib');
232
+
const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8'));
233
+
234
+
return new Response(recompressed, {
183
235
headers: {
184
236
'Content-Type': 'text/html; charset=utf-8',
237
+
'Content-Encoding': 'gzip',
185
238
},
186
239
});
187
240
}
+1
-1
hosting-service/tsconfig.json
+1
-1
hosting-service/tsconfig.json
+2
-1
package.json
+2
-1
package.json
···
2
2
"name": "elysia-static",
3
3
"version": "1.0.50",
4
4
"scripts": {
5
-
"test": "echo \"Error: no test specified\" && exit 1",
5
+
"test": "bun test",
6
6
"dev": "bun run --watch src/index.ts",
7
7
"start": "bun run src/index.ts",
8
8
"build": "bun build --compile --target bun --outfile server src/index.ts"
···
30
30
"lucide-react": "^0.546.0",
31
31
"react": "^19.2.0",
32
32
"react-dom": "^19.2.0",
33
+
"react-shiki": "^0.9.0",
33
34
"tailwind-merge": "^3.3.1",
34
35
"tailwindcss": "4",
35
36
"tw-animate-css": "^1.4.0",
+23
public/components/ui/code-block.tsx
+23
public/components/ui/code-block.tsx
···
1
+
import ShikiHighlighter from 'react-shiki'
2
+
3
+
interface CodeBlockProps {
4
+
code: string
5
+
language?: string
6
+
className?: string
7
+
}
8
+
9
+
export function CodeBlock({ code, language = 'bash', className = '' }: CodeBlockProps) {
10
+
return (
11
+
<ShikiHighlighter
12
+
language={language}
13
+
theme={{
14
+
light: 'catppuccin-latte',
15
+
dark: 'catppuccin-mocha',
16
+
}}
17
+
defaultColor="light-dark()"
18
+
className={className}
19
+
>
20
+
{code.trim()}
21
+
</ShikiHighlighter>
22
+
)
23
+
}
+1
-1
public/components/ui/radio-group.tsx
+1
-1
public/components/ui/radio-group.tsx
···
27
27
<RadioGroupPrimitive.Item
28
28
data-slot="radio-group-item"
29
29
className={cn(
30
-
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
30
+
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/50 aspect-square size-4 shrink-0 rounded-full border border-black/30 dark:border-white/30 shadow-inner transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
31
31
className
32
32
)}
33
33
{...props}
+2
-2
public/components/ui/tabs.tsx
+2
-2
public/components/ui/tabs.tsx
···
24
24
<TabsPrimitive.List
25
25
data-slot="tabs-list"
26
26
className={cn(
27
-
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
27
+
"bg-muted dark:bg-muted/80 text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
28
28
className
29
29
)}
30
30
{...props}
···
40
40
<TabsPrimitive.Trigger
41
41
data-slot="tabs-trigger"
42
42
className={cn(
43
-
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
43
+
"data-[state=active]:bg-background dark:data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-border focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-border dark:data-[state=active]:shadow-sm text-foreground dark:text-muted-foreground/70 inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
44
44
className
45
45
)}
46
46
{...props}
+268
-19
public/editor/editor.tsx
+268
-19
public/editor/editor.tsx
···
38
38
Settings
39
39
} from 'lucide-react'
40
40
import { RadioGroup, RadioGroupItem } from '@public/components/ui/radio-group'
41
+
import { CodeBlock } from '@public/components/ui/code-block'
41
42
42
43
import Layout from '@public/layouts'
43
44
···
579
580
</div>
580
581
581
582
<Tabs defaultValue="sites" className="space-y-6 w-full">
582
-
<TabsList className="grid w-full grid-cols-3 max-w-md">
583
+
<TabsList className="grid w-full grid-cols-4">
583
584
<TabsTrigger value="sites">Sites</TabsTrigger>
584
585
<TabsTrigger value="domains">Domains</TabsTrigger>
585
586
<TabsTrigger value="upload">Upload</TabsTrigger>
587
+
<TabsTrigger value="cli">CLI</TabsTrigger>
586
588
</TabsList>
587
589
588
590
{/* Sites Tab */}
···
884
886
</CardHeader>
885
887
<CardContent className="space-y-6">
886
888
<div className="space-y-4">
887
-
<RadioGroup
888
-
value={siteMode}
889
-
onValueChange={(value) => setSiteMode(value as 'existing' | 'new')}
890
-
disabled={isUploading}
891
-
>
892
-
<div className="flex items-center space-x-2">
893
-
<RadioGroupItem value="existing" id="existing" />
894
-
<Label htmlFor="existing" className="cursor-pointer">
895
-
Update existing site
896
-
</Label>
897
-
</div>
898
-
<div className="flex items-center space-x-2">
899
-
<RadioGroupItem value="new" id="new" />
900
-
<Label htmlFor="new" className="cursor-pointer">
901
-
Create new site
902
-
</Label>
903
-
</div>
904
-
</RadioGroup>
889
+
<div className="p-4 bg-muted/50 rounded-lg">
890
+
<RadioGroup
891
+
value={siteMode}
892
+
onValueChange={(value) => setSiteMode(value as 'existing' | 'new')}
893
+
disabled={isUploading}
894
+
>
895
+
<div className="flex items-center space-x-2">
896
+
<RadioGroupItem value="existing" id="existing" />
897
+
<Label htmlFor="existing" className="cursor-pointer">
898
+
Update existing site
899
+
</Label>
900
+
</div>
901
+
<div className="flex items-center space-x-2">
902
+
<RadioGroupItem value="new" id="new" />
903
+
<Label htmlFor="new" className="cursor-pointer">
904
+
Create new site
905
+
</Label>
906
+
</div>
907
+
</RadioGroup>
908
+
</div>
905
909
906
910
{siteMode === 'existing' ? (
907
911
<div className="space-y-2">
···
1074
1078
</>
1075
1079
)}
1076
1080
</Button>
1081
+
</CardContent>
1082
+
</Card>
1083
+
</TabsContent>
1084
+
1085
+
{/* CLI Tab */}
1086
+
<TabsContent value="cli" className="space-y-4 min-h-[400px]">
1087
+
<Card>
1088
+
<CardHeader>
1089
+
<div className="flex items-center gap-2 mb-2">
1090
+
<CardTitle>Wisp CLI Tool</CardTitle>
1091
+
<Badge variant="secondary" className="text-xs">v0.1.0</Badge>
1092
+
<Badge variant="outline" className="text-xs">Alpha</Badge>
1093
+
</div>
1094
+
<CardDescription>
1095
+
Deploy static sites directly from your terminal
1096
+
</CardDescription>
1097
+
</CardHeader>
1098
+
<CardContent className="space-y-6">
1099
+
<div className="prose prose-sm max-w-none dark:prose-invert">
1100
+
<p className="text-sm text-muted-foreground">
1101
+
The Wisp CLI is a command-line tool for deploying static websites directly to your AT Protocol account.
1102
+
Authenticate with app password or OAuth and deploy from CI/CD pipelines.
1103
+
</p>
1104
+
</div>
1105
+
1106
+
<div className="space-y-3">
1107
+
<h3 className="text-sm font-semibold">Download CLI</h3>
1108
+
<div className="grid gap-2">
1109
+
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
1110
+
<a
1111
+
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-macos-arm64"
1112
+
target="_blank"
1113
+
rel="noopener noreferrer"
1114
+
className="flex items-center justify-between mb-2"
1115
+
>
1116
+
<span className="font-mono text-sm">macOS (Apple Silicon)</span>
1117
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
1118
+
</a>
1119
+
<div className="text-xs text-muted-foreground">
1120
+
<span className="font-mono">SHA256: 637e325d9668ca745e01493d80dfc72447ef0a889b313e28913ca65c94c7aaae</span>
1121
+
</div>
1122
+
</div>
1123
+
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
1124
+
<a
1125
+
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-aarch64-linux"
1126
+
target="_blank"
1127
+
rel="noopener noreferrer"
1128
+
className="flex items-center justify-between mb-2"
1129
+
>
1130
+
<span className="font-mono text-sm">Linux (ARM64)</span>
1131
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
1132
+
</a>
1133
+
<div className="text-xs text-muted-foreground">
1134
+
<span className="font-mono">SHA256: 01561656b64826f95b39f13c65c97da8bcc63ecd9f4d7e4e369c8ba8c903c22a</span>
1135
+
</div>
1136
+
</div>
1137
+
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
1138
+
<a
1139
+
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux"
1140
+
target="_blank"
1141
+
rel="noopener noreferrer"
1142
+
className="flex items-center justify-between mb-2"
1143
+
>
1144
+
<span className="font-mono text-sm">Linux (x86_64)</span>
1145
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
1146
+
</a>
1147
+
<div className="text-xs text-muted-foreground">
1148
+
<span className="font-mono">SHA256: 1ff485b9bcf89bc5721a862863c4843cf4530cbcd2489cf200cb24a44f7865a2</span>
1149
+
</div>
1150
+
</div>
1151
+
</div>
1152
+
</div>
1153
+
1154
+
<div className="space-y-3">
1155
+
<h3 className="text-sm font-semibold">Basic Usage</h3>
1156
+
<CodeBlock
1157
+
code={`# Download and make executable
1158
+
curl -O https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-macos-arm64
1159
+
chmod +x wisp-cli-macos-arm64
1160
+
1161
+
# Deploy your site (will use OAuth)
1162
+
./wisp-cli-macos-arm64 your-handle.bsky.social \\
1163
+
--path ./dist \\
1164
+
--site my-site
1165
+
1166
+
# Your site will be available at:
1167
+
# https://sites.wisp.place/your-handle/my-site`}
1168
+
language="bash"
1169
+
/>
1170
+
</div>
1171
+
1172
+
<div className="space-y-3">
1173
+
<h3 className="text-sm font-semibold">CI/CD with Tangled Spindle</h3>
1174
+
<p className="text-xs text-muted-foreground">
1175
+
Deploy automatically on every push using{' '}
1176
+
<a
1177
+
href="https://blog.tangled.org/ci"
1178
+
target="_blank"
1179
+
rel="noopener noreferrer"
1180
+
className="text-accent hover:underline"
1181
+
>
1182
+
Tangled Spindle
1183
+
</a>
1184
+
</p>
1185
+
1186
+
<div className="space-y-4">
1187
+
<div>
1188
+
<h4 className="text-xs font-semibold mb-2 flex items-center gap-2">
1189
+
<span>Example 1: Simple Asset Publishing</span>
1190
+
<Badge variant="secondary" className="text-xs">Copy Files</Badge>
1191
+
</h4>
1192
+
<CodeBlock
1193
+
code={`when:
1194
+
- event: ['push']
1195
+
branch: ['main']
1196
+
- event: ['manual']
1197
+
1198
+
engine: 'nixery'
1199
+
1200
+
clone:
1201
+
skip: false
1202
+
depth: 1
1203
+
1204
+
dependencies:
1205
+
nixpkgs:
1206
+
- coreutils
1207
+
- curl
1208
+
1209
+
environment:
1210
+
SITE_PATH: '.' # Copy entire repo
1211
+
SITE_NAME: 'myWebbedSite'
1212
+
WISP_HANDLE: 'your-handle.bsky.social'
1213
+
1214
+
steps:
1215
+
- name: deploy assets to wisp
1216
+
command: |
1217
+
# Download Wisp CLI
1218
+
curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli
1219
+
chmod +x wisp-cli
1220
+
1221
+
# Deploy to Wisp
1222
+
./wisp-cli \\
1223
+
"$WISP_HANDLE" \\
1224
+
--path "$SITE_PATH" \\
1225
+
--site "$SITE_NAME" \\
1226
+
--password "$WISP_APP_PASSWORD"
1227
+
1228
+
# Output
1229
+
#Deployed site 'myWebbedSite': at://did:plc:ttdrpj45ibqunmfhdsb4zdwq/place.wisp.fs/myWebbedSite
1230
+
#Available at: https://sites.wisp.place/did:plc:ttdrpj45ibqunmfhdsb4zdwq/myWebbedSite
1231
+
`}
1232
+
language="yaml"
1233
+
/>
1234
+
</div>
1235
+
1236
+
<div>
1237
+
<h4 className="text-xs font-semibold mb-2 flex items-center gap-2">
1238
+
<span>Example 2: React/Vite Build & Deploy</span>
1239
+
<Badge variant="secondary" className="text-xs">Full Build</Badge>
1240
+
</h4>
1241
+
<CodeBlock
1242
+
code={`when:
1243
+
- event: ['push']
1244
+
branch: ['main']
1245
+
- event: ['manual']
1246
+
1247
+
engine: 'nixery'
1248
+
1249
+
clone:
1250
+
skip: false
1251
+
depth: 1
1252
+
submodules: false
1253
+
1254
+
dependencies:
1255
+
nixpkgs:
1256
+
- nodejs
1257
+
- coreutils
1258
+
- curl
1259
+
github:NixOS/nixpkgs/nixpkgs-unstable:
1260
+
- bun
1261
+
1262
+
environment:
1263
+
SITE_PATH: 'dist'
1264
+
SITE_NAME: 'my-react-site'
1265
+
WISP_HANDLE: 'your-handle.bsky.social'
1266
+
1267
+
steps:
1268
+
- name: build site
1269
+
command: |
1270
+
# necessary to ensure bun is in PATH
1271
+
export PATH="$HOME/.nix-profile/bin:$PATH"
1272
+
1273
+
bun install --frozen-lockfile
1274
+
1275
+
# build with vite, run directly to get around env issues
1276
+
bun node_modules/.bin/vite build
1277
+
1278
+
- name: deploy to wisp
1279
+
command: |
1280
+
# Download Wisp CLI
1281
+
curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli
1282
+
chmod +x wisp-cli
1283
+
1284
+
# Deploy to Wisp
1285
+
./wisp-cli \\
1286
+
"$WISP_HANDLE" \\
1287
+
--path "$SITE_PATH" \\
1288
+
--site "$SITE_NAME" \\
1289
+
--password "$WISP_APP_PASSWORD"`}
1290
+
language="yaml"
1291
+
/>
1292
+
</div>
1293
+
</div>
1294
+
1295
+
<div className="p-3 bg-muted/30 rounded-lg border-l-4 border-accent">
1296
+
<p className="text-xs text-muted-foreground">
1297
+
<strong className="text-foreground">Note:</strong> Set <code className="px-1.5 py-0.5 bg-background rounded text-xs">WISP_APP_PASSWORD</code> as a secret in your Tangled Spindle repository settings.
1298
+
Generate an app password from your AT Protocol account settings.
1299
+
</p>
1300
+
</div>
1301
+
</div>
1302
+
1303
+
<div className="space-y-3">
1304
+
<h3 className="text-sm font-semibold">Learn More</h3>
1305
+
<div className="grid gap-2">
1306
+
<a
1307
+
href="https://tangled.org/@nekomimi.pet/wisp.place-monorepo/tree/main/cli"
1308
+
target="_blank"
1309
+
rel="noopener noreferrer"
1310
+
className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"
1311
+
>
1312
+
<span className="text-sm">Source Code</span>
1313
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
1314
+
</a>
1315
+
<a
1316
+
href="https://blog.tangled.org/ci"
1317
+
target="_blank"
1318
+
rel="noopener noreferrer"
1319
+
className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"
1320
+
>
1321
+
<span className="text-sm">Tangled Spindle CI/CD</span>
1322
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
1323
+
</a>
1324
+
</div>
1325
+
</div>
1077
1326
</CardContent>
1078
1327
</Card>
1079
1328
</TabsContent>
+24
public/layouts/index.tsx
+24
public/layouts/index.tsx
···
1
1
import type { PropsWithChildren } from 'react'
2
+
import { useEffect } from 'react'
2
3
3
4
import { QueryClientProvider, QueryClient } from '@tanstack/react-query'
4
5
import clsx from 'clsx'
···
12
13
}
13
14
14
15
export default function Layout({ children, className }: LayoutProps) {
16
+
useEffect(() => {
17
+
// Function to update dark mode based on system preference
18
+
const updateDarkMode = (e: MediaQueryList | MediaQueryListEvent) => {
19
+
if (e.matches) {
20
+
document.documentElement.classList.add('dark')
21
+
} else {
22
+
document.documentElement.classList.remove('dark')
23
+
}
24
+
}
25
+
26
+
// Create media query
27
+
const darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)')
28
+
29
+
// Set initial value
30
+
updateDarkMode(darkModeQuery)
31
+
32
+
// Listen for changes
33
+
darkModeQuery.addEventListener('change', updateDarkMode)
34
+
35
+
// Cleanup
36
+
return () => darkModeQuery.removeEventListener('change', updateDarkMode)
37
+
}, [])
38
+
15
39
return (
16
40
<QueryClientProvider client={client}>
17
41
<div
+62
-36
public/styles/global.css
+62
-36
public/styles/global.css
···
1
1
@import "tailwindcss";
2
2
@import "tw-animate-css";
3
3
4
-
@custom-variant dark (&:is(.dark *));
4
+
@custom-variant dark (@media (prefers-color-scheme: dark));
5
5
6
6
:root {
7
+
color-scheme: light;
8
+
7
9
/* Warm beige background inspired by Sunset design #E9DDD8 */
8
10
--background: oklch(0.90 0.012 35);
9
11
/* Very dark brown text for strong contrast #2A2420 */
···
57
59
}
58
60
59
61
.dark {
60
-
/* #413C58 - violet background for dark mode */
61
-
--background: oklch(0.28 0.04 285);
62
-
/* #F2E7C9 - parchment text */
63
-
--foreground: oklch(0.93 0.03 85);
62
+
color-scheme: dark;
64
63
65
-
--card: oklch(0.32 0.04 285);
66
-
--card-foreground: oklch(0.93 0.03 85);
64
+
/* Slate violet background - #2C2C2C with violet tint */
65
+
--background: oklch(0.23 0.015 285);
66
+
/* Light gray text - #E4E4E4 */
67
+
--foreground: oklch(0.90 0.005 285);
67
68
68
-
--popover: oklch(0.32 0.04 285);
69
-
--popover-foreground: oklch(0.93 0.03 85);
69
+
/* Slightly lighter slate for cards */
70
+
--card: oklch(0.28 0.015 285);
71
+
--card-foreground: oklch(0.90 0.005 285);
70
72
71
-
/* #FFAAD2 - pink primary in dark mode */
72
-
--primary: oklch(0.78 0.15 345);
73
-
--primary-foreground: oklch(0.32 0.04 285);
73
+
--popover: oklch(0.28 0.015 285);
74
+
--popover-foreground: oklch(0.90 0.005 285);
74
75
75
-
--accent: oklch(0.78 0.15 345);
76
-
--accent-foreground: oklch(0.32 0.04 285);
76
+
/* Lavender buttons - #B39CD0 */
77
+
--primary: oklch(0.70 0.10 295);
78
+
--primary-foreground: oklch(0.23 0.015 285);
77
79
78
-
--secondary: oklch(0.56 0.08 220);
79
-
--secondary-foreground: oklch(0.93 0.03 85);
80
+
/* Soft pink accent - #FFC1CC */
81
+
--accent: oklch(0.85 0.08 5);
82
+
--accent-foreground: oklch(0.23 0.015 285);
80
83
81
-
--muted: oklch(0.38 0.03 285);
82
-
--muted-foreground: oklch(0.75 0.02 85);
84
+
/* Light cyan secondary - #A8DADC */
85
+
--secondary: oklch(0.82 0.05 200);
86
+
--secondary-foreground: oklch(0.23 0.015 285);
83
87
84
-
--border: oklch(0.42 0.03 285);
85
-
--input: oklch(0.42 0.03 285);
86
-
--ring: oklch(0.78 0.15 345);
88
+
/* Muted slate areas */
89
+
--muted: oklch(0.33 0.015 285);
90
+
--muted-foreground: oklch(0.72 0.01 285);
87
91
88
-
--destructive: oklch(0.577 0.245 27.325);
89
-
--destructive-foreground: oklch(0.985 0 0);
92
+
/* Subtle borders */
93
+
--border: oklch(0.38 0.02 285);
94
+
--input: oklch(0.30 0.015 285);
95
+
--ring: oklch(0.70 0.10 295);
90
96
91
-
--chart-1: oklch(0.78 0.15 345);
92
-
--chart-2: oklch(0.93 0.03 85);
93
-
--chart-3: oklch(0.56 0.08 220);
94
-
--chart-4: oklch(0.85 0.02 130);
95
-
--chart-5: oklch(0.32 0.04 285);
96
-
--sidebar: oklch(0.205 0 0);
97
-
--sidebar-foreground: oklch(0.985 0 0);
98
-
--sidebar-primary: oklch(0.488 0.243 264.376);
99
-
--sidebar-primary-foreground: oklch(0.985 0 0);
100
-
--sidebar-accent: oklch(0.269 0 0);
101
-
--sidebar-accent-foreground: oklch(0.985 0 0);
102
-
--sidebar-border: oklch(0.269 0 0);
103
-
--sidebar-ring: oklch(0.439 0 0);
97
+
/* Warm destructive color */
98
+
--destructive: oklch(0.60 0.22 27);
99
+
--destructive-foreground: oklch(0.98 0.01 85);
100
+
101
+
/* Chart colors using the accent palette */
102
+
--chart-1: oklch(0.85 0.08 5);
103
+
--chart-2: oklch(0.82 0.05 200);
104
+
--chart-3: oklch(0.70 0.10 295);
105
+
--chart-4: oklch(0.75 0.08 340);
106
+
--chart-5: oklch(0.65 0.08 180);
107
+
108
+
/* Sidebar slate */
109
+
--sidebar: oklch(0.20 0.015 285);
110
+
--sidebar-foreground: oklch(0.90 0.005 285);
111
+
--sidebar-primary: oklch(0.70 0.10 295);
112
+
--sidebar-primary-foreground: oklch(0.20 0.015 285);
113
+
--sidebar-accent: oklch(0.28 0.015 285);
114
+
--sidebar-accent-foreground: oklch(0.90 0.005 285);
115
+
--sidebar-border: oklch(0.32 0.02 285);
116
+
--sidebar-ring: oklch(0.70 0.10 295);
104
117
}
105
118
106
119
@theme inline {
···
164
177
.arrow-animate {
165
178
animation: arrow-bounce 1.5s ease-in-out infinite;
166
179
}
180
+
181
+
/* Shiki syntax highlighting styles */
182
+
.shiki-wrapper {
183
+
border-radius: 0.5rem;
184
+
padding: 1rem;
185
+
overflow-x: auto;
186
+
border: 1px solid hsl(var(--border));
187
+
}
188
+
189
+
.shiki-wrapper pre {
190
+
margin: 0 !important;
191
+
padding: 0 !important;
192
+
}
+16
-8
src/index.ts
+16
-8
src/index.ts
···
1
1
import { Elysia } from 'elysia'
2
+
import type { Context } from 'elysia'
2
3
import { cors } from '@elysiajs/cors'
3
-
import { openapi, fromTypes } from '@elysiajs/openapi'
4
4
import { staticPlugin } from '@elysiajs/static'
5
5
6
6
import type { Config } from './lib/types'
···
58
58
dnsVerifier.start()
59
59
logger.info('DNS Verifier Started - checking custom domains every 10 minutes')
60
60
61
-
export const app = new Elysia()
62
-
.use(openapi({
63
-
references: fromTypes()
64
-
}))
61
+
export const app = new Elysia({
62
+
serve: {
63
+
maxPayloadLength: 1024 * 1024 * 128 * 3,
64
+
development: Bun.env.NODE_ENV !== 'production' ? true : false,
65
+
id: Bun.env.NODE_ENV !== 'production' ? undefined : null,
66
+
}
67
+
})
65
68
// Observability middleware
66
69
.onBeforeHandle(observabilityMiddleware('main-app').beforeHandle)
67
-
.onAfterHandle((ctx) => {
70
+
.onAfterHandle((ctx: Context) => {
68
71
observabilityMiddleware('main-app').afterHandle(ctx)
69
72
// Security headers middleware
70
73
const { set } = ctx
···
104
107
prefix: '/'
105
108
})
106
109
)
107
-
.get('/client-metadata.json', (c) => {
110
+
.get('/client-metadata.json', () => {
108
111
return createClientMetadata(config)
109
112
})
110
-
.get('/jwks.json', async (c) => {
113
+
.get('/jwks.json', async () => {
111
114
const keys = await getCurrentKeys()
112
115
if (!keys.length) return { keys: [] }
113
116
···
143
146
error: error instanceof Error ? error.message : String(error)
144
147
}
145
148
}
149
+
})
150
+
.get('/.well-known/atproto-did', ({ set }) => {
151
+
// Return plain text DID for AT Protocol domain verification
152
+
set.headers['Content-Type'] = 'text/plain'
153
+
return 'did:plc:7puq73yz2hkvbcpdhnsze2qw'
146
154
})
147
155
.use(cors({
148
156
origin: config.domain,
+81
src/lib/csrf.test.ts
+81
src/lib/csrf.test.ts
···
1
+
import { describe, test, expect } from 'bun:test'
2
+
import { verifyRequestOrigin } from './csrf'
3
+
4
+
describe('verifyRequestOrigin', () => {
5
+
test('should accept matching origin and host', () => {
6
+
expect(verifyRequestOrigin('https://example.com', ['example.com'])).toBe(true)
7
+
expect(verifyRequestOrigin('http://localhost:8000', ['localhost:8000'])).toBe(true)
8
+
expect(verifyRequestOrigin('https://app.example.com', ['app.example.com'])).toBe(true)
9
+
})
10
+
11
+
test('should accept origin matching one of multiple allowed hosts', () => {
12
+
const allowedHosts = ['example.com', 'app.example.com', 'localhost:8000']
13
+
expect(verifyRequestOrigin('https://example.com', allowedHosts)).toBe(true)
14
+
expect(verifyRequestOrigin('https://app.example.com', allowedHosts)).toBe(true)
15
+
expect(verifyRequestOrigin('http://localhost:8000', allowedHosts)).toBe(true)
16
+
})
17
+
18
+
test('should reject non-matching origin', () => {
19
+
expect(verifyRequestOrigin('https://evil.com', ['example.com'])).toBe(false)
20
+
expect(verifyRequestOrigin('https://fake-example.com', ['example.com'])).toBe(false)
21
+
expect(verifyRequestOrigin('https://example.com.evil.com', ['example.com'])).toBe(false)
22
+
})
23
+
24
+
test('should reject empty origin', () => {
25
+
expect(verifyRequestOrigin('', ['example.com'])).toBe(false)
26
+
})
27
+
28
+
test('should reject invalid URL format', () => {
29
+
expect(verifyRequestOrigin('not-a-url', ['example.com'])).toBe(false)
30
+
expect(verifyRequestOrigin('javascript:alert(1)', ['example.com'])).toBe(false)
31
+
expect(verifyRequestOrigin('file:///etc/passwd', ['example.com'])).toBe(false)
32
+
})
33
+
34
+
test('should handle different protocols correctly', () => {
35
+
// Same host, different protocols should match (we only check host)
36
+
expect(verifyRequestOrigin('http://example.com', ['example.com'])).toBe(true)
37
+
expect(verifyRequestOrigin('https://example.com', ['example.com'])).toBe(true)
38
+
})
39
+
40
+
test('should handle port numbers correctly', () => {
41
+
expect(verifyRequestOrigin('http://localhost:3000', ['localhost:3000'])).toBe(true)
42
+
expect(verifyRequestOrigin('http://localhost:3000', ['localhost:8000'])).toBe(false)
43
+
expect(verifyRequestOrigin('http://localhost', ['localhost'])).toBe(true)
44
+
})
45
+
46
+
test('should handle subdomains correctly', () => {
47
+
expect(verifyRequestOrigin('https://sub.example.com', ['sub.example.com'])).toBe(true)
48
+
expect(verifyRequestOrigin('https://sub.example.com', ['example.com'])).toBe(false)
49
+
})
50
+
51
+
test('should handle case sensitivity (exact match required)', () => {
52
+
// URL host is automatically lowercased by URL parser
53
+
expect(verifyRequestOrigin('https://EXAMPLE.COM', ['example.com'])).toBe(true)
54
+
expect(verifyRequestOrigin('https://example.com', ['example.com'])).toBe(true)
55
+
// But allowed hosts are case-sensitive
56
+
expect(verifyRequestOrigin('https://example.com', ['EXAMPLE.COM'])).toBe(false)
57
+
})
58
+
59
+
test('should handle trailing slashes in origin', () => {
60
+
expect(verifyRequestOrigin('https://example.com/', ['example.com'])).toBe(true)
61
+
})
62
+
63
+
test('should handle paths in origin (host extraction)', () => {
64
+
expect(verifyRequestOrigin('https://example.com/path/to/page', ['example.com'])).toBe(true)
65
+
expect(verifyRequestOrigin('https://evil.com/example.com', ['example.com'])).toBe(false)
66
+
})
67
+
68
+
test('should reject when allowed hosts is empty', () => {
69
+
expect(verifyRequestOrigin('https://example.com', [])).toBe(false)
70
+
})
71
+
72
+
test('should handle IPv4 addresses', () => {
73
+
expect(verifyRequestOrigin('http://127.0.0.1:8000', ['127.0.0.1:8000'])).toBe(true)
74
+
expect(verifyRequestOrigin('http://192.168.1.1', ['192.168.1.1'])).toBe(true)
75
+
})
76
+
77
+
test('should handle IPv6 addresses', () => {
78
+
expect(verifyRequestOrigin('http://[::1]:8000', ['[::1]:8000'])).toBe(true)
79
+
expect(verifyRequestOrigin('http://[2001:db8::1]', ['[2001:db8::1]'])).toBe(true)
80
+
})
81
+
})
+35
-15
src/lib/dns-verification-worker.ts
+35
-15
src/lib/dns-verification-worker.ts
···
71
71
};
72
72
73
73
try {
74
-
// Get all verified custom domains
75
-
const domains = await db`
76
-
SELECT id, domain, did FROM custom_domains WHERE verified = true
74
+
// Get all custom domains (both verified and pending)
75
+
const domains = await db<Array<{
76
+
id: string;
77
+
domain: string;
78
+
did: string;
79
+
verified: boolean;
80
+
}>>`
81
+
SELECT id, domain, did, verified FROM custom_domains
77
82
`;
78
83
79
84
if (!domains || domains.length === 0) {
80
-
this.log('No verified custom domains to check');
85
+
this.log('No custom domains to check');
81
86
this.lastRunTime = Date.now();
82
87
return;
83
88
}
84
89
85
-
this.log(`Checking ${domains.length} verified custom domains`);
90
+
const verifiedCount = domains.filter(d => d.verified).length;
91
+
const pendingCount = domains.filter(d => !d.verified).length;
92
+
this.log(`Checking ${domains.length} custom domains (${verifiedCount} verified, ${pendingCount} pending)`);
86
93
87
94
// Verify each domain
88
95
for (const row of domains) {
89
96
runStats.totalChecked++;
90
-
const { id, domain, did } = row;
97
+
const { id, domain, did, verified: wasVerified } = row;
91
98
92
99
try {
93
100
// Extract hash from id (SHA256 of did:domain)
···
97
104
const result = await verifyCustomDomain(domain, did, expectedHash);
98
105
99
106
if (result.verified) {
100
-
// Update last_verified_at timestamp
107
+
// Update verified status and last_verified_at timestamp
101
108
await db`
102
109
UPDATE custom_domains
103
-
SET last_verified_at = EXTRACT(EPOCH FROM NOW())
110
+
SET verified = true,
111
+
last_verified_at = EXTRACT(EPOCH FROM NOW())
104
112
WHERE id = ${id}
105
113
`;
106
114
runStats.verified++;
107
-
this.log(`Domain verified: ${domain}`, { did });
115
+
if (!wasVerified) {
116
+
this.log(`Domain newly verified: ${domain}`, { did });
117
+
} else {
118
+
this.log(`Domain re-verified: ${domain}`, { did });
119
+
}
108
120
} else {
109
-
// Mark domain as unverified
121
+
// Mark domain as unverified or keep it pending
110
122
await db`
111
123
UPDATE custom_domains
112
124
SET verified = false,
···
114
126
WHERE id = ${id}
115
127
`;
116
128
runStats.failed++;
117
-
this.log(`Domain verification failed: ${domain}`, {
118
-
did,
119
-
error: result.error,
120
-
found: result.found,
121
-
});
129
+
if (wasVerified) {
130
+
this.log(`Domain verification failed (was verified): ${domain}`, {
131
+
did,
132
+
error: result.error,
133
+
found: result.found,
134
+
});
135
+
} else {
136
+
this.log(`Domain still pending: ${domain}`, {
137
+
did,
138
+
error: result.error,
139
+
found: result.found,
140
+
});
141
+
}
122
142
}
123
143
} catch (error) {
124
144
runStats.errors++;
+10
-6
src/lib/observability.ts
+10
-6
src/lib/observability.ts
···
312
312
service
313
313
)
314
314
315
-
logCollector.error(
316
-
`Request failed: ${request.method} ${url.pathname}`,
317
-
service,
318
-
error,
319
-
{ statusCode: set.status || 500 }
320
-
)
315
+
// Don't log 404 errors
316
+
const statusCode = set.status || 500
317
+
if (statusCode !== 404) {
318
+
logCollector.error(
319
+
`Request failed: ${request.method} ${url.pathname}`,
320
+
service,
321
+
error,
322
+
{ statusCode }
323
+
)
324
+
}
321
325
}
322
326
}
323
327
}
+639
src/lib/wisp-utils.test.ts
+639
src/lib/wisp-utils.test.ts
···
1
+
import { describe, test, expect } from 'bun:test'
2
+
import {
3
+
shouldCompressFile,
4
+
compressFile,
5
+
processUploadedFiles,
6
+
createManifest,
7
+
updateFileBlobs,
8
+
type UploadedFile,
9
+
type FileUploadResult,
10
+
} from './wisp-utils'
11
+
import type { Directory } from '../lexicons/types/place/wisp/fs'
12
+
import { gunzipSync } from 'zlib'
13
+
import { BlobRef } from '@atproto/api'
14
+
import { CID } from 'multiformats/cid'
15
+
16
+
// Helper function to create a valid CID for testing
17
+
// Using a real valid CID from actual AT Protocol usage
18
+
const TEST_CID_STRING = 'bafkreid7ybejd5s2vv2j7d4aajjlmdgazguemcnuliiyfn6coxpwp2mi6y'
19
+
20
+
function createMockBlobRef(mimeType: string, size: number): BlobRef {
21
+
// Create a properly formatted CID
22
+
const cid = CID.parse(TEST_CID_STRING)
23
+
return new BlobRef(cid, mimeType, size)
24
+
}
25
+
26
+
describe('shouldCompressFile', () => {
27
+
test('should compress HTML files', () => {
28
+
expect(shouldCompressFile('text/html')).toBe(true)
29
+
expect(shouldCompressFile('text/html; charset=utf-8')).toBe(true)
30
+
})
31
+
32
+
test('should compress CSS files', () => {
33
+
expect(shouldCompressFile('text/css')).toBe(true)
34
+
})
35
+
36
+
test('should compress JavaScript files', () => {
37
+
expect(shouldCompressFile('text/javascript')).toBe(true)
38
+
expect(shouldCompressFile('application/javascript')).toBe(true)
39
+
expect(shouldCompressFile('application/x-javascript')).toBe(true)
40
+
})
41
+
42
+
test('should compress JSON files', () => {
43
+
expect(shouldCompressFile('application/json')).toBe(true)
44
+
})
45
+
46
+
test('should compress SVG files', () => {
47
+
expect(shouldCompressFile('image/svg+xml')).toBe(true)
48
+
})
49
+
50
+
test('should compress XML files', () => {
51
+
expect(shouldCompressFile('text/xml')).toBe(true)
52
+
expect(shouldCompressFile('application/xml')).toBe(true)
53
+
})
54
+
55
+
test('should compress plain text files', () => {
56
+
expect(shouldCompressFile('text/plain')).toBe(true)
57
+
})
58
+
59
+
test('should NOT compress images', () => {
60
+
expect(shouldCompressFile('image/png')).toBe(false)
61
+
expect(shouldCompressFile('image/jpeg')).toBe(false)
62
+
expect(shouldCompressFile('image/jpg')).toBe(false)
63
+
expect(shouldCompressFile('image/gif')).toBe(false)
64
+
expect(shouldCompressFile('image/webp')).toBe(false)
65
+
})
66
+
67
+
test('should NOT compress videos', () => {
68
+
expect(shouldCompressFile('video/mp4')).toBe(false)
69
+
expect(shouldCompressFile('video/webm')).toBe(false)
70
+
})
71
+
72
+
test('should NOT compress already compressed formats', () => {
73
+
expect(shouldCompressFile('application/zip')).toBe(false)
74
+
expect(shouldCompressFile('application/gzip')).toBe(false)
75
+
expect(shouldCompressFile('application/pdf')).toBe(false)
76
+
})
77
+
78
+
test('should NOT compress fonts', () => {
79
+
expect(shouldCompressFile('font/woff')).toBe(false)
80
+
expect(shouldCompressFile('font/woff2')).toBe(false)
81
+
expect(shouldCompressFile('font/ttf')).toBe(false)
82
+
})
83
+
})
84
+
85
+
describe('compressFile', () => {
86
+
test('should compress text content', () => {
87
+
const content = Buffer.from('Hello, World! '.repeat(100))
88
+
const compressed = compressFile(content)
89
+
90
+
expect(compressed.length).toBeLessThan(content.length)
91
+
92
+
// Verify we can decompress it back
93
+
const decompressed = gunzipSync(compressed)
94
+
expect(decompressed.toString()).toBe(content.toString())
95
+
})
96
+
97
+
test('should compress HTML content significantly', () => {
98
+
const html = `
99
+
<!DOCTYPE html>
100
+
<html>
101
+
<head><title>Test</title></head>
102
+
<body>
103
+
${'<p>Hello World!</p>\n'.repeat(50)}
104
+
</body>
105
+
</html>
106
+
`
107
+
const content = Buffer.from(html)
108
+
const compressed = compressFile(content)
109
+
110
+
expect(compressed.length).toBeLessThan(content.length)
111
+
112
+
// Verify decompression
113
+
const decompressed = gunzipSync(compressed)
114
+
expect(decompressed.toString()).toBe(html)
115
+
})
116
+
117
+
test('should handle empty content', () => {
118
+
const content = Buffer.from('')
119
+
const compressed = compressFile(content)
120
+
const decompressed = gunzipSync(compressed)
121
+
expect(decompressed.toString()).toBe('')
122
+
})
123
+
124
+
test('should produce deterministic compression', () => {
125
+
const content = Buffer.from('Test content')
126
+
const compressed1 = compressFile(content)
127
+
const compressed2 = compressFile(content)
128
+
129
+
expect(compressed1.toString('base64')).toBe(compressed2.toString('base64'))
130
+
})
131
+
})
132
+
133
+
describe('processUploadedFiles', () => {
134
+
test('should process single root-level file', () => {
135
+
const files: UploadedFile[] = [
136
+
{
137
+
name: 'index.html',
138
+
content: Buffer.from('<html></html>'),
139
+
mimeType: 'text/html',
140
+
size: 13,
141
+
},
142
+
]
143
+
144
+
const result = processUploadedFiles(files)
145
+
146
+
expect(result.fileCount).toBe(1)
147
+
expect(result.directory.type).toBe('directory')
148
+
expect(result.directory.entries).toHaveLength(1)
149
+
expect(result.directory.entries[0].name).toBe('index.html')
150
+
151
+
const node = result.directory.entries[0].node
152
+
expect('blob' in node).toBe(true) // It's a file node
153
+
})
154
+
155
+
test('should process multiple root-level files', () => {
156
+
const files: UploadedFile[] = [
157
+
{
158
+
name: 'index.html',
159
+
content: Buffer.from('<html></html>'),
160
+
mimeType: 'text/html',
161
+
size: 13,
162
+
},
163
+
{
164
+
name: 'styles.css',
165
+
content: Buffer.from('body {}'),
166
+
mimeType: 'text/css',
167
+
size: 7,
168
+
},
169
+
{
170
+
name: 'script.js',
171
+
content: Buffer.from('console.log("hi")'),
172
+
mimeType: 'application/javascript',
173
+
size: 17,
174
+
},
175
+
]
176
+
177
+
const result = processUploadedFiles(files)
178
+
179
+
expect(result.fileCount).toBe(3)
180
+
expect(result.directory.entries).toHaveLength(3)
181
+
182
+
const names = result.directory.entries.map(e => e.name)
183
+
expect(names).toContain('index.html')
184
+
expect(names).toContain('styles.css')
185
+
expect(names).toContain('script.js')
186
+
})
187
+
188
+
test('should process files with subdirectories', () => {
189
+
const files: UploadedFile[] = [
190
+
{
191
+
name: 'dist/index.html',
192
+
content: Buffer.from('<html></html>'),
193
+
mimeType: 'text/html',
194
+
size: 13,
195
+
},
196
+
{
197
+
name: 'dist/css/styles.css',
198
+
content: Buffer.from('body {}'),
199
+
mimeType: 'text/css',
200
+
size: 7,
201
+
},
202
+
{
203
+
name: 'dist/js/app.js',
204
+
content: Buffer.from('console.log()'),
205
+
mimeType: 'application/javascript',
206
+
size: 13,
207
+
},
208
+
]
209
+
210
+
const result = processUploadedFiles(files)
211
+
212
+
expect(result.fileCount).toBe(3)
213
+
expect(result.directory.entries).toHaveLength(3) // index.html, css/, js/
214
+
215
+
// Check root has index.html (after base folder removal)
216
+
const indexEntry = result.directory.entries.find(e => e.name === 'index.html')
217
+
expect(indexEntry).toBeDefined()
218
+
219
+
// Check css directory exists
220
+
const cssDir = result.directory.entries.find(e => e.name === 'css')
221
+
expect(cssDir).toBeDefined()
222
+
expect('entries' in cssDir!.node).toBe(true)
223
+
224
+
if ('entries' in cssDir!.node) {
225
+
expect(cssDir!.node.entries).toHaveLength(1)
226
+
expect(cssDir!.node.entries[0].name).toBe('styles.css')
227
+
}
228
+
229
+
// Check js directory exists
230
+
const jsDir = result.directory.entries.find(e => e.name === 'js')
231
+
expect(jsDir).toBeDefined()
232
+
expect('entries' in jsDir!.node).toBe(true)
233
+
})
234
+
235
+
test('should handle deeply nested subdirectories', () => {
236
+
const files: UploadedFile[] = [
237
+
{
238
+
name: 'dist/deep/nested/folder/file.txt',
239
+
content: Buffer.from('content'),
240
+
mimeType: 'text/plain',
241
+
size: 7,
242
+
},
243
+
]
244
+
245
+
const result = processUploadedFiles(files)
246
+
247
+
expect(result.fileCount).toBe(1)
248
+
249
+
// Navigate through the directory structure (base folder removed)
250
+
const deepDir = result.directory.entries.find(e => e.name === 'deep')
251
+
expect(deepDir).toBeDefined()
252
+
expect('entries' in deepDir!.node).toBe(true)
253
+
254
+
if ('entries' in deepDir!.node) {
255
+
const nestedDir = deepDir!.node.entries.find(e => e.name === 'nested')
256
+
expect(nestedDir).toBeDefined()
257
+
258
+
if (nestedDir && 'entries' in nestedDir.node) {
259
+
const folderDir = nestedDir.node.entries.find(e => e.name === 'folder')
260
+
expect(folderDir).toBeDefined()
261
+
262
+
if (folderDir && 'entries' in folderDir.node) {
263
+
expect(folderDir.node.entries).toHaveLength(1)
264
+
expect(folderDir.node.entries[0].name).toBe('file.txt')
265
+
}
266
+
}
267
+
}
268
+
})
269
+
270
+
test('should remove base folder name from paths', () => {
271
+
const files: UploadedFile[] = [
272
+
{
273
+
name: 'dist/index.html',
274
+
content: Buffer.from('<html></html>'),
275
+
mimeType: 'text/html',
276
+
size: 13,
277
+
},
278
+
{
279
+
name: 'dist/css/styles.css',
280
+
content: Buffer.from('body {}'),
281
+
mimeType: 'text/css',
282
+
size: 7,
283
+
},
284
+
]
285
+
286
+
const result = processUploadedFiles(files)
287
+
288
+
// After removing 'dist/', we should have index.html and css/ at root
289
+
expect(result.directory.entries.find(e => e.name === 'index.html')).toBeDefined()
290
+
expect(result.directory.entries.find(e => e.name === 'css')).toBeDefined()
291
+
expect(result.directory.entries.find(e => e.name === 'dist')).toBeUndefined()
292
+
})
293
+
294
+
test('should handle empty file list', () => {
295
+
const files: UploadedFile[] = []
296
+
const result = processUploadedFiles(files)
297
+
298
+
expect(result.fileCount).toBe(0)
299
+
expect(result.directory.entries).toHaveLength(0)
300
+
})
301
+
302
+
test('should handle multiple files in same subdirectory', () => {
303
+
const files: UploadedFile[] = [
304
+
{
305
+
name: 'dist/assets/image1.png',
306
+
content: Buffer.from('png1'),
307
+
mimeType: 'image/png',
308
+
size: 4,
309
+
},
310
+
{
311
+
name: 'dist/assets/image2.png',
312
+
content: Buffer.from('png2'),
313
+
mimeType: 'image/png',
314
+
size: 4,
315
+
},
316
+
]
317
+
318
+
const result = processUploadedFiles(files)
319
+
320
+
expect(result.fileCount).toBe(2)
321
+
322
+
const assetsDir = result.directory.entries.find(e => e.name === 'assets')
323
+
expect(assetsDir).toBeDefined()
324
+
325
+
if ('entries' in assetsDir!.node) {
326
+
expect(assetsDir!.node.entries).toHaveLength(2)
327
+
const names = assetsDir!.node.entries.map(e => e.name)
328
+
expect(names).toContain('image1.png')
329
+
expect(names).toContain('image2.png')
330
+
}
331
+
})
332
+
})
333
+
334
+
describe('createManifest', () => {
335
+
test('should create valid manifest', () => {
336
+
const root: Directory = {
337
+
$type: 'place.wisp.fs#directory',
338
+
type: 'directory',
339
+
entries: [],
340
+
}
341
+
342
+
const manifest = createManifest('example.com', root, 0)
343
+
344
+
expect(manifest.$type).toBe('place.wisp.fs')
345
+
expect(manifest.site).toBe('example.com')
346
+
expect(manifest.root).toBe(root)
347
+
expect(manifest.fileCount).toBe(0)
348
+
expect(manifest.createdAt).toBeDefined()
349
+
350
+
// Verify it's a valid ISO date string
351
+
const date = new Date(manifest.createdAt)
352
+
expect(date.toISOString()).toBe(manifest.createdAt)
353
+
})
354
+
355
+
test('should create manifest with file count', () => {
356
+
const root: Directory = {
357
+
$type: 'place.wisp.fs#directory',
358
+
type: 'directory',
359
+
entries: [],
360
+
}
361
+
362
+
const manifest = createManifest('test-site', root, 42)
363
+
364
+
expect(manifest.fileCount).toBe(42)
365
+
expect(manifest.site).toBe('test-site')
366
+
})
367
+
368
+
test('should create manifest with populated directory', () => {
369
+
const mockBlob = createMockBlobRef('text/html', 100)
370
+
371
+
const root: Directory = {
372
+
$type: 'place.wisp.fs#directory',
373
+
type: 'directory',
374
+
entries: [
375
+
{
376
+
name: 'index.html',
377
+
node: {
378
+
$type: 'place.wisp.fs#file',
379
+
type: 'file',
380
+
blob: mockBlob,
381
+
},
382
+
},
383
+
],
384
+
}
385
+
386
+
const manifest = createManifest('populated-site', root, 1)
387
+
388
+
expect(manifest).toBeDefined()
389
+
expect(manifest.site).toBe('populated-site')
390
+
expect(manifest.root.entries).toHaveLength(1)
391
+
})
392
+
})
393
+
394
+
describe('updateFileBlobs', () => {
395
+
test('should update single file blob at root', () => {
396
+
const directory: Directory = {
397
+
$type: 'place.wisp.fs#directory',
398
+
type: 'directory',
399
+
entries: [
400
+
{
401
+
name: 'index.html',
402
+
node: {
403
+
$type: 'place.wisp.fs#file',
404
+
type: 'file',
405
+
blob: undefined as any,
406
+
},
407
+
},
408
+
],
409
+
}
410
+
411
+
const mockBlob = createMockBlobRef('text/html', 100)
412
+
const uploadResults: FileUploadResult[] = [
413
+
{
414
+
hash: TEST_CID_STRING,
415
+
blobRef: mockBlob,
416
+
mimeType: 'text/html',
417
+
},
418
+
]
419
+
420
+
const filePaths = ['index.html']
421
+
422
+
const updated = updateFileBlobs(directory, uploadResults, filePaths)
423
+
424
+
expect(updated.entries).toHaveLength(1)
425
+
const fileNode = updated.entries[0].node
426
+
427
+
if ('blob' in fileNode) {
428
+
expect(fileNode.blob).toBeDefined()
429
+
expect(fileNode.blob.mimeType).toBe('text/html')
430
+
expect(fileNode.blob.size).toBe(100)
431
+
} else {
432
+
throw new Error('Expected file node')
433
+
}
434
+
})
435
+
436
+
test('should update files in nested directories', () => {
437
+
const directory: Directory = {
438
+
$type: 'place.wisp.fs#directory',
439
+
type: 'directory',
440
+
entries: [
441
+
{
442
+
name: 'css',
443
+
node: {
444
+
$type: 'place.wisp.fs#directory',
445
+
type: 'directory',
446
+
entries: [
447
+
{
448
+
name: 'styles.css',
449
+
node: {
450
+
$type: 'place.wisp.fs#file',
451
+
type: 'file',
452
+
blob: undefined as any,
453
+
},
454
+
},
455
+
],
456
+
},
457
+
},
458
+
],
459
+
}
460
+
461
+
const mockBlob = createMockBlobRef('text/css', 50)
462
+
const uploadResults: FileUploadResult[] = [
463
+
{
464
+
hash: TEST_CID_STRING,
465
+
blobRef: mockBlob,
466
+
mimeType: 'text/css',
467
+
encoding: 'gzip',
468
+
},
469
+
]
470
+
471
+
const filePaths = ['css/styles.css']
472
+
473
+
const updated = updateFileBlobs(directory, uploadResults, filePaths)
474
+
475
+
const cssDir = updated.entries[0]
476
+
expect(cssDir.name).toBe('css')
477
+
478
+
if ('entries' in cssDir.node) {
479
+
const cssFile = cssDir.node.entries[0]
480
+
expect(cssFile.name).toBe('styles.css')
481
+
482
+
if ('blob' in cssFile.node) {
483
+
expect(cssFile.node.blob.mimeType).toBe('text/css')
484
+
if ('encoding' in cssFile.node) {
485
+
expect(cssFile.node.encoding).toBe('gzip')
486
+
}
487
+
} else {
488
+
throw new Error('Expected file node')
489
+
}
490
+
} else {
491
+
throw new Error('Expected directory node')
492
+
}
493
+
})
494
+
495
+
test('should handle normalized paths with base folder removed', () => {
496
+
const directory: Directory = {
497
+
$type: 'place.wisp.fs#directory',
498
+
type: 'directory',
499
+
entries: [
500
+
{
501
+
name: 'index.html',
502
+
node: {
503
+
$type: 'place.wisp.fs#file',
504
+
type: 'file',
505
+
blob: undefined as any,
506
+
},
507
+
},
508
+
],
509
+
}
510
+
511
+
const mockBlob = createMockBlobRef('text/html', 100)
512
+
const uploadResults: FileUploadResult[] = [
513
+
{
514
+
hash: TEST_CID_STRING,
515
+
blobRef: mockBlob,
516
+
},
517
+
]
518
+
519
+
// Path includes base folder that should be normalized
520
+
const filePaths = ['dist/index.html']
521
+
522
+
const updated = updateFileBlobs(directory, uploadResults, filePaths)
523
+
524
+
const fileNode = updated.entries[0].node
525
+
if ('blob' in fileNode) {
526
+
expect(fileNode.blob).toBeDefined()
527
+
} else {
528
+
throw new Error('Expected file node')
529
+
}
530
+
})
531
+
532
+
test('should preserve file metadata (encoding, mimeType, base64)', () => {
533
+
const directory: Directory = {
534
+
$type: 'place.wisp.fs#directory',
535
+
type: 'directory',
536
+
entries: [
537
+
{
538
+
name: 'data.json',
539
+
node: {
540
+
$type: 'place.wisp.fs#file',
541
+
type: 'file',
542
+
blob: undefined as any,
543
+
},
544
+
},
545
+
],
546
+
}
547
+
548
+
const mockBlob = createMockBlobRef('application/json', 200)
549
+
const uploadResults: FileUploadResult[] = [
550
+
{
551
+
hash: TEST_CID_STRING,
552
+
blobRef: mockBlob,
553
+
mimeType: 'application/json',
554
+
encoding: 'gzip',
555
+
base64: true,
556
+
},
557
+
]
558
+
559
+
const filePaths = ['data.json']
560
+
561
+
const updated = updateFileBlobs(directory, uploadResults, filePaths)
562
+
563
+
const fileNode = updated.entries[0].node
564
+
if ('blob' in fileNode && 'mimeType' in fileNode && 'encoding' in fileNode && 'base64' in fileNode) {
565
+
expect(fileNode.mimeType).toBe('application/json')
566
+
expect(fileNode.encoding).toBe('gzip')
567
+
expect(fileNode.base64).toBe(true)
568
+
} else {
569
+
throw new Error('Expected file node with metadata')
570
+
}
571
+
})
572
+
573
+
test('should handle multiple files at different directory levels', () => {
574
+
const directory: Directory = {
575
+
$type: 'place.wisp.fs#directory',
576
+
type: 'directory',
577
+
entries: [
578
+
{
579
+
name: 'index.html',
580
+
node: {
581
+
$type: 'place.wisp.fs#file',
582
+
type: 'file',
583
+
blob: undefined as any,
584
+
},
585
+
},
586
+
{
587
+
name: 'assets',
588
+
node: {
589
+
$type: 'place.wisp.fs#directory',
590
+
type: 'directory',
591
+
entries: [
592
+
{
593
+
name: 'logo.svg',
594
+
node: {
595
+
$type: 'place.wisp.fs#file',
596
+
type: 'file',
597
+
blob: undefined as any,
598
+
},
599
+
},
600
+
],
601
+
},
602
+
},
603
+
],
604
+
}
605
+
606
+
const htmlBlob = createMockBlobRef('text/html', 100)
607
+
const svgBlob = createMockBlobRef('image/svg+xml', 500)
608
+
609
+
const uploadResults: FileUploadResult[] = [
610
+
{
611
+
hash: TEST_CID_STRING,
612
+
blobRef: htmlBlob,
613
+
},
614
+
{
615
+
hash: TEST_CID_STRING,
616
+
blobRef: svgBlob,
617
+
},
618
+
]
619
+
620
+
const filePaths = ['index.html', 'assets/logo.svg']
621
+
622
+
const updated = updateFileBlobs(directory, uploadResults, filePaths)
623
+
624
+
// Check root file
625
+
const indexNode = updated.entries[0].node
626
+
if ('blob' in indexNode) {
627
+
expect(indexNode.blob.mimeType).toBe('text/html')
628
+
}
629
+
630
+
// Check nested file
631
+
const assetsDir = updated.entries[1]
632
+
if ('entries' in assetsDir.node) {
633
+
const logoNode = assetsDir.node.entries[0].node
634
+
if ('blob' in logoNode) {
635
+
expect(logoNode.blob.mimeType).toBe('image/svg+xml')
636
+
}
637
+
}
638
+
})
639
+
})