⬢ 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
.tilewith 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 theing.dasl.maslcollection. - Watch a tile source directory: point it at a directory with a
manifest.json+index.html(the same layoutatilepublishes). 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.maslrecord is written. Theat://URI each directory was published to is remembered in~/.atile/identifiers.json, the same stable-id store theatileCLI 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, fromexamples/_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).