import * as URI from "fast-uri"; import * as TID from "@atcute/tid"; import { Client, ok, simpleFetchHandler } from "@atcute/client"; import { CompositeDidDocumentResolver, LocalActorResolver, PlcDidDocumentResolver, WebDidDocumentResolver, XrpcHandleResolver, } from "@atcute/identity-resolver"; import { effect } from "~/common/signal.js"; import { decodeBlocks, parseCar, resolveRoot, rewriteCssImports, rewriteLegacyURIPath, rewriteModuleImports, tileResourceEntries, } from "./tiles.js"; // When the service worker takes control (clients.claim()), the page is about // to reload (see service-worker-loader.js reloadForNewController, which fires // on both the "sw-activated" message and the "controllerchange" event). Any // fetch() calls in flight at that moment will be cancelled by the navigation // and throw a NetworkError. We detect the controller change here so we can // suppress those spurious errors rather than flashing an error UI before the // reload. let swControllerChanging = false; if ("serviceWorker" in navigator) { navigator.serviceWorker.addEventListener("controllerchange", () => { swControllerChanging = true; }); } /** * @import {SignalReader} from "~/common/signal.d.ts" */ /** * @typedef {{ resources: Record; blocks: Map }} TileLink * * @typedef {{ html?: string; uri?: string; cid?: string; resources?: unknown; blocks?: Record; tile?: TileLink; id: string; name: string; $type: string }} LoadableItem * * `html`, `cid` and `tile` are internal, non-persisted fields supplied by * `ensureHTML` on a resolved copy of the record — never written onto the record * itself, since records usually live in a persisted collection: `html` is the * facet's resolved index document (for fragment injection), `cid` is the tile * root resource's CID (for content-addressed verification), and `tile` carries * the resolved resources + blocks so loaders can serve the tile's absolute-path * resources as Blob URLs. */ /** * Memoised resolved copies from `ensureHTML`, keyed by the input item, so a * record is only resolved once without being mutated in place. * * @type {WeakMap} */ const resolvedHTML = new WeakMap(); /** * @typedef {object} LoaderConfig * @property {string} $type - The atproto $type * @property {string} label - Human-readable label for error messages (e.g. "Facet", "Theme") * @property {() => { collection: SignalReader<{ state: "loading" } | { state: "loaded"; data: LoadableItem[] } | { state: "error" }> }} source - The collection source * @property {(item: LoadableItem) => void} render - Renders the loaded item */ /** * Sets up the full loader effect: reads URL params, resolves the item * from the collection or creates a temporary one, ensures HTML is loaded, * and calls the render callback. * * @param {LoaderConfig} config */ export function createLoader(config) { const docUrl = new URL(document.location.href); const id = docUrl.searchParams.get("id"); const name = docUrl.searchParams.get("name"); const uri = docUrl.searchParams.get("uri"); const path = docUrl.searchParams.get("path"); const containerNull = document.querySelector("#container"); if (!containerNull) throw new Error("Container not found"); const container = /** @type {HTMLDivElement} */ (containerNull); /** @type {string | null} */ let loadedId = null; /** @type {string | null} */ let loader = null; effect(() => { /** @type {LoadableItem | undefined} */ let item = undefined; if (path) { item = { $type: config.$type, id: TID.now(), name: "temporary", uri: `diffuse://${path}`, }; loader = "path"; } else if (uri) { item = { $type: config.$type, id: TID.now(), name: "temporary", uri, }; loader = "uri"; } else { const source = config.source(); const col = source.collection(); if (col.state === "error") { return renderError(container, `Failed to load ${config.label.toLowerCase()}`); } if (col.state !== "loaded") return; const collection = col.data; if (id) { item = collection.find((c) => c.id === id); loader = "id"; } else if (name) { item = collection.find((c) => c.name === name); loader = "name"; } } if (!loader) { return renderError(container, "No loader specified"); } else if (!item) { return renderError(container, `${config.label} not found`); } // Make sure HTML is loaded when a URI is specified. `ensureHTML` returns a // resolved copy rather than mutating `item`, which is usually a record inside // the (persisted) collection. let loadable = item; ensureHTML(item).then((resolved) => { loadable = resolved; }).catch((err) => { if (swControllerChanging) return; renderError(container, `Failed to load URI: ${item.uri}`, { context: err, throw: true, }); }).then(() => { if (loadable.id === loadedId) return; loadedId = loadable.id ?? null; config.render(loadable); }); }); } /** * @param {string} uri */ export async function loadURI(uri) { const u = URI.parse(uri); switch (u.scheme) { case "at": return atprotoLoader(uri); case "diffuse": return httpLoader(uri.replace(/^diffuse:\/\//, "")); case "http": case "https": return httpLoader(uri); default: throw new Error(`Unsupported scheme: ${u.scheme}`); } } /** * @typedef {{ html: string; cid?: string; resources: Record; blocks: Map }} MaterializedTile */ /** * Resolves an item's HTML without mutating it. Tiles (inline `resources` + * `blocks`, or a `.tile` CAR referenced by `uri`) resolve their `/` resource; * for those the returned copy carries `cid` (the root resource's CID, for * content-addressed integrity) and `tile` (the resolved resources + blocks, so * callers can serve the tile's absolute-path resources). Otherwise it falls * back to loading `uri` as plain HTML. * * The result is memoised per input item, so any `html`/`cid`/`tile` added here * never leaks onto the original record. Persisting those would embed the facet's * whole HTML document and, worse, a `Map` of blocks that encoders such as CBOR * cannot represent. * * @template {{ html?: string; uri?: string; cid?: string; resources?: unknown; blocks?: Record; tile?: TileLink }} T * @param {T} item * @returns {Promise} */ export async function ensureHTML(item) { if (item.html) return item; const memo = resolvedHTML.get(item); if (memo) return memo; /** @type {T} */ let loaded = item; const tile = await materializeTile(item); if (tile) { loaded = /** @type {T} */ ({ ...item, cid: tile.cid, html: tile.html, tile: { resources: tile.resources, blocks: tile.blocks, }, }); } else if (item.uri) { loaded = /** @type {T} */ ({ ...item, html: await loadURI(item.uri) }); } resolvedHTML.set(item, loaded); return loaded; } /** * Resolves a facet's full tile content: the root HTML plus its resolved * resources map and blocks. Handles inline tiles (a `resources` map plus a * `blocks` map) and `.tile` CARs referenced by `uri`. * * Stale `diffuse://` bundle paths pointing at a loose `index.html` (leftover * from before the build packaged every facet into an `index.tile` CAR) are * rewritten to the `.tile` path first, so pre-tile links, bookmarks, and * stored URIs that escaped migration still resolve to the tile instead of * falling back to a loose-file fetch (which would inject the facet HTML * without tile resource linking — its absolute `/facet.js`/`/facet.css` * references then resolve against the deployment root and 404). * * @param {{ uri?: string; resources?: unknown; blocks?: Record }} item * @returns {Promise} */ async function materializeTile(item) { if (item.resources) { const resources = /** @type {Record} */ (item.resources); const blocks = await decodeBlocks(item.blocks); const root = resolveRoot(resources, blocks); if (!root) return undefined; return { html: root.html, cid: root.cid, resources, blocks }; } const uri = typeof item.uri === "string" ? rewriteLegacyURIPath({ uri: item.uri }).uri : item.uri; if (uri?.endsWith(".tile")) { return await loadTileURI(uri); } return undefined; } /** * Fetches a `.tile` CAR and resolves its `/` resource plus its resources map * (per the DASL "Tiles in CAR" convention, the CAR header is the MASL * manifest). * * @param {string} uri * @returns {Promise} */ async function loadTileURI(uri) { const u = URI.parse(uri); if (u.scheme === "diffuse") uri = uri.replace(/^diffuse:\/\//, ""); const res = await fetch(uri); const bytes = new Uint8Array(await res.arrayBuffer()); const { manifest, blocks } = parseCar(bytes); const resources = /** @type {Record} */ (manifest.resources); const root = resolveRoot(resources, blocks); if (!root) return undefined; return { html: root.html, cid: root.cid, resources, blocks }; } /** * Resolves a facet's HTML and its resolved tile resources (for later Blob-URL * linking). Plain-HTML/URI facets resolve with empty resources. * * @param {{ html?: string; uri?: string; resources?: unknown; blocks?: Record }} facet * @returns {Promise<{ html: string; resources: Record; blocks: Map } | undefined>} */ export async function resolveFacetTile(facet) { if (facet.html) { return { html: facet.html, resources: {}, blocks: new Map() }; } const tile = await materializeTile(facet); if (tile) { return { html: tile.html, resources: tile.resources, blocks: tile.blocks }; } if (facet.uri) { return { html: await loadURI(facet.uri), resources: {}, blocks: new Map() }; } return undefined; } /** * Resolves a facet's HTML as a string, handling plain HTML and the two tile * forms (inline `resources`+`blocks`, and `.tile` CARs referenced by `uri`). * * @param {{ html?: string; uri?: string; resources?: unknown; blocks?: Record }} facet * @returns {Promise} */ export async function resolveFacetHTML(facet) { return (await resolveFacetTile(facet))?.html ?? ""; } /** @param {string} path @param {string | undefined} contentType */ function isModuleResource(path, contentType) { const type = (contentType ?? "").toLowerCase(); if (type.includes("javascript") || type.includes("ecmascript") || type === "module") { return true; } return /\.(cjs|mjs|js|jsx|ts|tsx)$/.test(path); } /** @param {string} path @param {string | undefined} contentType */ function isCssResource(path, contentType) { const type = (contentType ?? "").toLowerCase(); return type.includes("css") || /\.css$/.test(path); } /** * @param {Uint8Array | string} content * @param {string | undefined} contentType * @param {boolean} isModule */ function createBlobURL(content, contentType, isModule) { const type = contentType ?? (isModule ? "text/javascript" : "application/octet-stream"); return URL.createObjectURL(new Blob([/** @type {BlobPart} */ (content)], { type })); } /** * Makes a relative URL absolute against the Diffuse build root (which the * loader's `` targets). Values that are already absolute, anchors, query * strings, or carry a scheme are returned unchanged. * * @param {string} value * @returns {string} */ function toBuildRootUrl(value) { const v = value.trim(); if (!v || v.startsWith("/") || v.startsWith("#") || v.startsWith("?") || /^[a-z][a-z0-9+.-]*:/i.test(v)) { return value; } // Resolve against the loader's `` (the Diffuse build root) rather than // prefixing a bare `/`, so the URL stays inside the deployment's subfolder // instead of always pointing at the domain root. return new URL(v, document.baseURI).href; } /** * Serves a tile's absolute-path resources as Blob URLs and rewrites references * to them. Two kinds of rewriting happen, both limited to absolute paths (a * leading `/`) matching a tile resource: (1) DOM attributes (`src`/`href`/ * `srcset`) are pointed at the Blob URLs; (2) module resources rewrite their * own `import`/`export … from`/`import()` absolute specifiers to the target's * Blob URL (import maps are ignored for `blob:` origins). Relative references * are left untouched, so they keep their meaning against the Diffuse build root. * * @param {ParentNode} container - An element or detached fragment whose absolute * resource URLs (e.g. `/styles.css`) are rewritten to Blob URLs. * @param {Record} resources * @param {Map} blocks */ export function linkTileResources(container, resources, blocks) { const entries = tileResourceEntries(resources, blocks); if (entries.size === 0) return; /** @type {Map} */ const urls = new Map(); for (const [path, entry] of entries) { if (path === "/") continue; const isModule = isModuleResource(path, entry.contentType); urls.set(path, createBlobURL(entry.bytes, entry.contentType, isModule)); } if (urls.size === 0) return; // JS module resources import each other by absolute path; CSS files do so via // @import. Blob origins are excluded from import maps, so rewrite the specifiers // to the target's Blob URL directly. Iterate to a fixpoint so chains all point // at the final (already-rewritten) Blob URLs. const rewritable = [...urls.keys()].filter((path) => isModuleResource(path, entries.get(path)?.contentType ?? undefined) || isCssResource(path, entries.get(path)?.contentType ?? undefined) ); if (rewritable.length) { const decoder = new TextDecoder(); let changed = true; let guard = rewritable.length + 1; while (changed && guard-- > 0) { changed = false; for (const path of rewritable) { const entry = entries.get(path); if (!entry) continue; const isCss = isCssResource(path, entry.contentType); const rewrite = isCss ? rewriteCssImports : rewriteModuleImports; const source = decoder.decode(entry.bytes); const rewritten = rewrite( source, (specifier) => urls.get(specifier), ); if (rewritten !== source) { urls.set(path, createBlobURL(rewritten, entry.contentType, !isCss)); changed = true; } } } } for (const el of container.querySelectorAll("*")) { for (const attr of ["src", "href"]) { const value = el.getAttribute(attr); const linked = value ? urls.get(value) : undefined; // Absolute `/…` tile resources become blob URLs; other relative URLs are // made absolute against the Diffuse build root (the loader's ``) // so they keep working when the tile HTML is served from a blob. if (linked !== undefined) el.setAttribute(attr, linked); else if (value) el.setAttribute(attr, toBuildRootUrl(value)); } const srcset = el.getAttribute("srcset"); if (srcset) { const rewritten = srcset.split(",").map((part) => { const bits = part.trim().split(/\s+/); const linked = urls.get(bits[0]); bits[0] = linked !== undefined ? linked : toBuildRootUrl(bits[0]); return bits.join(" "); }).join(","); el.setAttribute("srcset", rewritten); } // Rewrite inline module/CSS imports (scripts haven't run yet — linking // happens on the detached fragment before it is inserted). if (el.tagName === "SCRIPT") { const text = el.textContent ?? ""; const rewritten = rewriteModuleImports(text, (spec) => urls.get(spec)); if (rewritten !== text) el.textContent = rewritten; } else if (el.tagName === "STYLE") { const text = el.textContent ?? ""; const rewritten = rewriteCssImports(text, (spec) => urls.get(spec)); if (rewritten !== text) el.textContent = rewritten; } } } /** * @param {HTMLElement} container * @param {string} error * @param {{ context?: Error; throw?: boolean }} [options] */ export function renderError(container, error, options) { document.querySelector("#diffuse-loader")?.classList.add("loaded"); container.classList.add("has-loaded"); container.innerHTML = ` `; if (options?.throw) { throw options.context ?? new Error(error); } } //////////////////////////////////////////// // 🛠️ | LOADERS //////////////////////////////////////////// /** * Resolves an `at://` URI to the record value stored at that address. * Reusable outside of loading (e.g. to preview a facet before adding it). * Throws when the URI is incomplete or can't be resolved. * * @param {string} uri * @returns {Promise} */ export async function loadAtProtoRecord(uri) { const parts = uri.replace(/at:\/\//, "").split("/"); const [repo, collection, rkey] = parts; if (!repo || !collection || !rkey) { throw new Error(`Invalid at:// URI: ${uri}`); } const resolver = new LocalActorResolver({ handleResolver: new XrpcHandleResolver({ serviceUrl: "https://public.api.bsky.app", }), didDocumentResolver: new CompositeDidDocumentResolver({ methods: { plc: new PlcDidDocumentResolver(), web: new WebDidDocumentResolver(), }, }), }); const identity = await resolver.resolve( /** @type {import("@atcute/lexicons/syntax").ActorIdentifier} */ (repo), ); const rpc = new Client({ handler: simpleFetchHandler({ service: identity.pds }), }); /** @type {any} */ const { value } = await ok( /** @type {any} */ (rpc).get("com.atproto.repo.getRecord", { params: { repo: identity.did, collection, rkey }, }), ); return value; } /** * @param {string} uri * @returns {Promise} */ async function atprotoLoader(uri) { const value = await loadAtProtoRecord(uri); if (value.html) { return value.html; } if (value.resources) { const content = await materializeTile(value); if (content) return content.html; } if (value.uri) { return loadURI(value.uri); } return ""; } /** * @param {string} url * @returns {Promise} */ async function httpLoader(url) { return fetch(url).then((res) => res.text()); }