From dc98a7ab8bf70f7d839bbf27f4ddf31525b09dde Mon Sep 17 00:00:00 2001 From: Robin Berjon Date: Tue, 14 Jul 2026 12:01:37 +0200 Subject: [PATCH] first pass --- .gitignore | 1 + README.md | 108 +++- deno.json | 45 ++ deno.lock | 836 ++++++++++++++++++++++++++++++ examples/hello-tile/index.html | 17 + examples/hello-tile/manifest.json | 5 + examples/hello-tile/style.css | 30 ++ examples/hello-tile/tile.js | 4 + main.ts | 57 ++ src/server/api.ts | 368 +++++++++++++ src/server/oauth.ts | 124 +++++ src/server/paths.ts | 33 ++ src/server/publish.ts | 116 +++++ src/server/state.ts | 90 ++++ src/server/tiledir.ts | 92 ++++ src/server/tileserver.ts | 39 ++ src/server/watcher.ts | 26 + src/server/windows.ts | 51 ++ src/types.ts | 43 ++ src/ui/api.ts | 45 ++ src/ui/app.ts | 296 +++++++++++ src/ui/mothership.ts | 31 ++ src/ui/store.ts | 176 +++++++ src/ui/tile-card.ts | 161 ++++++ src/ui/tile-frame.ts | 109 ++++ static/index.html | 51 ++ 26 files changed, 2953 insertions(+), 1 deletion(-) create mode 100644 deno.json create mode 100644 deno.lock create mode 100644 examples/hello-tile/index.html create mode 100644 examples/hello-tile/manifest.json create mode 100644 examples/hello-tile/style.css create mode 100644 examples/hello-tile/tile.js create mode 100644 main.ts create mode 100644 src/server/api.ts create mode 100644 src/server/oauth.ts create mode 100644 src/server/paths.ts create mode 100644 src/server/publish.ts create mode 100644 src/server/state.ts create mode 100644 src/server/tiledir.ts create mode 100644 src/server/tileserver.ts create mode 100644 src/server/watcher.ts create mode 100644 src/server/windows.ts create mode 100644 src/types.ts create mode 100644 src/ui/api.ts create mode 100644 src/ui/app.ts create mode 100644 src/ui/mothership.ts create mode 100644 src/ui/store.ts create mode 100644 src/ui/tile-card.ts create mode 100644 src/ui/tile-frame.ts create mode 100644 static/index.html diff --git a/.gitignore b/.gitignore index e46e550..423b058 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ dist/ build/ out/ *.tsbuildinfo +static/app.js # Coverage / test output coverage/ diff --git a/README.md b/README.md index 8324b93..a9b3238 100644 --- a/README.md +++ b/README.md @@ -1 +1,107 @@ -# blastile +# ⬢ Blastile + +A [Deno Desktop](https://docs.deno.com/runtime/desktop/) app for working with +[DASL web tiles](https://dasl.ing/tiles.html): open them from `.tile` CAR files +or `at://` URIs, live-preview a tile source directory with reload-on-change, +and publish tiles to the AT Protocol over OAuth. + +Built with [Lit](https://lit.dev/) + [refrakt](https://www.npmjs.com/package/refrakt) +for the UI, the [`@dasl/tiles`](https://github.com/darobin/dasl-tiles) libraries +for everything tile-shaped, and [atcute](https://github.com/mary-ext/atcute) for +AT Protocol (XRPC + OAuth). + +## What it does + +- **Open tiles from CAR files** — pick a `.tile` with the native file dialog, + or drop one anywhere on the window. +- **Open tiles from AT** — paste an `at://` URI (`did:` or handle authority; + handles are resolved for you). Records live in the `ing.dasl.masl` + collection. +- **Watch a tile source directory** — point it at a directory with a + `manifest.json` + `index.html` (the same layout `atile` publishes). The tile + renders live and reloads whenever anything in the directory changes; the CAR + is rebuilt on the fly with `@dasl/tile-writer`. +- **Publish to AT** — log in with AT OAuth (as a spec-compliant loopback + public client, no hosted client metadata needed), then publish a watched + tile: every resource is uploaded as a blob (CIDs verified against the PDS), + and an `ing.dasl.masl` record is written. The `at://` URI each directory was + published to is remembered in `~/.atile/identifiers.json` — the same stable-id + store the `atile` CLI uses — so republishing updates the same record, from + either tool. + +## Tile isolation + +Every rendered tile gets a **fresh random `<20-letters>.localhost` origin**, +served by an embedded [`@dasl/tile-server`](https://www.npmjs.com/package/@dasl/tile-server) +on a dynamic port. The client uses `@dasl/tile-loader`'s +mothership/shuttle/worker architecture: the shuttle + service worker load from +the random origin with the full web-tiles CSP, and all resource loading is +mediated by the mothership in the app — tiles never touch the network. + +Two local adaptations (the stock pieces assume `https://` on port 443): + +- the mothership subclass mints `http://.localhost:/…` load + sources itself (`src/ui/mothership.ts`); +- a port-preserving redirect sits in front of the stock `load.localhost` + redirect (`src/server/tileserver.ts`). + +`*.localhost` resolves to loopback and counts as a secure context in Chromium, +which is why the desktop build uses the CEF backend (`"backend": "cef"` in +`deno.json`) rather than the platform webview. + +## Running it + +Requires Deno ≥ 2.9 (for `deno desktop`). + +```sh +deno install # fetch dependencies + +deno task desktop # dev: bundle UI + run as a desktop app with HMR +deno task dev # dev without a window: serves http://127.0.0.1:4179 + # for a regular (Chromium) browser +deno task build # compile dist/Blastile.app +``` + +There's a sample tile in `examples/hello-tile/` — add it with +**Watch tile dir…** and start editing. For an AT-hosted example, try +Minesweeper: + +``` +at://did:plc:izttpdp3l6vss5crelt5kcux/ing.dasl.masl/3mcjwwoqjqs2v +``` + +## How it hangs together + +``` +main.ts Deno.serve on 127.0.0.1 (desktop-assigned port) +├── src/server/api.ts static UI, /api/* + WebSocket events, OAuth callback +├── src/server/tileserver.ts express + @dasl/tile-server on *.localhost: +├── src/server/tiledir.ts tile dir → MASL manifest / .tile CAR (atile-compatible) +├── src/server/watcher.ts Deno.watchFs → debounced tile-changed events +├── src/server/oauth.ts @atcute/oauth-node-client loopback client, +│ file session store in ~/.blastile +├── src/server/publish.ts uploadBlob + putRecord (ing.dasl.masl), TID rkeys, +│ stable ids shared with atile +└── src/ui/… Lit + refrakt; tile rendering via @dasl/tile-loader +``` + +The backend owns all state (tile registry in `~/.blastile/state.json`, +sessions, watchers) and broadcasts changes over a WebSocket; the UI is a thin +refrakt store plus Lit components. Tile viewer windows are plain +`Deno.BrowserWindow`s pointed at `/?view=`; without `deno desktop` +(plain `deno run`) they open as browser windows instead. + +OAuth runs entirely in the Deno process: `POST /api/login` resolves the +handle, runs PAR, and opens the system browser on the PDS's authorize page; +the redirect lands on `http://127.0.0.1:/oauth/callback` (loopback +redirect URIs are matched port-insensitively, so the desktop-assigned port is +fine). Publishing needs the `atproto transition:generic` scope, which is what +the login requests. + +## Smoke test + +With the app running (`deno task dev`) and at least one tile added, loading +`http://127.0.0.1:4179/?smoke[=]` drives a real `` +through load and watch-reload, reporting into `document.title` +(`SMOKE_OK_1: `, then `SMOKE_OK_2` after a watched file changes) — handy +under headless Chromium. diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..15bbb36 --- /dev/null +++ b/deno.json @@ -0,0 +1,45 @@ +{ + "name": "blastile", + "version": "0.1.0", + "exports": "./main.ts", + "nodeModulesDir": "auto", + "desktop": { + "app": { + "name": "Blastile", + "identifier": "com.berjon.blastile" + }, + "backend": "cef" + }, + "tasks": { + "build:ui": "deno bundle --platform browser --output static/app.js src/ui/app.ts", + "dev": "deno task build:ui && deno run -A --watch main.ts", + "desktop": "deno task build:ui && deno desktop --hmr -A --include static main.ts", + "build": "deno task build:ui && deno desktop -A --include static --output dist/Blastile.app main.ts" + }, + "compilerOptions": { + "lib": ["deno.ns", "dom", "dom.iterable", "dom.asynciterable"] + }, + "imports": { + "@std/path": "jsr:@std/path@^1.1.0", + "lit": "npm:lit@^3.3.3", + "@lit-labs/signals": "npm:@lit-labs/signals@^0.3.0", + "refrakt": "npm:refrakt@^1.0.0", + "express": "npm:express@^5.2.1", + "@dasl/tile-server": "npm:@dasl/tile-server@^2.0.0", + "@dasl/tile-loader": "npm:@dasl/tile-loader@^2.0.0", + "@dasl/tile-loader/car": "npm:/@dasl/tile-loader@^2.0.0/car", + "@dasl/tile-loader/at": "npm:/@dasl/tile-loader@^2.0.0/at", + "@dasl/tile-loader/memory": "npm:/@dasl/tile-loader@^2.0.0/memory", + "@dasl/tile-writer": "npm:@dasl/tile-writer@^2.0.0", + "@dasl/tile-lexicon": "npm:@dasl/tile-lexicon@^2.0.0", + "@atcute/client": "npm:@atcute/client@^5.1.1", + "@atcute/oauth-node-client": "npm:@atcute/oauth-node-client@^2.0.1", + "@atcute/identity-resolver": "npm:@atcute/identity-resolver@^2.0.1", + "@atcute/atproto": "npm:@atcute/atproto@^4.0.3", + "@atcute/lexicons": "npm:@atcute/lexicons@^2.0.2", + "@atcute/cid": "npm:@atcute/cid@^2.4.2", + "@atcute/cbor": "npm:@atcute/cbor@^2.3.5", + "file-type": "npm:file-type@^22.0.1", + "mime": "npm:mime@^4.1.0" + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..964ef13 --- /dev/null +++ b/deno.lock @@ -0,0 +1,836 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/internal@^1.0.14": "1.0.14", + "jsr:@std/path@^1.1.0": "1.1.6", + "npm:@atcute/atproto@^4.0.3": "4.0.3_@atcute+lexicons@2.0.2", + "npm:@atcute/cbor@^2.3.5": "2.3.5_@atcute+cid@2.4.2", + "npm:@atcute/cid@^2.4.2": "2.4.2", + "npm:@atcute/client@^5.1.1": "5.1.1_@atcute+lexicons@2.0.2", + "npm:@atcute/identity-resolver@^2.0.1": "2.0.1_@atcute+identity@2.0.1__@atcute+lexicons@2.0.2_@atcute+lexicons@2.0.2", + "npm:@atcute/lexicons@^2.0.2": "2.0.2", + "npm:@atcute/oauth-node-client@^2.0.1": "2.0.1_@atcute+identity-resolver@2.0.1__@atcute+identity@2.0.1___@atcute+lexicons@2.0.2__@atcute+lexicons@2.0.2_@atcute+lexicons@2.0.2", + "npm:@dasl/tile-lexicon@2": "2.0.0", + "npm:@dasl/tile-loader@2": "2.0.0_@atcute+cbor@2.3.5__@atcute+cid@2.4.2", + "npm:@dasl/tile-server@2": "2.0.0", + "npm:@dasl/tile-writer@2": "2.0.0", + "npm:@lit-labs/signals@0.3": "0.3.0", + "npm:@types/express@^5.0.6": "5.0.6", + "npm:express@^5.2.1": "5.2.1", + "npm:file-type@^22.0.1": "22.0.1", + "npm:lit@^3.3.3": "3.3.3", + "npm:mime@^4.1.0": "4.1.0", + "npm:refrakt@1": "1.0.0" + }, + "jsr": { + "@std/internal@1.0.14": { + "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" + }, + "@std/path@1.1.6": { + "integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe", + "dependencies": [ + "jsr:@std/internal" + ] + } + }, + "npm": { + "@atcute/atproto@4.0.3_@atcute+lexicons@2.0.2": { + "integrity": "sha512-BNylfO7nK0yYBpSpnGhOYgrJTeZWrXHPrb6tOQmp9A3Am0epctIWm6/5lPC4ZNPHpUbwr5w/LzH/v7kjAoKEDg==", + "dependencies": [ + "@atcute/lexicons" + ] + }, + "@atcute/car@6.0.2_@atcute+cbor@2.3.5__@atcute+cid@2.4.2_@atcute+cid@2.4.2": { + "integrity": "sha512-6AaLjO0zrFD8R/aK7jwrqHEmLzfVilu/5pv4LAcUjxIYfBrw+nThV2FVLPtA4Jt59tIhjp5tQDP+9sx1eqQp+A==", + "dependencies": [ + "@atcute/cbor", + "@atcute/cid", + "@atcute/uint8array", + "@atcute/varint" + ] + }, + "@atcute/cbor@2.3.5_@atcute+cid@2.4.2": { + "integrity": "sha512-NvgeibMqtfeD6/4NMTnBwhjyB/F945UdnzeZMEsGb2eXSalOBzutgNmpnBPTj5FiN6Sl9VYhE3qDQfyox+P8aw==", + "dependencies": [ + "@atcute/cid", + "@atcute/multibase", + "@atcute/uint8array" + ] + }, + "@atcute/cid@2.4.2": { + "integrity": "sha512-Uy48yfyo/hPQXF+XMXWIGomF6v8IvX5ErjozXMV7rlfU3EV7PSVX3J7plJXV6MRC3iI1z3PgTZTS7V0drCQVVw==", + "dependencies": [ + "@atcute/multibase", + "@atcute/uint8array" + ] + }, + "@atcute/client@5.1.1_@atcute+lexicons@2.0.2": { + "integrity": "sha512-cn5/Zi/qo37WtQG6gzIC7JPs0RDzX9Z4eaceX45SpKgLZoc3fCFDJcE7C8xsbxBNfjry2T6PmUxWA8obebZsEQ==", + "dependencies": [ + "@atcute/identity", + "@atcute/lexicons" + ] + }, + "@atcute/identity-resolver@2.0.1_@atcute+identity@2.0.1__@atcute+lexicons@2.0.2_@atcute+lexicons@2.0.2": { + "integrity": "sha512-0enA9w7XnbbqsZ5Rcl6jXLf7ZZuwFQ9dBmxFq3qOxPHLaCETsqsrQflXDPqiM27TnZwYq8sqCV5D1mFOksggDQ==", + "dependencies": [ + "@atcute/identity", + "@atcute/lexicons", + "@atcute/util-fetch", + "valibot" + ] + }, + "@atcute/identity@2.0.1_@atcute+lexicons@2.0.2": { + "integrity": "sha512-FEURUvl30SyyWWikkvm+MLz0Snuf0OF10L/qxRhWjj6qDB5Ib+XWhiBuwidjvhCkrCepTUNLbj4TlUm/gHaUig==", + "dependencies": [ + "@atcute/lexicons", + "valibot" + ] + }, + "@atcute/lexicons@2.0.2": { + "integrity": "sha512-ATBADJAy4KQ76NB86BjgYKrRdbDRUo76Cbqna4WIfQAgN105Rcy972MiNKs+BSmcOOM3WakilgTm0CXD4RC0iA==", + "dependencies": [ + "@atcute/uint8array", + "@atcute/util-text", + "@standard-schema/spec", + "esm-env" + ] + }, + "@atcute/multibase@1.2.4": { + "integrity": "sha512-WeX12hvFZEim6C+cyv7Eqd93w6DzubNWQGmTFBghjsEuXvMe4HbBCYvsti0OUnbA5qLBPlsTyssQUJeLlHCzIw==", + "dependencies": [ + "@atcute/uint8array" + ] + }, + "@atcute/oauth-crypto@1.0.1": { + "integrity": "sha512-ghC8ceFx0r6pi5X3dNoET1K6PNg8afeAOZLD4PKLhrpVYdRtRrgY4uqAe3ojtSIjoS6GXmVm063CoJ2xtg/MoQ==", + "dependencies": [ + "@atcute/multibase", + "@atcute/uint8array", + "nanoid", + "valibot" + ] + }, + "@atcute/oauth-keyset@0.1.2": { + "integrity": "sha512-2CUazWBWRjSEoYZDaLS4zEPgCOcr9LgnMhPr1pzr9I+DytRnoXmrdbRs4xBVg4iUdXtdzU48DsMaGi6bHXaFcg==", + "dependencies": [ + "@atcute/oauth-crypto" + ] + }, + "@atcute/oauth-node-client@2.0.1_@atcute+identity-resolver@2.0.1__@atcute+identity@2.0.1___@atcute+lexicons@2.0.2__@atcute+lexicons@2.0.2_@atcute+lexicons@2.0.2": { + "integrity": "sha512-MgqN9Rnt5nuUH5zriETtbQrwcx/tWaELKaFVQtqggMtRI4k4IP5+ZbnG40tiJjbZfSclapA2kwOnT14kO0nAxg==", + "dependencies": [ + "@atcute/client", + "@atcute/identity", + "@atcute/identity-resolver", + "@atcute/lexicons", + "@atcute/oauth-crypto", + "@atcute/oauth-keyset", + "@atcute/oauth-types", + "@atcute/util-fetch", + "nanoid", + "valibot" + ] + }, + "@atcute/oauth-types@1.0.1": { + "integrity": "sha512-9uFP/nK0PgPDsaNbLArzrCHvTZO1k04cSEX5jZ75W2Jh7Jxbh/CSTenSrad3CZXuX6y6hDquEtl6H/IqLtTmHw==", + "dependencies": [ + "@atcute/identity", + "@atcute/lexicons", + "@atcute/oauth-keyset", + "valibot" + ] + }, + "@atcute/uint8array@1.1.4": { + "integrity": "sha512-rSW5AFVCIN4ooH7vEZB+J60+uWjn5fRBQAQL58qHLiDm8+xDPmHfEU5GfYOJuuD+7UBj8KKiQugIzohFn8/xPw==" + }, + "@atcute/util-fetch@2.0.1": { + "integrity": "sha512-ugWTOLemA8OxSOj7c8q6ncRmBGFDHSwwE1YinO+PCtaw6WLQFGBfHn+yikQ0e3wTK2t4IPjQ5PxZcRXm961ZVA==", + "dependencies": [ + "valibot" + ] + }, + "@atcute/util-text@1.3.3": { + "integrity": "sha512-WhedTmg/msFhrdwXw9RjnNcDl8Vmisxl4+Vzyf5k3+8Gj5TKQg72dLSDtBNmNLd61RbHjgfQRBgE0ez6q/jciw==", + "dependencies": [ + "unicode-segmenter" + ] + }, + "@atcute/varint@2.0.1": { + "integrity": "sha512-reTtgQ1VPGVRcaTohKVb1tUbpvE7MpguoNS0gjhsxDWFY3t1yqWWvwKK6cJodAmePML36JombvgFwfsblir9Jg==" + }, + "@borewit/text-codec@0.2.2": { + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==" + }, + "@dasl/tile-lexicon@2.0.0": { + "integrity": "sha512-MeRgdv6MUBC5Bd6p8hZ19bRxLURbsSK1dJ6BekzehOWHfhJiIlSztBkFaQRsycRtHBVDATB4KVagb4pkWkUF0Q==" + }, + "@dasl/tile-loader@2.0.0_@atcute+cbor@2.3.5__@atcute+cid@2.4.2": { + "integrity": "sha512-P9M/GUDhN37vXIrB8E4JpbY2BoLRcJevJKAKYUrAZN3/v0dpP1FhYbWPzTeoyn0iT5iHNKqiaqQVspPqZfPL/A==", + "dependencies": [ + "@atcute/car", + "@atcute/cid", + "@dasl/tile-lexicon", + "jszip", + "mime", + "smol-toml" + ] + }, + "@dasl/tile-server@2.0.0": { + "integrity": "sha512-C9kh5sbS8WWNwJP4Q0h3SSzKX8GU1A7FWL84M0Q9Ol5ielw2/C0F6tHjH4ZsJbPgxdffwq9gxTpR5mNmWj8O4g==", + "dependencies": [ + "express", + "nanoid" + ], + "bin": true + }, + "@dasl/tile-writer@2.0.0": { + "integrity": "sha512-8PBONSoJCgtGWjkdFQzNkILWBSPU5tSHVxgpGFTPU0vH4wjL7jQLiqxyftchSsjOwGEi9ruPG3hWniCaijpXDw==", + "dependencies": [ + "@atcute/cbor", + "@atcute/cid", + "@atcute/varint", + "@dasl/tile-lexicon", + "tmp-promise" + ] + }, + "@lit-labs/signals@0.3.0": { + "integrity": "sha512-PfB5FLtorVfkhwTWXOPNT/rvrVdSDY8siwPyR17ztrViRbMqxy1TQBcHqJoA4303QT8yXPGxYtGKJGj7fEfEwg==", + "dependencies": [ + "lit", + "signal-polyfill" + ] + }, + "@lit-labs/ssr-dom-shim@1.6.0": { + "integrity": "sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==" + }, + "@lit/reactive-element@2.1.2": { + "integrity": "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==", + "dependencies": [ + "@lit-labs/ssr-dom-shim" + ] + }, + "@standard-schema/spec@1.1.0": { + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" + }, + "@tokenizer/inflate@0.4.1": { + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "dependencies": [ + "debug", + "token-types" + ] + }, + "@tokenizer/token@0.3.0": { + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==" + }, + "@types/body-parser@1.19.6": { + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dependencies": [ + "@types/connect", + "@types/node" + ] + }, + "@types/connect@3.4.38": { + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dependencies": [ + "@types/node" + ] + }, + "@types/express-serve-static-core@5.1.2": { + "integrity": "sha512-d3KvEXBSo/lOAMc2u6fkyDHBvetBHeqD7wm/AcXfLpSOQwlmG9D/aQ0SFswVjv05p7ullQS7Mjohj6/VdbZuTg==", + "dependencies": [ + "@types/node", + "@types/qs", + "@types/range-parser", + "@types/send" + ] + }, + "@types/express@5.0.6": { + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dependencies": [ + "@types/body-parser", + "@types/express-serve-static-core", + "@types/serve-static" + ] + }, + "@types/http-errors@2.0.5": { + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==" + }, + "@types/node@26.1.1": { + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dependencies": [ + "undici-types" + ] + }, + "@types/qs@6.15.1": { + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==" + }, + "@types/range-parser@1.2.7": { + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" + }, + "@types/send@1.2.1": { + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dependencies": [ + "@types/node" + ] + }, + "@types/serve-static@2.2.0": { + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dependencies": [ + "@types/http-errors", + "@types/node" + ] + }, + "@types/trusted-types@2.0.7": { + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" + }, + "accepts@2.0.0": { + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dependencies": [ + "mime-types", + "negotiator" + ] + }, + "body-parser@2.3.0": { + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "dependencies": [ + "bytes", + "content-type@2.0.0", + "debug", + "http-errors", + "iconv-lite", + "on-finished", + "qs", + "raw-body", + "type-is" + ] + }, + "bytes@3.1.2": { + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" + }, + "call-bind-apply-helpers@1.0.2": { + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": [ + "es-errors", + "function-bind" + ] + }, + "call-bound@1.0.4": { + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dependencies": [ + "call-bind-apply-helpers", + "get-intrinsic" + ] + }, + "content-disposition@1.1.0": { + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==" + }, + "content-type@1.0.5": { + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==" + }, + "content-type@2.0.0": { + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==" + }, + "cookie-signature@1.2.2": { + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==" + }, + "cookie@0.7.2": { + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==" + }, + "core-util-is@1.0.3": { + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, + "debug@4.4.3": { + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": [ + "ms" + ] + }, + "depd@2.0.0": { + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" + }, + "dunder-proto@1.0.1": { + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": [ + "call-bind-apply-helpers", + "es-errors", + "gopd" + ] + }, + "ee-first@1.1.1": { + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "encodeurl@2.0.0": { + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==" + }, + "es-define-property@1.0.1": { + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" + }, + "es-errors@1.3.0": { + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" + }, + "es-object-atoms@1.1.2": { + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dependencies": [ + "es-errors" + ] + }, + "escape-html@1.0.3": { + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "esm-env@1.2.2": { + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==" + }, + "etag@1.8.1": { + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" + }, + "express@5.2.1": { + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dependencies": [ + "accepts", + "body-parser", + "content-disposition", + "content-type@1.0.5", + "cookie", + "cookie-signature", + "debug", + "depd", + "encodeurl", + "escape-html", + "etag", + "finalhandler", + "fresh", + "http-errors", + "merge-descriptors", + "mime-types", + "on-finished", + "once", + "parseurl", + "proxy-addr", + "qs", + "range-parser", + "router", + "send", + "serve-static", + "statuses", + "type-is", + "vary" + ] + }, + "file-type@22.0.1": { + "integrity": "sha512-ww5Mhre0EE+jmBvOXTmXAbEMuZE7uX4a3+oRCQFNj8w++g3ev913N6tXQz0XTXbueQ5TWQfm6BdaViEHHn8bhA==", + "dependencies": [ + "@tokenizer/inflate", + "strtok3", + "token-types", + "uint8array-extras" + ] + }, + "finalhandler@2.1.1": { + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dependencies": [ + "debug", + "encodeurl", + "escape-html", + "on-finished", + "parseurl", + "statuses" + ] + }, + "forwarded@0.2.0": { + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" + }, + "fresh@2.0.0": { + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==" + }, + "function-bind@1.1.2": { + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" + }, + "get-intrinsic@1.3.0": { + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": [ + "call-bind-apply-helpers", + "es-define-property", + "es-errors", + "es-object-atoms", + "function-bind", + "get-proto", + "gopd", + "has-symbols", + "hasown", + "math-intrinsics" + ] + }, + "get-proto@1.0.1": { + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": [ + "dunder-proto", + "es-object-atoms" + ] + }, + "gopd@1.2.0": { + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" + }, + "has-symbols@1.1.0": { + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" + }, + "hasown@2.0.4": { + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dependencies": [ + "function-bind" + ] + }, + "http-errors@2.0.1": { + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dependencies": [ + "depd", + "inherits", + "setprototypeof", + "statuses", + "toidentifier" + ] + }, + "iconv-lite@0.7.3": { + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dependencies": [ + "safer-buffer" + ] + }, + "ieee754@1.2.1": { + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" + }, + "immediate@3.0.6": { + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" + }, + "inherits@2.0.4": { + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "ipaddr.js@1.9.1": { + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" + }, + "is-promise@4.0.0": { + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" + }, + "isarray@1.0.0": { + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, + "jszip@3.10.1": { + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dependencies": [ + "lie", + "pako", + "readable-stream", + "setimmediate" + ] + }, + "lie@3.3.0": { + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dependencies": [ + "immediate" + ] + }, + "lit-element@4.2.2": { + "integrity": "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==", + "dependencies": [ + "@lit-labs/ssr-dom-shim", + "@lit/reactive-element", + "lit-html" + ] + }, + "lit-html@3.3.3": { + "integrity": "sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA==", + "dependencies": [ + "@types/trusted-types" + ] + }, + "lit@3.3.3": { + "integrity": "sha512-fycuvZg/hkpozL00lm1pEJH5nN/lr9ZXd6mJI2HSN4+Bzc+LDNdEApJ6HFbPkdFNHLvOplIIuJvxkS4XUxqirw==", + "dependencies": [ + "@lit/reactive-element", + "lit-element", + "lit-html" + ] + }, + "math-intrinsics@1.1.0": { + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" + }, + "media-typer@1.1.0": { + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==" + }, + "merge-descriptors@2.0.0": { + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==" + }, + "mime-db@1.54.0": { + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==" + }, + "mime-types@3.0.2": { + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dependencies": [ + "mime-db" + ] + }, + "mime@4.1.0": { + "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", + "bin": true + }, + "ms@2.1.3": { + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "nanoid@5.1.16": { + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "bin": true + }, + "negotiator@1.0.0": { + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==" + }, + "object-inspect@1.13.4": { + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==" + }, + "on-finished@2.4.1": { + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": [ + "ee-first" + ] + }, + "once@1.4.0": { + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": [ + "wrappy" + ] + }, + "pako@1.0.11": { + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, + "parseurl@1.3.3": { + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" + }, + "path-to-regexp@8.4.2": { + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==" + }, + "process-nextick-args@2.0.1": { + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, + "proxy-addr@2.0.7": { + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": [ + "forwarded", + "ipaddr.js" + ] + }, + "qs@6.15.3": { + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "dependencies": [ + "es-define-property", + "side-channel" + ] + }, + "range-parser@1.3.0": { + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==" + }, + "raw-body@3.0.2": { + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dependencies": [ + "bytes", + "http-errors", + "iconv-lite", + "unpipe" + ] + }, + "readable-stream@2.3.8": { + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": [ + "core-util-is", + "inherits", + "isarray", + "process-nextick-args", + "safe-buffer", + "string_decoder", + "util-deprecate" + ] + }, + "refrakt@1.0.0": { + "integrity": "sha512-Ru0J+jimi7ee0331ERoCkE3kVtWM7lXzbtKbQQNqaGOAd2rOwzAhv7leWaDi38H8LCkz4WlRCr6wiLJFFjn70g==", + "dependencies": [ + "signal-polyfill" + ] + }, + "router@2.2.0": { + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dependencies": [ + "debug", + "depd", + "is-promise", + "parseurl", + "path-to-regexp" + ] + }, + "safe-buffer@5.1.2": { + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "safer-buffer@2.1.2": { + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "send@1.2.1": { + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dependencies": [ + "debug", + "encodeurl", + "escape-html", + "etag", + "fresh", + "http-errors", + "mime-types", + "ms", + "on-finished", + "range-parser", + "statuses" + ] + }, + "serve-static@2.2.1": { + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dependencies": [ + "encodeurl", + "escape-html", + "parseurl", + "send" + ] + }, + "setimmediate@1.0.5": { + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" + }, + "setprototypeof@1.2.0": { + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "side-channel-list@1.0.1": { + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dependencies": [ + "es-errors", + "object-inspect" + ] + }, + "side-channel-map@1.0.1": { + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dependencies": [ + "call-bound", + "es-errors", + "get-intrinsic", + "object-inspect" + ] + }, + "side-channel-weakmap@1.0.2": { + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dependencies": [ + "call-bound", + "es-errors", + "get-intrinsic", + "object-inspect", + "side-channel-map" + ] + }, + "side-channel@1.1.1": { + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dependencies": [ + "es-errors", + "object-inspect", + "side-channel-list", + "side-channel-map", + "side-channel-weakmap" + ] + }, + "signal-polyfill@0.2.2": { + "integrity": "sha512-p63Y4Er5/eMQ9RHg0M0Y64NlsQKpiu6MDdhBXpyywRuWiPywhJTpKJ1iB5K2hJEbFZ0BnDS7ZkJ+0AfTuL37Rg==" + }, + "smol-toml@1.7.0": { + "integrity": "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==" + }, + "statuses@2.0.2": { + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==" + }, + "string_decoder@1.1.1": { + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": [ + "safe-buffer" + ] + }, + "strtok3@10.3.5": { + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "dependencies": [ + "@tokenizer/token" + ] + }, + "tmp-promise@3.0.3": { + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dependencies": [ + "tmp" + ] + }, + "tmp@0.2.7": { + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==" + }, + "toidentifier@1.0.1": { + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" + }, + "token-types@6.1.2": { + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "dependencies": [ + "@borewit/text-codec", + "@tokenizer/token", + "ieee754" + ] + }, + "type-is@2.1.0": { + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "dependencies": [ + "content-type@2.0.0", + "media-typer", + "mime-types" + ] + }, + "uint8array-extras@1.5.0": { + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==" + }, + "undici-types@8.3.0": { + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==" + }, + "unicode-segmenter@0.14.5": { + "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==" + }, + "unpipe@1.0.0": { + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" + }, + "util-deprecate@1.0.2": { + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "valibot@1.4.2": { + "integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==" + }, + "vary@1.1.2": { + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" + }, + "wrappy@1.0.2": { + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + } + }, + "workspace": { + "dependencies": [ + "jsr:@std/path@^1.1.0", + "npm:@atcute/atproto@^4.0.3", + "npm:@atcute/cbor@^2.3.5", + "npm:@atcute/cid@^2.4.2", + "npm:@atcute/client@^5.1.1", + "npm:@atcute/identity-resolver@^2.0.1", + "npm:@atcute/lexicons@^2.0.2", + "npm:@atcute/oauth-node-client@^2.0.1", + "npm:@dasl/tile-lexicon@2", + "npm:@dasl/tile-loader@2", + "npm:@dasl/tile-server@2", + "npm:@dasl/tile-writer@2", + "npm:@lit-labs/signals@0.3", + "npm:express@^5.2.1", + "npm:file-type@^22.0.1", + "npm:lit@^3.3.3", + "npm:mime@^4.1.0", + "npm:refrakt@1" + ] + } +} diff --git a/examples/hello-tile/index.html b/examples/hello-tile/index.html new file mode 100644 index 0000000..71edd6a --- /dev/null +++ b/examples/hello-tile/index.html @@ -0,0 +1,17 @@ + + + + + Hello Blastile + + + +
+

⬢ Hello from a tile

+

This content is served from an isolated *.localhost origin.

+

Edit any file in examples/hello-tile/ and watch me reload.

+ 0 pokes +
+ + + diff --git a/examples/hello-tile/manifest.json b/examples/hello-tile/manifest.json new file mode 100644 index 0000000..f604471 --- /dev/null +++ b/examples/hello-tile/manifest.json @@ -0,0 +1,5 @@ +{ + "name": "Hello Blastile", + "description": "A tiny tile to smoke-test Blastile: open it, watch it, publish it.", + "sizing": { "width": 560, "height": 320 } +} diff --git a/examples/hello-tile/style.css b/examples/hello-tile/style.css new file mode 100644 index 0000000..f65b824 --- /dev/null +++ b/examples/hello-tile/style.css @@ -0,0 +1,30 @@ +body { + margin: 0; + font-family: system-ui, sans-serif; + background: linear-gradient(135deg, #1c2340, #34186c); + color: #f2f2ff; + min-height: 100vh; + display: grid; + place-items: center; +} +main { + text-align: center; + padding: 1rem 2rem; +} +h1 { + font-size: 1.5rem; +} +code { + background: rgba(255, 255, 255, 0.15); + padding: 0.1rem 0.35rem; + border-radius: 4px; +} +button { + font: inherit; + padding: 0.3rem 1rem; + border-radius: 6px; + border: none; + background: #7a90ff; + color: #101430; + cursor: pointer; +} diff --git a/examples/hello-tile/tile.js b/examples/hello-tile/tile.js new file mode 100644 index 0000000..a2c981a --- /dev/null +++ b/examples/hello-tile/tile.js @@ -0,0 +1,4 @@ +let pokes = 0; +document.getElementById('poke').addEventListener('click', () => { + document.getElementById('count').textContent = String(++pokes); +}); diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..83595c2 --- /dev/null +++ b/main.ts @@ -0,0 +1,57 @@ +// Blastile — open, watch, and publish DASL web tiles. +// +// Under `deno desktop`, Deno.serve() auto-binds to the runtime-assigned +// 127.0.0.1 port (DENO_SERVE_ADDRESS) and the webview opens on it. Run with +// plain `deno run -A main.ts` and it binds 127.0.0.1:4179 for a regular +// browser instead. + +import { AppState } from './src/server/state.ts'; +import { OAuthManager } from './src/server/oauth.ts'; +import { createHandler, type Ctx, TileManager } from './src/server/api.ts'; +import { startTileServer } from './src/server/tileserver.ts'; +import { hasDesktopWindows, setupMainWindow } from './src/server/windows.ts'; + +const state = new AppState(); +await state.load(); +state.desktop = hasDesktopWindows(); + +const oauth = new OAuthManager(); +const ctx: Ctx = { state, oauth, session: null, port: 0 }; +const tiles = new TileManager(ctx); +const handler = createHandler(ctx, tiles); + +// Bind the main server before ANY other listener: under deno desktop the +// runtime probes a free port for DENO_SERVE_ADDRESS and releases it, so a +// listener started earlier (like the tile server on port 0) can be handed +// that exact port by the OS and Deno.serve() then dies with AddrInUse. +const server = Deno.env.get('DENO_SERVE_ADDRESS') + ? Deno.serve(handler) + : Deno.serve({ hostname: '127.0.0.1', port: Number(Deno.env.get('BLASTILE_PORT') ?? 4179) }, handler); +ctx.port = (server.addr as Deno.NetAddr).port; +oauth.init(ctx.port); +console.log(`Blastile on http://127.0.0.1:${ctx.port}/`); + +// Deno's node-compat listeners honor DENO_SERVE_ADDRESS too, so without this +// the express tile server would try to bind the exact same address. +Deno.env.delete('DENO_SERVE_ADDRESS'); + +state.tileServerPort = await startTileServer(); +// UIs that connected before the tile server was up need the port. +state.broadcastState(); +console.log(`tile server on http://load.localhost:${state.tileServerPort}/`); + +tiles.resumeWatches(); +setupMainWindow(); + +// Try to pick up the previous session; tokens refresh on demand. +if (state.currentDid) { + const session = await oauth.restore(state.currentDid); + if (session) { + ctx.session = session; + state.session = { did: session.did, handle: state.currentHandle }; + state.broadcastState(); + console.log(`restored session for ${state.currentHandle ?? session.did}`); + } else { + console.log('previous session expired; log in again to publish'); + } +} diff --git a/src/server/api.ts b/src/server/api.ts new file mode 100644 index 0000000..1c2e231 --- /dev/null +++ b/src/server/api.ts @@ -0,0 +1,368 @@ +// HTTP + WebSocket surface of the app, served by Deno.serve on 127.0.0.1 +// (the webview points here). Static UI, tile registry, watch/reload events, +// AT OAuth endpoints, and publishing. + +import { basename, isAbsolute, join, normalize, toFileUrl } from '@std/path'; +import { + CompositeHandleResolver, + DohJsonHandleResolver, + WellKnownHandleResolver, +} from '@atcute/identity-resolver'; +import type { OAuthSession } from '@atcute/oauth-node-client'; +import type { TileEntry } from '../types.ts'; +import { type AppState, getIdentifier } from './state.ts'; +import type { OAuthManager } from './oauth.ts'; +import { bundleToBytes, loadTileDirectory } from './tiledir.ts'; +import { publishTile } from './publish.ts'; +import { watchDirectory } from './watcher.ts'; +import { openTileWindow } from './windows.ts'; +import { configDir, ensureDir } from './paths.ts'; + +// Static UI roots, in order: the real on-disk static/ next to the process's +// cwd (dev + `deno desktop --hmr`, where code runs from a compiled dylib whose +// VFS may not carry static/, and where live UI rebuilds should win), then +// module-relative (plain `deno run`, and compiled apps built with +// `--include static`). +const staticRoots = [ + toFileUrl(join(Deno.cwd(), 'static')) + '/', + new URL('../../static/', import.meta.url), +]; + +export interface Ctx { + state: AppState; + oauth: OAuthManager; + session: OAuthSession | null; + /** Main server port; set right after Deno.serve binds. */ + port: number; + /** Handle used for the login currently in flight, for display purposes. */ + pendingHandle?: string; +} + +const handleResolver = new CompositeHandleResolver({ + strategy: 'race', + methods: { + dns: new DohJsonHandleResolver({ dohUrl: 'https://mozilla.cloudflare-dns.com/dns-query' }), + http: new WellKnownHandleResolver(), + }, +}); + +/** Normalise an at:// URI: resolve a handle authority to its DID. */ +async function normalizeAtUri(uri: string): Promise { + const m = uri.match(/^at:\/\/([^/]+)(\/.*)?$/); + if (!m) throw new Error(`Not an at:// URI: ${uri}`); + let [, authority, rest] = m; + if (!authority.startsWith('did:')) { + const did = await handleResolver.resolve(authority as Parameters[0]); + if (!did) throw new Error(`Cannot resolve handle ${authority}`); + authority = did as string; + } + return `at://${authority}${rest ?? ''}`; +} + +function json(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +function id(): string { + return crypto.randomUUID().slice(0, 8); +} + +const MIME: Record = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', +}; + +async function serveStatic(path: string): Promise { + const clean = path === '/' ? '/index.html' : path; + if (clean.includes('..')) return new Response('nope', { status: 400 }); + for (const root of staticRoots) { + try { + const bytes = await Deno.readFile(new URL(`.${clean}`, root)); + const ext = clean.slice(clean.lastIndexOf('.')); + return new Response(bytes as BodyInit, { + headers: { 'content-type': MIME[ext] ?? 'application/octet-stream' }, + }); + } catch { + // try the next root + } + } + return new Response('not found', { status: 404 }); +} + +async function pickPath(mode: 'file' | 'dir'): Promise<{ path?: string; cancelled?: boolean; unsupported?: boolean }> { + if (Deno.build.os !== 'darwin') return { unsupported: true }; + const script = mode === 'dir' + ? 'POSIX path of (choose folder with prompt "Choose a tile source directory")' + : 'POSIX path of (choose file with prompt "Open a .tile file")'; + try { + const out = await new Deno.Command('osascript', { args: ['-e', script] }).output(); + if (!out.success) return { cancelled: true }; + const path = new TextDecoder().decode(out.stdout).trim(); + return path ? { path } : { cancelled: true }; + } catch { + return { unsupported: true }; + } +} + +function openInBrowser(url: string) { + const cmd = Deno.build.os === 'darwin' ? 'open' : Deno.build.os === 'windows' ? 'explorer' : 'xdg-open'; + try { + new Deno.Command(cmd, { args: [url] }).spawn(); + } catch (err) { + console.warn('could not open browser:', err); + } +} + +export class TileManager { + #ctx: Ctx; + #watchers = new Map void>(); + + constructor(ctx: Ctx) { + this.#ctx = ctx; + } + + /** Restart watchers for persisted watch tiles (on boot). */ + resumeWatches() { + for (const tile of this.#ctx.state.tiles.values()) { + if (tile.kind === 'watch' && tile.dir) this.#startWatch(tile); + } + } + + #startWatch(tile: TileEntry) { + this.#watchers.get(tile.id)?.(); + this.#watchers.set( + tile.id, + watchDirectory(tile.dir!, () => { + this.#ctx.state.broadcast({ type: 'tile-changed', tileId: tile.id }); + }), + ); + } + + async addCarTile(path: string): Promise { + path = normalize(path); + if (!isAbsolute(path)) throw new Error('Need an absolute path'); + const info = await Deno.stat(path); + if (!info.isFile) throw new Error(`${path} is not a file`); + const tile: TileEntry = { + id: id(), + kind: 'car', + path, + name: basename(path), + addedAt: new Date().toISOString(), + }; + this.#ctx.state.tiles.set(tile.id, tile); + await this.#ctx.state.persist(); + this.#ctx.state.broadcastState(); + return tile; + } + + async addAtTile(uri: string): Promise { + const atUri = await normalizeAtUri(uri.trim()); + const tile: TileEntry = { + id: id(), + kind: 'at', + atUri, + addedAt: new Date().toISOString(), + }; + this.#ctx.state.tiles.set(tile.id, tile); + await this.#ctx.state.persist(); + this.#ctx.state.broadcastState(); + return tile; + } + + async addWatchTile(dir: string): Promise { + dir = normalize(dir).replace(/\/$/, ''); + if (!isAbsolute(dir)) throw new Error('Need an absolute path'); + // Validates manifest.json + "/" resource before we commit to watching. + const loaded = await loadTileDirectory(dir); + const tile: TileEntry = { + id: id(), + kind: 'watch', + dir, + name: loaded.manifest.name || basename(dir), + publishedUri: await getIdentifier(dir), + addedAt: new Date().toISOString(), + }; + this.#ctx.state.tiles.set(tile.id, tile); + this.#startWatch(tile); + await this.#ctx.state.persist(); + this.#ctx.state.broadcastState(); + return tile; + } + + async importCarBytes(name: string, bytes: Uint8Array): Promise { + const importsDir = join(configDir, 'imports'); + await ensureDir(importsDir); + const safe = basename(name).replace(/[^\w.-]/g, '_') || 'import.tile'; + let path = join(importsDir, safe); + let counter = 1; + while (await Deno.stat(path).then(() => true).catch(() => false)) { + path = join(importsDir, safe.replace(/(\.tile)?$/, `-${counter++}$1`)); + } + await Deno.writeFile(path, bytes); + return await this.addCarTile(path); + } + + async removeTile(tileId: string) { + this.#watchers.get(tileId)?.(); + this.#watchers.delete(tileId); + this.#ctx.state.tiles.delete(tileId); + await this.#ctx.state.persist(); + this.#ctx.state.broadcastState(); + } + + async bundleFor(tile: TileEntry): Promise { + if (tile.kind === 'car' && tile.path) return await Deno.readFile(tile.path); + if (tile.kind === 'watch' && tile.dir) return await bundleToBytes(tile.dir); + throw new Error(`Tile ${tile.id} has no bundle`); + } +} + +export function createHandler(ctx: Ctx, tiles: TileManager): (req: Request) => Promise { + return async (req: Request): Promise => { + const url = new URL(req.url); + const { pathname } = url; + try { + // --- WebSocket events + if (pathname === '/api/events' && req.headers.get('upgrade')?.toLowerCase() === 'websocket') { + const { socket, response } = Deno.upgradeWebSocket(req); + socket.addEventListener('open', () => { + ctx.state.addSocket(socket); + socket.send(JSON.stringify({ type: 'state', state: ctx.state.snapshot() })); + }); + return response; + } + + // --- OAuth + if (pathname === '/oauth/callback') { + try { + const session = await ctx.oauth.handleCallback(url.searchParams); + ctx.session = session; + await ctx.state.setSession({ did: session.did, handle: ctx.pendingHandle }); + return new Response( + `Blastile + +

Signed in ✓

You can close this tab and return to Blastile.

`, + { headers: { 'content-type': 'text/html' } }, + ); + } catch (err) { + return new Response( + `Blastile + +

Sign-in failed

${(err as Error).message}

`, + { status: 400, headers: { 'content-type': 'text/html' } }, + ); + } + } + if (pathname === '/api/login' && req.method === 'POST') { + const { handle } = await req.json(); + if (!handle) return json({ error: 'handle required' }, 400); + ctx.pendingHandle = handle; + const authUrl = await ctx.oauth.startLogin(handle); + openInBrowser(authUrl); + ctx.state.broadcast({ type: 'login-pending', url: authUrl }); + return json({ url: authUrl }); + } + if (pathname === '/api/logout' && req.method === 'POST') { + if (ctx.session) await ctx.oauth.logout(ctx.session.did); + ctx.session = null; + await ctx.state.setSession(null); + return json({ ok: true }); + } + + // --- pickers + if (pathname === '/api/pick') { + const mode = url.searchParams.get('mode') === 'dir' ? 'dir' : 'file'; + return json(await pickPath(mode)); + } + + // --- tiles + if (pathname === '/api/state') return json(ctx.state.snapshot()); + if (pathname === '/api/tiles' && req.method === 'POST') { + const body = await req.json(); + if (body.kind === 'car') return json(await tiles.addCarTile(body.source)); + if (body.kind === 'at') return json(await tiles.addAtTile(body.source)); + if (body.kind === 'watch') return json(await tiles.addWatchTile(body.source)); + return json({ error: `unknown kind ${body.kind}` }, 400); + } + if (pathname === '/api/import' && req.method === 'POST') { + const name = url.searchParams.get('name') ?? 'import.tile'; + const bytes = new Uint8Array(await req.arrayBuffer()); + if (!bytes.length) return json({ error: 'empty file' }, 400); + return json(await tiles.importCarBytes(name, bytes)); + } + + const tileRoute = pathname.match(/^\/api\/tiles\/([^/]+)(?:\/(.+))?$/); + if (tileRoute) { + const tile = ctx.state.tiles.get(tileRoute[1]); + if (!tile) return json({ error: 'no such tile' }, 404); + const sub = tileRoute[2]; + if (!sub && req.method === 'DELETE') { + await tiles.removeTile(tile.id); + return json({ ok: true }); + } + if (sub === 'bundle.tile') { + const bytes = await tiles.bundleFor(tile); + return new Response(bytes as BodyInit, { + headers: { 'content-type': 'application/vnd.ipld.car', 'cache-control': 'no-store' }, + }); + } + if (sub === 'meta' && req.method === 'POST') { + const { name } = await req.json(); + if (name && tile.name !== name) { + tile.name = name; + await ctx.state.persist(); + ctx.state.broadcastState(); + } + return json({ ok: true }); + } + if (sub === 'window' && req.method === 'POST') { + const { width, height } = await req.json().catch(() => ({})); + const viewerUrl = `http://127.0.0.1:${ctx.port}/?view=${tile.id}`; + const opened = openTileWindow(viewerUrl, { + title: tile.name ?? 'Blastile tile', + width: width ? Math.min(width + 40, 1600) : undefined, + height: height ? Math.min(height + 80, 1200) : undefined, + }); + return json({ opened, url: viewerUrl }); + } + if (sub === 'publish' && req.method === 'POST') { + if (!ctx.session) return json({ error: 'not logged in' }, 401); + if (tile.kind !== 'watch' || !tile.dir) { + return json({ error: 'only watched tile directories can be published' }, 400); + } + try { + const { uri } = await publishTile(ctx.session, tile.dir, (message) => { + ctx.state.broadcast({ type: 'publish-progress', tileId: tile.id, message }); + }); + tile.publishedUri = uri; + await ctx.state.persist(); + ctx.state.broadcast({ type: 'tile-published', tileId: tile.id, uri }); + ctx.state.broadcastState(); + return json({ uri }); + } catch (err) { + const message = (err as Error).message; + ctx.state.broadcast({ type: 'publish-error', tileId: tile.id, message }); + return json({ error: message }, 500); + } + } + return json({ error: 'unknown tile route' }, 404); + } + + // --- static UI + if (req.method === 'GET' && !pathname.startsWith('/api/')) { + return await serveStatic(pathname); + } + return json({ error: 'not found' }, 404); + } catch (err) { + console.error(`error handling ${req.method} ${pathname}:`, err); + return json({ error: (err as Error).message }, 500); + } + }; +} diff --git a/src/server/oauth.ts b/src/server/oauth.ts new file mode 100644 index 0000000..8b86d3c --- /dev/null +++ b/src/server/oauth.ts @@ -0,0 +1,124 @@ +// AT Protocol OAuth as a loopback public client, run entirely in the Deno +// process. The client_id is the spec's `http://localhost?...` development +// client; the redirect URI must use a loopback IP (127.0.0.1), which is +// exactly where deno desktop binds Deno.serve(). + +import { + MemoryStore, + OAuthClient, + type OAuthSession, + type StoredState, +} from '@atcute/oauth-node-client'; +import type { ActorIdentifier } from '@atcute/lexicons'; +import { + CompositeDidDocumentResolver, + CompositeHandleResolver, + DohJsonHandleResolver, + LocalActorResolver, + PlcDidDocumentResolver, + WebDidDocumentResolver, + WellKnownHandleResolver, +} from '@atcute/identity-resolver'; +import { readJson, sessionsPath, writeJson } from './paths.ts'; + +// Broad transitional scope: covers putRecord + uploadBlob on any PDS, +// including ones that don't speak the granular permission system yet. +const SCOPE = 'atproto transition:generic'; + +class FileSessionStore { + #data: Record = {}; + #loaded = false; + async #load() { + if (!this.#loaded) { + this.#data = await readJson>(sessionsPath, {}); + this.#loaded = true; + } + } + async get(did: string) { + await this.#load(); + // deno-lint-ignore no-explicit-any + return this.#data[did] as any; + } + async set(did: string, session: unknown) { + await this.#load(); + this.#data[did] = session; + await writeJson(sessionsPath, this.#data); + } + async delete(did: string) { + await this.#load(); + delete this.#data[did]; + await writeJson(sessionsPath, this.#data); + } + async clear() { + this.#data = {}; + this.#loaded = true; + await writeJson(sessionsPath, this.#data); + } +} + +export class OAuthManager { + #client: OAuthClient | undefined; + #redirectUri = ''; + + /** Must be called once the main server's port is known. */ + init(port: number) { + this.#redirectUri = `http://127.0.0.1:${port}/oauth/callback`; + this.#client = new OAuthClient({ + metadata: { + redirect_uris: [this.#redirectUri], + scope: SCOPE, + }, + actorResolver: new LocalActorResolver({ + handleResolver: new CompositeHandleResolver({ + strategy: 'race', + methods: { + dns: new DohJsonHandleResolver({ dohUrl: 'https://mozilla.cloudflare-dns.com/dns-query' }), + http: new WellKnownHandleResolver(), + }, + }), + didDocumentResolver: new CompositeDidDocumentResolver({ + methods: { plc: new PlcDidDocumentResolver(), web: new WebDidDocumentResolver() }, + }), + }), + stores: { + // deno-lint-ignore no-explicit-any + sessions: new FileSessionStore() as any, + states: new MemoryStore({ maxSize: 32, ttl: 600_000, ttlAutopurge: true }), + }, + }); + } + + #ensure(): OAuthClient { + if (!this.#client) throw new Error('OAuthManager not initialised'); + return this.#client; + } + + async startLogin(identifier: string): Promise { + const { url } = await this.#ensure().authorize({ + target: { type: 'account', identifier: identifier as ActorIdentifier }, + redirectUri: this.#redirectUri, + }); + return url.href; + } + + async handleCallback(params: URLSearchParams): Promise { + const { session } = await this.#ensure().callback(params, { redirectUri: this.#redirectUri }); + return session; + } + + async restore(did: string): Promise { + try { + return await this.#ensure().restore(did as Parameters[0]); + } catch { + return null; + } + } + + async logout(did: string) { + try { + await this.#ensure().revoke(did as Parameters[0]); + } catch { + // revocation is best-effort; the session store entry is what matters + } + } +} diff --git a/src/server/paths.ts b/src/server/paths.ts new file mode 100644 index 0000000..9ca5ef3 --- /dev/null +++ b/src/server/paths.ts @@ -0,0 +1,33 @@ +import { join } from '@std/path'; + +const home = Deno.env.get('HOME') ?? Deno.cwd(); + +/** Blastile's own config directory. */ +export const configDir = join(home, '.blastile'); +export const statePath = join(configDir, 'state.json'); +export const sessionsPath = join(configDir, 'sessions.json'); + +/** + * Shared with the atile CLI: `~/.atile/identifiers.json` maps absolute tile + * source directories to the at:// URI they were published under, so both tools + * update the same record instead of minting new ones. + */ +export const atileDir = join(home, '.atile'); +export const identifiersPath = join(atileDir, 'identifiers.json'); + +export async function ensureDir(path: string) { + await Deno.mkdir(path, { recursive: true }); +} + +export async function readJson(path: string, fallback: T): Promise { + try { + return JSON.parse(await Deno.readTextFile(path)) as T; + } catch { + return fallback; + } +} + +export async function writeJson(path: string, data: unknown) { + await ensureDir(join(path, '..')); + await Deno.writeTextFile(path, JSON.stringify(data, null, 2) + '\n'); +} diff --git a/src/server/publish.ts b/src/server/publish.ts new file mode 100644 index 0000000..03bff21 --- /dev/null +++ b/src/server/publish.ts @@ -0,0 +1,116 @@ +// Publish a tile source directory to the AT Protocol over an OAuth session. +// The record shape, blob handling, and rkey reuse mirror @dasl/atile exactly +// (collection ing.dasl.masl, DRISL CID of the manifest in `cid`, blobs +// verified against locally computed CIDs, stable ids in ~/.atile). + +import { Client, ok } from '@atcute/client'; +import type { OAuthSession } from '@atcute/oauth-node-client'; +import { CODEC_DCBOR, create, toString as cidToString } from '@atcute/cid'; +import { encode } from '@atcute/cbor'; +import type {} from '@atcute/atproto'; +import type { BlobRef, WriterMasl } from '@dasl/tile-lexicon'; +import { loadTileDirectory } from './tiledir.ts'; +import { getIdentifier, saveIdentifier } from './state.ts'; + +export const TILE_COLLECTION = 'ing.dasl.masl'; + +// TIDs, the timestamp-based rkeys AT repos sort by (13 chars of +// base32-sortable over microseconds<<10 | clock id). +const TID_ALPHABET = '234567abcdefghijklmnopqrstuvwxyz'; +export function tidNow(): string { + const micros = BigInt(Date.now()) * 1000n; + const clockId = BigInt(Math.floor(Math.random() * 1024)); + let value = (micros << 10n) | clockId; + let tid = ''; + for (let i = 0; i < 13; i++) { + tid = TID_ALPHABET[Number(value & 31n)] + tid; + value >>= 5n; + } + return tid; +} + +function validate(manifest: WriterMasl, warn: (msg: string) => void) { + if (!manifest.name) throw new Error('Cannot publish tile without name'); + if (!manifest.icons?.[0]?.src) warn('Tile has no icon'); + if (!manifest.description) warn('Tile has no description'); + for (const icon of manifest.icons || []) { + if (!icon.src) throw new Error('Tile icon has no src'); + if (!manifest.resources[icon.src]) throw new Error(`Tile icon "${icon.src}" is not in resources`); + } + for (const shot of manifest.screenshots || []) { + if (!shot.src) throw new Error('Tile screenshot has no src'); + if (!manifest.resources[shot.src]) throw new Error(`Tile screenshot "${shot.src}" is not in resources`); + } + if (!manifest.resources['/']) throw new Error('Tile does not have default "/" resource'); +} + +// The PDS returns the uploaded blob's ref in whatever shape the client lib +// left it in; normalise to the bare CID string. +// deno-lint-ignore no-explicit-any +function refToCidString(ref: any): string { + if (typeof ref === 'string') return ref; + if (ref?.$link) return ref.$link; + return String(ref); +} + +export interface PublishOutcome { + uri: string; + record: unknown; +} + +export async function publishTile( + session: OAuthSession, + dir: string, + onProgress: (message: string) => void = () => {}, +): Promise { + const loaded = await loadTileDirectory(dir); + validate(loaded.manifest, (msg) => onProgress(`warning: ${msg}`)); + const rpc = new Client({ handler: session }); + + // Upload every resource as a blob; the PDS must agree with our CID. + for (const res of Object.keys(loaded.manifest.resources)) { + const src = loaded.manifest.resources[res].src as BlobRef; + onProgress(`Uploading ${res}`); + const bytes = await Deno.readFile(loaded.sourceMap[res]); + const out = await ok(rpc.post('com.atproto.repo.uploadBlob', { + input: new Blob([bytes as BlobPart], { type: src.mimeType }), + })); + const pdsCid = refToCidString(out.blob.ref); + if (pdsCid !== src.ref.$link) { + throw new Error(`CID for "${res}" does not match: ${src.ref.$link} (ours) vs ${pdsCid} (PDS)`); + } + } + + onProgress('Writing record'); + const manifestCid = await create(CODEC_DCBOR, encode(loaded.manifest)); + const record = { + $type: TILE_COLLECTION, + cid: cidToString(manifestCid), + tile: loaded.manifest, + createdAt: new Date().toISOString(), + }; + + // Reuse the rkey this directory was already published under (shared with + // the atile CLI), unless it belongs to a different account. + let rkey = tidNow(); + const existing = await getIdentifier(dir); + if (existing) { + const [did, , tid] = existing.replace(/^at:\/\//, '').split('/'); + if (tid && did === session.did) rkey = tid; + else if (tid) onProgress(`warning: previously published as ${did}, minting a new record`); + } + + const res = await ok(rpc.post('com.atproto.repo.putRecord', { + input: { + repo: session.did, + collection: TILE_COLLECTION, + rkey, + // deno-lint-ignore no-explicit-any + record: record as any, + }, + })); + const uri = res.uri ?? `at://${session.did}/${TILE_COLLECTION}/${rkey}`; + await saveIdentifier(dir, uri); + onProgress('Published'); + return { uri, record }; +} diff --git a/src/server/state.ts b/src/server/state.ts new file mode 100644 index 0000000..9d8ff0c --- /dev/null +++ b/src/server/state.ts @@ -0,0 +1,90 @@ +import type { AppStateSnapshot, ServerEvent, SessionInfo, TileEntry } from '../types.ts'; +import { identifiersPath, readJson, statePath, writeJson } from './paths.ts'; + +interface PersistedState { + tiles: TileEntry[]; + currentDid?: string; + currentHandle?: string; +} + +/** + * In-memory app state: the registry of open tiles, the current session, and + * the set of connected UI WebSockets. Tiles that reference the filesystem + * (car paths, watched dirs) are persisted across runs in ~/.blastile. + */ +export class AppState { + tiles = new Map(); + session: SessionInfo | null = null; + currentDid: string | undefined; + currentHandle: string | undefined; + tileServerPort = 0; + desktop = false; + #sockets = new Set(); + + async load() { + const persisted = await readJson(statePath, { tiles: [] }); + for (const tile of persisted.tiles) this.tiles.set(tile.id, tile); + this.currentDid = persisted.currentDid; + this.currentHandle = persisted.currentHandle; + const identifiers = await readJson>(identifiersPath, {}); + for (const tile of this.tiles.values()) { + if (tile.dir && identifiers[tile.dir]) tile.publishedUri = identifiers[tile.dir]; + } + } + + async persist() { + const tiles = [...this.tiles.values()].filter((t) => t.kind !== 'car' || t.path); + await writeJson( + statePath, + { tiles, currentDid: this.currentDid, currentHandle: this.currentHandle } satisfies PersistedState, + ); + } + + snapshot(): AppStateSnapshot { + return { + tiles: [...this.tiles.values()], + session: this.session, + tileServerPort: this.tileServerPort, + desktop: this.desktop, + }; + } + + addSocket(ws: WebSocket) { + this.#sockets.add(ws); + ws.addEventListener('close', () => this.#sockets.delete(ws)); + ws.addEventListener('error', () => this.#sockets.delete(ws)); + } + + broadcast(event: ServerEvent) { + const msg = JSON.stringify(event); + for (const ws of this.#sockets) { + if (ws.readyState === WebSocket.OPEN) ws.send(msg); + } + } + + broadcastState() { + this.broadcast({ type: 'state', state: this.snapshot() }); + } + + async setSession(session: SessionInfo | null) { + this.session = session; + this.currentDid = session?.did; + this.currentHandle = session?.handle; + await this.persist(); + this.broadcast({ type: 'session', session }); + this.broadcastState(); + } +} + +/** Read the atile-compatible published-URI mapping. */ +export async function getIdentifier(dir: string): Promise { + const map = await readJson>(identifiersPath, {}); + return map[dir]; +} + +/** Update the atile-compatible published-URI mapping (read-merge-write). */ +export async function saveIdentifier(dir: string, uri: string) { + const map = await readJson>(identifiersPath, {}); + map[dir] = uri; + await writeJson(identifiersPath, map); +} diff --git a/src/server/tiledir.ts b/src/server/tiledir.ts new file mode 100644 index 0000000..5bf74ef --- /dev/null +++ b/src/server/tiledir.ts @@ -0,0 +1,92 @@ +// Loading a tile source directory into a MASL manifest, and bundling it into +// a .tile CAR file. This mirrors @dasl/atile's TilePublisher.loadFromDirectory +// and .bundle so that tiles built here are byte-compatible with the atile CLI, +// but stays decoupled from its AtpAgent app-password login so we can publish +// with an OAuth session instead. + +import { basename, join } from '@std/path'; +import { CODEC_RAW, create, toCidLink } from '@atcute/cid'; +import { fileTypeFromBuffer } from 'file-type'; +import mime from 'mime'; +import TileWriter from '@dasl/tile-writer'; +import type { CidLink, MaslHeaders, WriterMasl } from '@dasl/tile-lexicon'; + +export interface LoadedTileDir { + dir: string; + manifest: WriterMasl; + /** resource path (e.g. "/img/icon.png") → absolute file path */ + sourceMap: Record; +} + +async function* walkFiles(root: string, prefix = ''): AsyncGenerator { + for await (const entry of Deno.readDir(join(root, prefix))) { + const rel = `${prefix}/${entry.name}`; + if (entry.isDirectory) yield* walkFiles(root, rel); + else if (entry.isFile) yield rel; + } +} + +/** + * Read a tile source directory: `manifest.json` plus every file in the tree + * (`index.html` becomes the `/` resource). Each resource gets an AT-style blob + * `src` (`$type: blob`, raw CID, size, sniffed mimeType) and a `content-type` + * header guessed from the file name, exactly like atile does before publish. + */ +export async function loadTileDirectory(dir: string): Promise { + let manifest: WriterMasl; + try { + manifest = JSON.parse(await Deno.readTextFile(join(dir, 'manifest.json'))); + } catch (err) { + throw new Error(`Cannot read ${join(dir, 'manifest.json')}: ${(err as Error).message}`); + } + if (!manifest.resources) manifest.resources = {}; + const sourceMap: Record = {}; + for await (const file of walkFiles(dir)) { + if (file === '/manifest.json' || basename(file) === '.DS_Store') continue; + const src = join(dir, file); + const res = file === '/index.html' ? '/' : file; + sourceMap[res] = src; + const buf = await Deno.readFile(src); + const cid = await create(CODEC_RAW, buf); + if (!manifest.resources[res]) manifest.resources[res] = {}; + manifest.resources[res].src = { + $type: 'blob', + // atcute CIDs only serialise as { $link } through toJSON() + ref: (toCidLink(cid) as unknown as { toJSON(): unknown }).toJSON() as CidLink, + mimeType: (await fileTypeFromBuffer(buf))?.mime || 'application/octet-stream', + size: buf.length, + }; + if (!manifest.resources[res]['content-type']) { + manifest.resources[res]['content-type'] = mime.getType(src) || 'application/octet-stream'; + } + } + if (!manifest.resources['/']) { + throw new Error(`Tile at ${dir} has no "/" resource (add an index.html)`); + } + return { dir, manifest, sourceMap }; +} + +/** Bundle a loaded tile directory into a .tile CAR file at `out`. */ +export async function bundleTileDirectory(loaded: LoadedTileDir, out: string) { + const tw = new TileWriter(loaded.manifest); + for (const res of Object.keys(loaded.manifest.resources)) { + tw.addResource( + res, + { ...loaded.manifest.resources[res], src: undefined } as MaslHeaders, + { path: loaded.sourceMap[res] }, + ); + } + await tw.write(out); +} + +/** Bundle a tile source directory and return the .tile bytes. */ +export async function bundleToBytes(dir: string): Promise { + const loaded = await loadTileDirectory(dir); + const tmp = await Deno.makeTempFile({ suffix: '.tile' }); + try { + await bundleTileDirectory(loaded, tmp); + return await Deno.readFile(tmp); + } finally { + await Deno.remove(tmp).catch(() => {}); + } +} diff --git a/src/server/tileserver.ts b/src/server/tileserver.ts new file mode 100644 index 0000000..01784e5 --- /dev/null +++ b/src/server/tileserver.ts @@ -0,0 +1,39 @@ +// The tile-isolation server: @dasl/tile-server's router serving the +// shuttle/worker runtime on *.localhost origins. The stock router's +// load. redirect rebuilds URLs without a port, which only works on :80, +// so we mint the random subdomain ourselves (port included) in front of it. + +// @ts-types="npm:@types/express@^5.0.6" +import express from 'express'; +import { createTileLoadingRouter } from '@dasl/tile-server'; + +const LETTERS = 'abcdefghijklmnopqrstuvwxyz'; +export function randomSubdomain(length = 20): string { + const bytes = crypto.getRandomValues(new Uint8Array(length)); + return [...bytes].map((b) => LETTERS[b % LETTERS.length]).join(''); +} + +export function startTileServer(): Promise { + const app = express(); + app.set('trust proxy', 'loopback'); + let port = 0; + app.use((req, res, next) => { + if (req.hostname === 'load.localhost') { + res.redirect(303, `http://${randomSubdomain()}.localhost:${port}${req.originalUrl || '/'}`); + return; + } + next(); + }); + app.use(createTileLoadingRouter('localhost')); + return new Promise((resolve, reject) => { + // Express 5 calls the listen callback with the error on failure. + const server = app.listen(0, '127.0.0.1', (err?: Error) => { + if (err) return reject(err); + const addr = server.address(); + if (!addr || typeof addr === 'string') return reject(new Error('tile server: no port')); + port = addr.port; + resolve(port); + }); + server.on('error', reject); + }); +} diff --git a/src/server/watcher.ts b/src/server/watcher.ts new file mode 100644 index 0000000..aff99b8 --- /dev/null +++ b/src/server/watcher.ts @@ -0,0 +1,26 @@ +/** + * Watch a tile source directory; call onChange (debounced) whenever anything + * in it changes. Returns a stop function. + */ +export function watchDirectory(dir: string, onChange: () => void): () => void { + const watcher = Deno.watchFs(dir, { recursive: true }); + let timer: ReturnType | undefined; + (async () => { + for await (const event of watcher) { + if (event.kind === 'access') continue; + clearTimeout(timer); + timer = setTimeout(onChange, 200); + } + })().catch((err) => { + // watcher.close() lands here; anything else is worth seeing + if (!(err instanceof Deno.errors.BadResource)) console.warn(`watcher for ${dir}:`, err); + }); + return () => { + clearTimeout(timer); + try { + watcher.close(); + } catch { + // already closed + } + }; +} diff --git a/src/server/windows.ts b/src/server/windows.ts new file mode 100644 index 0000000..579070a --- /dev/null +++ b/src/server/windows.ts @@ -0,0 +1,51 @@ +// Deno Desktop windows (experimental API in Deno 2.9) with graceful fallback: +// when we're not running under `deno desktop`, callers get `false` and the UI +// falls back to window.open in the regular browser. + +interface BrowserWindowLike { + navigate?: (url: string) => void | Promise; + openDevtools?: (opts?: Record) => void; +} + +interface BrowserWindowCtor { + new (options?: Record): BrowserWindowLike; +} + +function ctor(): BrowserWindowCtor | undefined { + return (Deno as unknown as { BrowserWindow?: BrowserWindowCtor }).BrowserWindow; +} + +export function hasDesktopWindows(): boolean { + return typeof ctor() === 'function'; +} + +/** Adopt/setup the main startup window (first BrowserWindow call). */ +export function setupMainWindow(): void { + const BW = ctor(); + if (!BW) return; + try { + new BW({ title: 'Blastile', width: 1080, height: 760 }); + } catch (err) { + console.warn('could not set up main window:', err); + } +} + +export function openTileWindow( + url: string, + opts: { title?: string; width?: number; height?: number } = {}, +): boolean { + const BW = ctor(); + if (!BW) return false; + try { + const win = new BW({ + title: opts.title ?? 'Blastile tile', + width: opts.width ?? 720, + height: opts.height ?? 560, + }); + win.navigate?.(url); + return true; + } catch (err) { + console.warn('could not open tile window:', err); + return false; + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..9627b18 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,43 @@ +// Types shared between the Deno backend and the webview UI. + +export type TileKind = 'car' | 'at' | 'watch'; + +export interface TileEntry { + id: string; + kind: TileKind; + /** Display name, once known (from the tile manifest). */ + name?: string; + /** Absolute path to a .tile CAR file (kind: car). */ + path?: string; + /** at:// URI the tile is loaded from (kind: at). */ + atUri?: string; + /** Absolute path to the watched tile source directory (kind: watch). */ + dir?: string; + /** at:// URI this tile was published to, when known. */ + publishedUri?: string; + addedAt: string; +} + +export interface SessionInfo { + did: string; + handle?: string; +} + +export interface AppStateSnapshot { + tiles: TileEntry[]; + session: SessionInfo | null; + /** Port the @dasl/tile-server listens on (for .localhost origins). */ + tileServerPort: number; + /** Whether we run inside deno desktop (native windows available). */ + desktop: boolean; +} + +export type ServerEvent = + | { type: 'state'; state: AppStateSnapshot } + | { type: 'tile-changed'; tileId: string } + | { type: 'tile-published'; tileId: string; uri: string } + | { type: 'publish-progress'; tileId: string; message: string } + | { type: 'publish-error'; tileId: string; message: string } + | { type: 'session'; session: SessionInfo | null } + | { type: 'login-pending'; url: string } + | { type: 'error'; message: string }; diff --git a/src/ui/api.ts b/src/ui/api.ts new file mode 100644 index 0000000..e5550d7 --- /dev/null +++ b/src/ui/api.ts @@ -0,0 +1,45 @@ +import type { ServerEvent } from '../types.ts'; + +export async function api(path: string, init?: RequestInit): Promise { + const res = await fetch(path, init); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error((data as { error?: string }).error ?? `${res.status} on ${path}`); + return data as T; +} + +export function post(path: string, body?: unknown): Promise { + return api(path, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); +} + +/** WebSocket to /api/events with automatic reconnection. */ +export function connectEvents(handlers: { + onEvent: (event: ServerEvent) => void; + onOpen?: () => void; + onClose?: () => void; +}) { + let delay = 500; + const connect = () => { + const ws = new WebSocket(`ws://${location.host}/api/events`); + ws.addEventListener('open', () => { + delay = 500; + handlers.onOpen?.(); + }); + ws.addEventListener('message', (ev) => { + try { + handlers.onEvent(JSON.parse(ev.data)); + } catch (err) { + console.error('bad server event', err); + } + }); + ws.addEventListener('close', () => { + handlers.onClose?.(); + setTimeout(connect, delay); + delay = Math.min(delay * 2, 10_000); + }); + }; + connect(); +} diff --git a/src/ui/app.ts b/src/ui/app.ts new file mode 100644 index 0000000..1e146aa --- /dev/null +++ b/src/ui/app.ts @@ -0,0 +1,296 @@ +// UI entry point: boots the store <-> WebSocket wiring and registers the +// root element. With ?view= it renders a single tile +// full-window (that's what tile viewer windows navigate to). + +import { css, html, LitElement, nothing } from 'lit'; +import { SignalWatcher } from '@lit-labs/signals'; +import { effect } from 'refrakt'; +import { app } from './store.ts'; +import { connectEvents } from './api.ts'; +import { mothership } from './mothership.ts'; +import './tile-card.ts'; +import './tile-frame.ts'; + +connectEvents({ + onEvent: (event) => app.send({ type: 'server-event', event }), + onOpen: () => app.send({ type: 'ws-open' }), + onClose: () => app.send({ type: 'ws-close' }), +}); + +// The mothership can only mint .localhost origins once it knows the +// embedded tile server's port. +effect(() => { + const port = app.get().tileServerPort; + if (port) mothership.tileServerPort = port; +}); + +const viewParam = new URLSearchParams(location.search).get('view'); + +// Headless smoke test (?smoke): drive the real from the store — +// CAR fetch, shuttle iframe on a random *.localhost origin, service worker +// handshake — and count loads in document.title so a CDP harness can watch +// both the initial load (SMOKE_OK_1) and a watch-triggered reload +// (SMOKE_OK_2 after touching a file in the tile source directory). +if (new URLSearchParams(location.search).has('smoke')) { + import('./tile-frame.ts').then(() => { + const frame = document.createElement('tile-frame') as HTMLElement & { + tile?: unknown; + version: number; + }; + let loads = 0; + frame.addEventListener('tile-loaded', (ev) => { + document.title = `SMOKE_OK_${++loads}: ${(ev as CustomEvent).detail.name}`; + }); + document.body.append(frame); + const wanted = new URLSearchParams(location.search).get('smoke'); + effect(() => { + const model = app.get(); + const tile = wanted ? model.tiles.find((t) => t.id === wanted) : model.tiles[0]; + if (!model.tileServerPort || !tile) return; + frame.tile = tile; + frame.version = model.versions[tile.id] ?? 0; + }); + }); +} + +export class BlastileApp extends SignalWatcher(LitElement) { + static override properties = { + dragging: { state: true }, + }; + // `declare`d so it stays a Lit reactive accessor (a class field shadows it) + declare dragging: boolean; + + constructor() { + super(); + this.dragging = false; + } + + static override styles = css` + :host { + display: block; + min-height: 100vh; + color: var(--fg, #1a1a20); + } + .top { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.8rem 1.2rem; + border-bottom: 1px solid var(--border, #ddd); + background: var(--header-bg, #fff); + position: sticky; + top: 0; + z-index: 5; + } + .brand { + font-weight: 800; + font-size: 1.1rem; + letter-spacing: 0.02em; + } + .brand span { + color: var(--accent, #26c); + } + .conn { + font-size: 0.7rem; + color: var(--muted, #999); + } + .spacer { + flex: 1; + } + .session { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; + } + .session .who { + font-family: ui-monospace, monospace; + } + input { + font: inherit; + font-size: 0.85rem; + padding: 0.35rem 0.6rem; + border-radius: 6px; + border: 1px solid var(--border, #ccc); + background: var(--input-bg, #fff); + color: inherit; + } + button { + font: inherit; + font-size: 0.85rem; + padding: 0.35rem 0.8rem; + border-radius: 6px; + border: 1px solid var(--border, #ccc); + background: var(--btn-bg, #f7f7f9); + cursor: pointer; + } + button:hover { + background: var(--btn-hover, #ececf1); + } + button.primary { + background: var(--accent, #26c); + border-color: var(--accent, #26c); + color: white; + } + .toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.6rem; + padding: 1rem 1.2rem; + } + .toolbar form { + display: flex; + gap: 0.4rem; + flex: 1; + min-width: 260px; + } + .toolbar form input { + flex: 1; + font-family: ui-monospace, monospace; + } + .error { + margin: 0 1.2rem; + padding: 0.6rem 0.9rem; + border-radius: 8px; + background: #fdecea; + color: #b3261e; + display: flex; + gap: 1rem; + align-items: center; + } + .error .msg { + flex: 1; + white-space: pre-wrap; + word-break: break-word; + } + .tiles { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: 1rem; + padding: 1rem 1.2rem 3rem; + align-items: start; + } + .empty { + padding: 3rem 1.2rem; + color: var(--muted, #888); + text-align: center; + } + .drop { + outline: 3px dashed var(--accent, #26c); + outline-offset: -6px; + } + .viewer { + height: 100vh; + display: flex; + flex-direction: column; + } + .viewer tile-frame { + flex: 1; + } + `; + + override render() { + const model = app.get(); + if (viewParam) return this.#renderViewer(viewParam); + return html` +
(this.dragging = false)} + @drop=${this.#onDrop} + > +
+
⬢ Blastile
+
${model.connected ? '● connected' : '○ reconnecting…'}
+
+ ${this.#renderSession()} +
+
+ + +
+ + +
+
+ ${model.error ? html` +
+
${model.error}
+ +
` : nothing} + ${model.tiles.length ? html` +
+ ${model.tiles.map((tile) => html``)} +
` : html` +
+ No tiles yet. Open a .tile file, watch a tile source directory, + or load one from AT — you can also drop a .tile file anywhere here. +
`} +
+ `; + } + + #renderSession() { + const model = app.get(); + if (model.session) { + return html` +
+ ${model.session.handle ?? model.session.did} + +
`; + } + if (model.loginPending) { + return html`
Waiting for the browser sign-in…
`; + } + return html` +
+ + +
`; + } + + #renderViewer(tileId: string) { + const model = app.get(); + const tile = model.tiles.find((t) => t.id === tileId); + if (!tile) return html`
This tile is gone.
`; + const version = model.versions[tile.id] ?? 0; + document.title = tile.name ? `${tile.name} — Blastile` : 'Blastile'; + return html` +
+ +
`; + } + + #onLogin = (ev: SubmitEvent) => { + ev.preventDefault(); + const input = (ev.target as HTMLFormElement).elements.namedItem('handle') as HTMLInputElement; + const handle = input.value.trim().replace(/^@/, ''); + if (handle) app.send({ type: 'login', handle }); + }; + + #onOpenAt = (ev: SubmitEvent) => { + ev.preventDefault(); + const input = (ev.target as HTMLFormElement).elements.namedItem('uri') as HTMLInputElement; + const uri = input.value.trim(); + if (uri) { + app.send({ type: 'open-at', uri }); + input.value = ''; + } + }; + + #onDragOver = (ev: DragEvent) => { + ev.preventDefault(); + this.dragging = true; + }; + + #onDrop = (ev: DragEvent) => { + ev.preventDefault(); + this.dragging = false; + for (const file of ev.dataTransfer?.files ?? []) { + if (file.name.endsWith('.tile')) app.send({ type: 'import-file', file }); + } + }; +} + +customElements.define('blastile-app', BlastileApp); diff --git a/src/ui/mothership.ts b/src/ui/mothership.ts new file mode 100644 index 0000000..fd46770 --- /dev/null +++ b/src/ui/mothership.ts @@ -0,0 +1,31 @@ +// One mothership for the whole UI. Each rendered tile gets a freshly minted +// random *.localhost origin (same alphabet as @dasl/tile-server uses for its +// load. redirect), served by our embedded tile server — that random +// origin is what isolates tiles from the app and from each other. The stock +// TileMothership hardcodes https:// and portless hosts in getLoadSource(), +// hence the subclass. + +import { TileMothership } from '@dasl/tile-loader'; +import { CARTileLoader } from '@dasl/tile-loader/car'; +import { ATTileLoader } from '@dasl/tile-loader/at'; + +const LETTERS = 'abcdefghijklmnopqrstuvwxyz'; +function randomSubdomain(length = 20): string { + const bytes = crypto.getRandomValues(new Uint8Array(length)); + return Array.from(bytes, (b) => LETTERS[b % LETTERS.length]).join(''); +} + +export class LocalTileMothership extends TileMothership { + /** Port of the embedded @dasl/tile-server; set from app state. */ + tileServerPort = 0; + + override getLoadSource(): string { + if (!this.tileServerPort) throw new Error('tile server port not known yet'); + return `http://${randomSubdomain()}.localhost:${this.tileServerPort}/.well-known/web-tiles/`; + } +} + +export const mothership = new LocalTileMothership(); +mothership.init(); +mothership.addLoader(new CARTileLoader()); +mothership.addLoader(new ATTileLoader()); diff --git a/src/ui/store.ts b/src/ui/store.ts new file mode 100644 index 0000000..4bf2d61 --- /dev/null +++ b/src/ui/store.ts @@ -0,0 +1,176 @@ +// App state, refrakt-style: one store, a pure-ish reducer returning +// transactions whose fx talk to the backend. Server truth flows back in +// through WebSocket events. + +import { store, tx, type Tx } from 'refrakt'; +import type { ServerEvent, SessionInfo, TileEntry } from '../types.ts'; +import { post } from './api.ts'; + +export interface Model { + connected: boolean; + desktop: boolean; + tileServerPort: number; + session: SessionInfo | null; + tiles: TileEntry[]; + /** Bumped per tile on watch changes; keys tile reloads. */ + versions: Record; + /** Publish progress message per tile while a publish is running. */ + publishing: Record; + loginPending: boolean; + error: string | null; +} + +export type Action = + | { type: 'ws-open' } + | { type: 'ws-close' } + | { type: 'server-event'; event: ServerEvent } + | { type: 'error'; message: string | null } + | { type: 'login'; handle: string } + | { type: 'logout' } + | { type: 'open-car' } + | { type: 'open-at'; uri: string } + | { type: 'watch-dir' } + | { type: 'import-file'; file: File } + | { type: 'remove-tile'; id: string } + | { type: 'open-window'; id: string; width?: number; height?: number } + | { type: 'publish'; id: string } + | { type: 'tile-name'; id: string; name: string }; + +const initial: Model = { + connected: false, + desktop: false, + tileServerPort: 0, + session: null, + tiles: [], + versions: {}, + publishing: {}, + loginPending: false, + error: null, +}; + +// A fire-and-forget backend call whose effects come back over the WebSocket; +// only failures produce a follow-up action. +function call(fn: () => Promise) { + return async function* (): AsyncGenerator { + try { + await fn(); + } catch (err) { + yield { type: 'error', message: (err as Error).message }; + } + }; +} + +// Native picker via the backend (osascript on macOS); manual path entry +// everywhere else. Registering the picked path comes back via WS state. +function pickAndAdd(mode: 'file' | 'dir', kind: 'car' | 'watch', promptText: string) { + return call(async () => { + const picked = await post<{ path?: string; cancelled?: boolean; unsupported?: boolean }>( + `/api/pick?mode=${mode}`, + ); + let path = picked.path; + if (picked.unsupported) path = globalThis.prompt(promptText) ?? undefined; + if (path) await post('/api/tiles', { kind, source: path }); + }); +} + +function onServerEvent(state: Model, event: ServerEvent): Model { + switch (event.type) { + case 'state': + return { + ...state, + tiles: event.state.tiles, + session: event.state.session, + tileServerPort: event.state.tileServerPort, + desktop: event.state.desktop, + }; + case 'tile-changed': + return { + ...state, + versions: { ...state.versions, [event.tileId]: (state.versions[event.tileId] ?? 0) + 1 }, + }; + case 'session': + return { ...state, session: event.session, loginPending: false }; + case 'login-pending': + return { ...state, loginPending: true }; + case 'publish-progress': + return { ...state, publishing: { ...state.publishing, [event.tileId]: event.message } }; + case 'tile-published': { + const publishing = { ...state.publishing }; + delete publishing[event.tileId]; + return { ...state, publishing }; + } + case 'publish-error': { + const publishing = { ...state.publishing }; + delete publishing[event.tileId]; + return { ...state, publishing, error: event.message }; + } + case 'error': + return { ...state, error: event.message }; + default: + return state; + } +} + +function update(state: Model, action: Action): Tx { + switch (action.type) { + case 'ws-open': + return tx({ ...state, connected: true }); + case 'ws-close': + return tx({ ...state, connected: false }); + case 'server-event': + return tx(onServerEvent(state, action.event)); + case 'error': + return tx({ ...state, error: action.message }); + case 'login': + return tx( + { ...state, loginPending: true }, + call(() => post('/api/login', { handle: action.handle })), + ); + case 'logout': + return tx(state, call(() => post('/api/logout'))); + case 'open-car': + return tx(state, pickAndAdd('file', 'car', 'Absolute path to a .tile file:')); + case 'watch-dir': + return tx(state, pickAndAdd('dir', 'watch', 'Absolute path to a tile source directory:')); + case 'open-at': + return tx(state, call(() => post('/api/tiles', { kind: 'at', source: action.uri }))); + case 'import-file': + return tx(state, call(async () => { + const res = await fetch(`/api/import?name=${encodeURIComponent(action.file.name)}`, { + method: 'POST', + body: action.file, + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error((data as { error?: string }).error ?? `import failed (${res.status})`); + } + })); + case 'remove-tile': + return tx(state, call(() => fetch(`/api/tiles/${action.id}`, { method: 'DELETE' }))); + case 'open-window': + return tx(state, async function* () { + try { + const res = await post<{ opened: boolean; url: string }>(`/api/tiles/${action.id}/window`, { + width: action.width, + height: action.height, + }); + if (!res.opened) { + globalThis.open(res.url, '_blank', `width=${action.width ?? 720},height=${action.height ?? 560}`); + } + } catch (err) { + yield { type: 'error', message: (err as Error).message }; + } + }); + case 'publish': + return tx( + { ...state, publishing: { ...state.publishing, [action.id]: 'Starting…' } }, + call(() => post(`/api/tiles/${action.id}/publish`)), + ); + case 'tile-name': + return tx(state, call(() => post(`/api/tiles/${action.id}/meta`, { name: action.name }))); + default: + return tx(state); + } +} + +export const app = store(update, initial); diff --git a/src/ui/tile-card.ts b/src/ui/tile-card.ts new file mode 100644 index 0000000..e3a8610 --- /dev/null +++ b/src/ui/tile-card.ts @@ -0,0 +1,161 @@ +import { css, html, LitElement, nothing } from 'lit'; +import { SignalWatcher } from '@lit-labs/signals'; +import { app } from './store.ts'; +import type { TileEntry } from '../types.ts'; +import type { MaslSizing } from '@dasl/tile-lexicon'; +import './tile-frame.ts'; + +const KIND_LABEL: Record = { + car: 'CAR file', + at: 'AT Protocol', + watch: 'watched dir', +}; + +export class TileCard extends SignalWatcher(LitElement) { + static override properties = { + tile: { attribute: false }, + showing: { state: true }, + }; + + // `declare`d so they stay Lit reactive accessors (class fields shadow them) + declare tile: TileEntry; + declare showing: boolean; + #sizing?: MaslSizing; + + constructor() { + super(); + this.showing = false; + } + + static override styles = css` + :host { + display: block; + border: 1px solid var(--border, #d5d5d8); + border-radius: 8px; + background: var(--card-bg, #fff); + overflow: hidden; + } + header { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.7rem 0.9rem; + border-bottom: 1px solid var(--border, #e5e5e8); + } + header .name { + font-weight: 600; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .kind { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.15rem 0.5rem; + border-radius: 99px; + background: var(--badge-bg, #eef); + color: var(--badge-fg, #446); + white-space: nowrap; + } + .source, .published { + font-size: 0.78rem; + font-family: ui-monospace, monospace; + color: var(--muted, #777); + padding: 0.4rem 0.9rem 0; + word-break: break-all; + } + .published { + color: var(--accent, #2a6); + } + .actions { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + padding: 0.7rem 0.9rem; + } + button { + font: inherit; + font-size: 0.82rem; + padding: 0.3rem 0.7rem; + border-radius: 6px; + border: 1px solid var(--border, #ccc); + background: var(--btn-bg, #f7f7f9); + cursor: pointer; + } + button:hover { + background: var(--btn-hover, #ececf1); + } + button.danger { + color: #c0392b; + } + .progress { + font-size: 0.8rem; + padding: 0 0.9rem 0.6rem; + color: var(--accent, #26c); + } + tile-frame { + border-top: 1px solid var(--border, #e5e5e8); + } + `; + + override render() { + const model = app.get(); + const tile = this.tile; + const version = model.versions[tile.id] ?? 0; + const publishing = model.publishing[tile.id]; + const source = tile.path ?? tile.atUri ?? tile.dir ?? ''; + return html` +
+ ${tile.name ?? source} + ${KIND_LABEL[tile.kind]} +
+
${source}
+ ${tile.publishedUri ? html` +
+ published: ${tile.publishedUri} + +
` : nothing} + ${publishing ? html`
⇡ ${publishing}
` : nothing} +
+ + + ${tile.kind === 'watch' ? html` + ` : nothing} + +
+ ${this.showing ? html` + ` : nothing} + `; + } + + #onLoaded = (ev: CustomEvent) => { + this.#sizing = ev.detail.sizing; + const name = ev.detail.name as string | undefined; + if (name && name !== this.tile.name) { + app.send({ type: 'tile-name', id: this.tile.id, name }); + } + }; + + #openWindow = () => { + app.send({ + type: 'open-window', + id: this.tile.id, + width: this.#sizing?.width, + height: this.#sizing?.height, + }); + }; +} + +customElements.define('tile-card', TileCard); diff --git a/src/ui/tile-frame.ts b/src/ui/tile-frame.ts new file mode 100644 index 0000000..f500b75 --- /dev/null +++ b/src/ui/tile-frame.ts @@ -0,0 +1,109 @@ +// Renders one tile through the mothership: fetches/loads via the appropriate +// loader (CAR over local HTTP, or at://), then mounts the isolated shuttle +// iframe. Bumping `version` reloads the tile (used by watch mode). + +import { css, html, LitElement, type PropertyValues } from 'lit'; +import { mothership } from './mothership.ts'; +import type { TileEntry } from '../types.ts'; + +export class TileFrame extends LitElement { + static override properties = { + tile: { attribute: false }, + version: { type: Number }, + fill: { type: Boolean }, + }; + + // `declare` keeps these as Lit's generated reactive accessors: a real class + // field would shadow them and silently break updates. + declare tile: TileEntry | undefined; + declare version: number; + declare fill: boolean; + #token = 0; + #status: 'loading' | 'error' | 'ready' = 'loading'; + #error = ''; + + constructor() { + super(); + this.tile = undefined; + this.version = 0; + this.fill = false; + } + + static override styles = css` + :host { + display: block; + } + .status { + padding: 1rem; + font-size: 0.85rem; + color: var(--muted, #888); + } + .status.error { + color: #c0392b; + white-space: pre-wrap; + } + .mount, .mount iframe { + height: 100%; + } + :host([fill]) { + height: 100%; + } + `; + + override render() { + return html` + ${this.#status === 'loading' ? html`
Loading tile…
` : null} + ${this.#status === 'error' ? html`
${this.#error}
` : null} +
+ `; + } + + protected override updated(changed: PropertyValues) { + if (changed.has('tile') || changed.has('version')) this.#load(); + } + + #src(): string | undefined { + if (!this.tile) return undefined; + if (this.tile.kind === 'at') return this.tile.atUri; + return `${location.origin}/api/tiles/${this.tile.id}/bundle.tile?v=${this.version}`; + } + + async #load() { + const src = this.#src(); + const mount = this.renderRoot.querySelector('.mount'); + if (!src || !mount) return; + const token = ++this.#token; + this.#status = 'loading'; + this.requestUpdate(); + try { + const tile = await mothership.loadTile(src); + if (token !== this.#token) return; + if (!tile) throw new Error(`No loader could handle ${src}`); + const sizing = tile.manifest?.sizing; + const height = this.fill + ? Math.max(this.getBoundingClientRect().height, 200) + : sizing?.height ?? 360; + const ifr = tile.renderContent(height); + if (this.fill) ifr.style.height = '100%'; + mount.replaceChildren(ifr); + this.#status = 'ready'; + if (tile.manifest?.name) { + this.dispatchEvent( + new CustomEvent('tile-loaded', { + detail: { name: tile.manifest.name, sizing }, + bubbles: true, + composed: true, + }), + ); + } + } catch (err) { + if (token !== this.#token) return; + this.#status = 'error'; + this.#error = (err as Error).message; + mount.replaceChildren(); + } + this.requestUpdate(); + } +} + +customElements.define('tile-frame', TileFrame); diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..7e22e64 --- /dev/null +++ b/static/index.html @@ -0,0 +1,51 @@ + + + + + + Blastile + + + + + + + -- 2.51.2