All-purpose web tiles library
README.md

⬢ Blastile #

A desktop app (a Node server in a mollusc window) for working with DASL web tiles: 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 + refrakt for the UI, styled with supram, the @dasl/tile-* packages of this monorepo for everything tile-shaped, and 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.
  • A data console for every tile: the host side of the tp-data tiles protocol. It logs everything the tile sends, lets you send it JSON payloads (⌘⏎), and notes protocol events like the ready signal.

Tile isolation #

Every rendered tile gets a fresh random <20-letters>.localhost origin, served by an embedded @dasl/tile-server on a port of its own; the UI renders through @dasl/tile-loader's mothership/shuttle/worker architecture, so tile content travels over postMessage and the tile's documents carry the shared policy from @dasl/tile-lexicon. *.localhost resolves to loopback and is a secure context in Chromium, which is why the desktop app is Chromium (Electron, through mollusc) rather than the platform webview.

Under mollusc the window runs with "network": "loopback": the renderer can reach nothing but loopback (no name resolves, IP literals hit a dead proxy, WebRTC may not send UDP). Everything that talks to the Atmosphere happens in the sidecar: OAuth, publishing, and the AT fetches the tile loader needs, which go through /api/at/fetch, a relay that lets through exactly a did:plc document and a PDS's getRecord/getBlob, nothing else (src/server/at-proxy.ts). The page's bridge (window.mollusc, with only dialog.open in it) exists in the app's top frame alone; tile viewer windows get no bridge at all.

The red-team tile that checks all of this is @dasl/containment-tile: watch ../containment-tile/tile and open its preview.

Running it #

From the monorepo root, npm install and npm run build once. Then, here:

npm run dev          # the server on http://127.0.0.1:4179 for a regular (Chromium) browser
npm run app          # the desktop app: mollusc dev, with tsc and esbuild watching
npm run app:build    # installers in dist/ (needs mollusc on your PATH)

.tile files open in Blastile from the Finder.

There are sample tiles in examples/; add them with Watch tile dir…:

  • hello-tile/: minimal tile; echoes tp-data payloads and reports pokes.
  • theme-tile/: a little site whose whole theme (colours, fonts, radius) is CSS custom properties driven live over tp-data. It tells you what it accepts when it connects, applies whatever tokens you send, and answers with { ok, applied, ignored, theme }.
  • svelte-tile/: a blank canvas with a built-in Svelte 5 engine. Send it { "source": "<Svelte component source>", "props": {…} } and it compiles and mounts it live. Its engine bundle (tile.js, from examples/_src/) was built with Deno's bundler; it is not part of this package's build.

For an AT-hosted example, try Minesweeper:

at://did:plc:izttpdp3l6vss5crelt5kcux/ing.dasl.masl/3mcjwwoqjqs2v

How it hangs together #

src/server/main.ts        http on 127.0.0.1 (BLASTILE_PORT; mollusc's in the app) + the events WebSocket
├── api.ts                static UI, /api/*, OAuth callback, the AT relay
├── at-proxy.ts           what the renderer may fetch from the Atmosphere, and the relay
├── tileserver.ts         @dasl/tile-server on *.localhost:<port>
├── tiledir.ts            tile dir → MASL manifest / .tile CAR (atile-compatible)
├── watcher.ts            fs.watch → debounced tile-changed events
├── oauth.ts              @atcute/oauth-node-client loopback client, file session store in ~/.blastile
├── publish.ts            uploadBlob + putRecord (ing.dasl.masl), TID rkeys, stable ids shared with atile
└── state.ts              the registry, sessions, connected UIs
src/ui/                   Lit + refrakt, light DOM, supram classes; esbuild bundles it to static/app.js
static/supram/            supram, vendored (npx supram copy static/supram)

The backend owns all state (tile registry in ~/.blastile/state.json, sessions, watchers) and broadcasts changes over the WebSocket; the UI is a thin refrakt store plus Lit elements. Tile viewer windows are pointed at /?view=<tile-id>: in the desktop app they are windows with no bridge (mollusc.openWindow(url, { privileged: false })), in a browser they are browser windows.

OAuth runs entirely in the server 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:<port>/oauth/callback (loopback redirect URIs are matched port-insensitively, so the port mollusc picks is fine). Publishing needs the atproto transition:generic scope, which is what the login requests.

Smoke test #

With the server running and at least one tile added, loading http://127.0.0.1:4179/?smoke[=<tile-id>] drives a real <tile-frame> through load and watch-reload, reporting into document.title (SMOKE_OK_1: <name>, then SMOKE_OK_2 after a watched file changes), handy under headless Chromium. npm test runs the unit tests (the AT relay's allowlist).