Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
81 commits
Select commit Hold shift + click to select a range
6280845
spec: paths, namespace, and a content-addressed package cache
chee Oct 28, 2026
60829f3
plan: paths phase 1 (grammar, providers, namespace)
chee Oct 28, 2026
91c220d
filesystem: path grammar (idhash, heads, live and pinned paths)
chee Oct 28, 2026
851fbd9
filesystem: path providers and the account namespace
chee Oct 28, 2026
5e83fe0
filesystem: resolve and pin live paths through the namespace
chee Oct 28, 2026
41be2fd
filesystem: resolvePath dispatches through path providers
chee Oct 28, 2026
5a4eff3
changeset: paths grammar and providers
chee Oct 28, 2026
a8372d8
filesystem: review fixes for paths
chee Oct 28, 2026
5ebd608
spike: service worker ↔ shared worker messaging across browsers
chee Oct 28, 2026
e66af94
spec: record the service worker ↔ shared worker spike results
chee Oct 28, 2026
3d43fa4
spec: a port relayed through a tab doesn't authenticate the worker
chee Oct 28, 2026
43d0fec
plan: paths phase 2 (service worker, content store, index)
chee Oct 28, 2026
ee28c9d
filesystem: walk takes a find, and remembers idhashes and warnings
chee Oct 28, 2026
8eac5e7
filesystem: readPath and listFiles
chee Oct 28, 2026
50fad4c
bootloader: content store, index and the pinned request flow
chee Oct 28, 2026
3ee67d6
bootloader: manifests, and the worker's authenticated answers
chee Oct 28, 2026
9aaf485
bootloader: resolvers answer /@/ paths and leave caching to the servi…
chee Oct 28, 2026
9c17a49
bootloader: the automerge worker answers manifests; tabs register the…
chee Oct 28, 2026
f5cdb27
service worker: serve /@/ paths from the checked content store
chee Oct 28, 2026
e9006cc
e2e: forged store entries and forged answers; bench: hashing a hit
chee Oct 28, 2026
8edb11c
spec and changeset: content store
chee Oct 28, 2026
a2299b3
content store: review fixes
chee Oct 28, 2026
92f7c7b
bootloader: test the service worker itself
chee Oct 28, 2026
f944669
spec: pinned URLs name a hash of their folder's file list
chee Oct 28, 2026
9199452
plan: paths phase 2b (pinned URLs name their file list)
chee Oct 28, 2026
4cad85f
filesystem: doc steps carry a file list hash
chee Oct 28, 2026
ddfa1d6
filesystem: file lists, reading at recorded heads, pinPath names its …
chee Oct 28, 2026
d1a6b35
bootloader: check pinned URLs against their own file list
chee Oct 28, 2026
9cc8ca0
bootloader: resolvers answer file lists and reads at heads
chee Oct 28, 2026
abea6f6
service worker: serve pinned paths checked against their file list
chee Oct 28, 2026
399a418
content store: changesets and e2e for file lists
chee Oct 28, 2026
caae9f9
content store: rebuild a redirect's one-file list; refuse partial lists
chee Oct 28, 2026
50075b7
plan: paths phase 3 (routing)
chee Oct 28, 2026
6ae06c3
add optional path to patchwork:open-document
chee Oct 27, 2026
1767c14
elements: open-document's path is the live path it was opened at
chee Oct 28, 2026
472ef66
patchwork: hash params keep a path's encoding
chee Oct 28, 2026
a763953
patchwork: route &path= — write it, open from it, drop it when it dis…
chee Oct 28, 2026
0966c92
changeset: path routing; e2e: open with &path= alone
chee Oct 28, 2026
274dc28
spec: how phase 4 looks packages up, and keeps module settings for now
chee Oct 28, 2026
e464e19
plan: paths phase 4 (packages via /packages)
chee Oct 28, 2026
a616a4a
router: review fixes for path routing
chee Oct 28, 2026
8da1f71
paths: package lists go deep; a redirect to a folder carries its list
chee Oct 28, 2026
f7f07e9
service worker: test a folder package end to end
chee Oct 28, 2026
df1abe1
filesystem: watch /packages and look packages up through it
chee Oct 28, 2026
4e5849c
filesystem: ModuleWatcher loads the /packages folder
chee Oct 28, 2026
ff61fc9
patchwork: /packages on new accounts, migration, and wiring
chee Oct 28, 2026
3619c30
changeset: packages via /packages
chee Oct 28, 2026
698e7fd
packages: stop reloading every package when /packages changes
chee Oct 28, 2026
8667e5c
packages: safer migration; tabs leave /packages to the automerge worker
chee Oct 28, 2026
19a1871
plan: paths in the sidebar (packages folder, status, open at a path)
chee Oct 28, 2026
81229b2
filesystem: the ModuleWatcher reports each package's status
chee Oct 28, 2026
9a36e52
patchwork: show /packages as a packages folder in the root folder
chee Oct 28, 2026
f4d3d70
spec: propose package-supplied path providers (section 6, not approved)
chee Oct 28, 2026
976dd54
spec: section 6 approved — providers declare datatypes, may offer edi…
chee Oct 28, 2026
0c03d47
filesystem: sub-document children, provider editing methods, extra pr…
chee Oct 28, 2026
b3dbd06
plugins: patchwork:path-provider plugins
chee Oct 28, 2026
8b0aa34
tabs and the router walk through path-provider plugins; the worker do…
chee Oct 29, 2026
dacd2ab
filesystem: package providers only for docs a walk goes through; no s…
chee Oct 29, 2026
ee74993
spec, changeset: package providers apply only to docs a walk goes thr…
chee Oct 29, 2026
b45d513
paths: one tail per provider name, non-array lists, sub-aware path ch…
chee Oct 29, 2026
e676512
router: checks don't outlive their navigation; paths are per sub-docu…
chee Oct 29, 2026
4d5a6e4
file lists: a __proto__ tail is listed; prototype names aren't entries
chee Oct 29, 2026
664e2b7
migration: unique package names, and a /packages binding that arrives…
chee Oct 29, 2026
77adaf4
packages: a /packages package that's a branches doc reloads when its …
chee Oct 29, 2026
10f3f2a
paths: pinning through a package's container lists it; values in nest…
chee Oct 29, 2026
91ce8d4
packages: entries migration moved to /packages load only through it, …
chee Oct 29, 2026
40f4358
path providers: a typed doc that's also folder-shaped goes to its typ…
chee Oct 29, 2026
44f9c0b
packages: a /packages member that can't be found doesn't hold up the …
chee Oct 29, 2026
2e233b2
boot: wait at most a second for the move into /packages
chee Oct 29, 2026
d629d8a
service worker: a /@/ URL for an account with nothing stored creates …
chee Oct 29, 2026
fd233f6
changeset: review fixes to paths, packages and the content store
chee Oct 29, 2026
dec4afc
bootloader: no URLs in console format strings; error responses withou…
chee Oct 30, 2026
a9ee639
plan: the Packages tool installs into /packages
chee Oct 30, 2026
77cdf37
spec: the Packages tool installs into /packages
chee Oct 30, 2026
1abe1b0
ModuleWatcher: packageFor maps a loaded /@/ import url back to its pa…
chee Oct 30, 2026
53a5393
spec: where the Packages tool installs, and how it tells what's insta…
chee Oct 30, 2026
db4e182
elements: part of a document opens in its document's tool
chee Oct 30, 2026
656cd3b
ModuleWatcher: discover /packages packages in the module-loader worker
chee Oct 30, 2026
701ada8
file lists: record nested folders' heads, so renamed files stay readable
chee Oct 30, 2026
b7aa61a
bootloader tests: give the harness's fake tabs time to accept
chee Oct 30, 2026
a1b1853
packages-folder: say how the watcher imports a /packages URL now
chee Oct 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/codeql-sw-logging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@inkandswitch/patchwork-bootloader": patch
---

The service worker and resolvers no longer put request URLs or paths into a console format string, where a `%c` or `%s` in a URL was interpreted. Error responses carry the error's message, not its stack; the stack goes to the console.
5 changes: 5 additions & 0 deletions .changeset/handoff-accept-timeout-module.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@inkandswitch/patchwork-bootloader": patch
---

The service worker's tab accept timeout (250ms) moves into its own module, `handoff-timing.ts`, so the node test harness can widen it. Its in-process fake tabs share the service worker's event loop, so on a loaded machine their "accepted" could miss the window and the test would wait out a 35-second broadcast. Behaviour in browsers is unchanged.
8 changes: 8 additions & 0 deletions .changeset/nested-renames-keep-pinned-urls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@inkandswitch/patchwork-filesystem": patch
"@inkandswitch/patchwork-bootloader": patch
---

A pinned URL to a file in a package's nested folder keeps working after the file, or a folder above it, is renamed. A file list now records `via` for such a file: the heads of each nested folder doc on the way to it. The service worker passes those on with `heads` when it asks a resolver for the file, and `readPath` reads any document it reaches by a link without heads at whichever of them it has. Before, the walk read nested folders as they are now, so a renamed file answered nothing when its bytes weren't already stored.

Lists with no nested folders leave `via` out and hash as before. A package with nested folders gets a new list, and so a new pinned URL, once.
6 changes: 6 additions & 0 deletions .changeset/open-document-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@inkandswitch/patchwork-elements": patch
"@inkandswitch/patchwork": patch
---

`patchwork:open-document` (and `openDocument`, `patchwork.open`, `<patchwork-view path>`) can say where a document was opened from: `path` is the live path it was opened at, like a cwd, e.g. `<acctHash>/Projects/foo` with each segment `encodeURIComponent`-encoded. A `<patchwork-view>` hands a new path to its tool without remounting it.
8 changes: 8 additions & 0 deletions .changeset/packages-discovered-off-thread.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@inkandswitch/patchwork-filesystem": patch
"@inkandswitch/patchwork-bootloader": patch
---

`/packages` packages are discovered off the main thread, like module-settings packages. The pinned `/@/` URL a `/packages` lookup answers with now goes through the ModuleWatcher's `importAutomergePackage` hook, so the browser's module-loader worker reads its plugin descriptors, and a plugin's `load()` re-imports the same pinned URL on the main thread. Before, those packages' entry points were imported on the main thread to register them.

`importPluginFromPackage(url, type, id)` takes a folder doc pinned to heads or a pinned `/@/` URL. `importPluginFromFolderDocUrl` is kept as a deprecated alias for it.
19 changes: 19 additions & 0 deletions .changeset/path-provider-plugins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@inkandswitch/patchwork-filesystem": patch
"@inkandswitch/patchwork-plugins": patch
"@inkandswitch/patchwork-bootloader": patch
"@inkandswitch/patchwork": patch
---

Packages can now say what a document of their type contains. A `patchwork:path-provider` plugin is a `PathProvider` with `supportedDatatypes`. The built-in providers (account, directory, folder, branches) are always asked first and can't be overridden. After them come plugins, in registration order. The one exception is a typed document that also has a folder's `docs` array (a tldraw canvas): a plugin declared for its type walks it, and the folder provider walks it otherwise (`yieldsToPackage`).

- `patchwork-filesystem`:
- a `Child` can be `{ name, sub: [...segments] }`, a value inside the document offered as a sub-document. A walk that ends at one reports it as `WalkEnd.sub`, and `resolveLivePath` returns `handle.sub(...segments)`;
- `PathProvider` gains the optional `add`, `rename` and `remove`, for whoever shows a provider's children to edit them;
- walks take `WalkOptions.providers` (an `ExtraProviders`), asked after the built-ins, as do `listChildren` and `listUnion`. A provider that throws is treated as having no children.
- extra providers apply only to a document a walk goes on through. Whether a document a path ends at is a container, and what a file list counts as a file, are the built-ins' call, so installing a provider doesn't change what an existing pinned URL means;
- a link with path segments (to a sub-document) isn't walked or listed as the whole document.
- a name a provider spreads over several path segments is written as the file list writes it, so every spelling of it pins to a tail the list covers.
- `patchwork-plugins`: the plugin type `PathProviderDescription`/`PathProviderPlugin`, with `pathProviderFor(doc)` (built-ins, then plugins), `packagePathProviders` (plugins only, for `WalkOptions.providers`), and `pathProviderTypes()`. `"*"` in `supportedDatatypes` is ignored.
- `patchwork-bootloader`: `createResolver` takes `providers`. A tab's resolver passes the registered plugins, and the automerge worker's passes none, since it never loads package code.
- `patchwork`: `&path=` resolves through registered path providers too, and a path that ends at a sub-document opens it at its sub-handle's URL. A `path=` that names a different part of the document `doc=` names is dropped, as one naming a different document already was.
8 changes: 8 additions & 0 deletions .changeset/paths-content-store.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@inkandswitch/patchwork-filesystem": patch
"@inkandswitch/patchwork-bootloader": patch
"@inkandswitch/patchwork": patch
"@inkandswitch/patchwork-e2e": patch
---

The service worker serves `/@/` paths. A live path (`/@/<acctHash>/Projects/foo/x.js`) redirects to its pinned form. A pinned one names its folder's file list by hash (`…/<idhash>@<heads>~<listHash>/x.js`): the list records each file's content hash and the heads it was read at, so the service worker checks the list against the URL and the bytes against the list, and serves a pinned URL's old bytes even after the file changes. Lists and bytes are kept in a content-addressed store (`patchwork-content-<acctHash>`) that anything on the origin can write and that is checked on every read. Links no longer need heads to pin what they point at, and no document id appears in a URL, a file list or a store key (a stored folder's own JSON still holds its children's URLs). A pinned URL whose list has been lost is rebuilt from its pinned heads, trying the one-file list a redirect hands out before the whole folder's; `pinPath` refuses to pin a list it couldn't read every file for (`IncompleteFileListError`). Resolvers no longer write the cache: they hand bytes back and the service worker decides what to keep, so legacy `/automerge%3A…` URLs are now cached by the service worker itself. New in `@inkandswitch/patchwork-bootloader`: `registerAccount` (patchwork calls it at boot) and `clearAccountStore`. New in `@inkandswitch/patchwork-filesystem`: `buildFileList`, `canonicalJson`, `parseFileList`, `contentHash`, `toBytes`, `readPath` (optionally at given heads), `listFiles`, `readNode`, `WalkOptions.find`, `WalkEnd.container` and `WalkEnd.tail`. The e2e suite gains a test that a forged store entry is never served.
6 changes: 6 additions & 0 deletions .changeset/paths-grammar-providers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@inkandswitch/patchwork-filesystem": patch
"@inkandswitch/patchwork-plugins": patch
---

Add paths. `idhash`, `livePath` and `parsePath` read and write live paths (`<acctHash>/Projects/foo`) and pinned ones (`<acctHash>@<heads>/<idhash>@<heads>~<listHash>/…`), where a container's step may name a hash of its file list. `resolveLivePath`, `pinnedPath`, `pinPath`, `listChildren` and `listUnion` walk an account's namespace to find a document or pin a path; `pinnedPath` and `pinPath` return the pinned path or URL together with the file list it names. Path providers (`PathProvider`, `accountProvider`, `directoryProvider`, `folderProvider`, `branchesProvider`, `providerFor`) replace `FolderStrategy`, which is now a deprecated alias of `PathProvider`. `resolvePath` now also follows a branches doc to its default branch. `AccountDoc` gains an optional `namespace` mount table; without one, `/` is `rootFolderUrl`. `isKeyhiveDoc` in `@inkandswitch/patchwork-plugins` now goes through filesystem's new `isKeyhiveDocumentId`, so the rule lives in one place.
8 changes: 8 additions & 0 deletions .changeset/paths-packages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@inkandswitch/patchwork-filesystem": patch
"@inkandswitch/patchwork-bootloader": patch
"@inkandswitch/patchwork-plugins": patch
"@inkandswitch/patchwork": patch
---

Package management is whatever is bound at `/packages` in the account's namespace. New accounts bind `/` to their root folder and `/packages` to a new Packages folder; an older account gets a Packages folder at boot, linking each automerge package its module-settings doc lists, by its `package.json` name. The ModuleWatcher loads every package in the `/packages` union (`addPackagesFolder`, `watchPackagesFolder`), keyed by document so a rename reloads nothing, and looks each one up as a pinned `/@/` URL (`trackPackagesFolder`) that it imports and the service worker checks against the package's file list. A package's list now covers everything under it, nested folders included, and a redirect to a folder carries the folder's whole list. New accounts, and migrated ones, also get a `packages` link in their root folder to that folder, so `/packages` shows in the tree like any other folder. The ModuleWatcher reports each package's status (`packageStatus(url)`: loading, loaded with its plugins, or failed with why) and fires a `"status"` event when one changes; it is now an `EventTarget`. `AccountDoc.moduleSettingsUrl` is deprecated in favour of the `/packages` binding; it's still read, deduplicated against `/packages`, since the Packages UI installs into it.
23 changes: 23 additions & 0 deletions .changeset/paths-review-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"@inkandswitch/patchwork-filesystem": patch
"@inkandswitch/patchwork-bootloader": patch
"@inkandswitch/patchwork": patch
---

Fixes to paths, packages and the content store from review.

- File lists: a file named `__proto__` is listed like any other. A tail named like an `Object.prototype` member (`constructor`, `toString`) that the list doesn't name answers 404.
- Pinning through a package's container (a path provider's doc) lists that container, so the URL serves. A doc named by a doc step is walked with package providers; one a path ends at by name is still a container or a file by the built-ins.
- A value held by a doc below a package's root (a nested directory doc) is listed with that doc's heads, and read back at them.
- `/packages`:
- a package that's a branches doc reloads when its default branch's code changes, and follows the branch when it moves;
- a member folder that can't be found doesn't hold up the others: the first listing waits up to 3s, then lists what it has, and the rest arrive as they're found;
- migration records the entries it moved in the module-settings doc (`movedToPackages`). This build loads those only through `/packages`, so removing a package there removes it. Builds that predate `/packages` still read `modules` as before;
- migration never gives two packages one name, and a `/packages` binding that syncs in mid-migration wins;
- boot waits at most a second for migration.
- Routing:
- a `doc=`/`path=` check that finishes after the hash moved on leaves the new hash alone;
- opening another part of the same document drops the path;
- a path-only hash that doesn't resolve is walked again when path providers register, so a cold boot opens a path through a package's datatype.
- The service worker opens an account's content store only to write to it, so a `/@/` link naming an unknown account leaves no cache behind.
- `ModuleWatcher.packageFor(importUrl)` says which package doc an import url was loaded for. A `/packages` package's plugins are registered under its pinned `/@/` url; this maps it back, so a tool like Packages can tell what's installed.
6 changes: 6 additions & 0 deletions .changeset/paths-routing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@inkandswitch/patchwork": patch
"@inkandswitch/patchwork-e2e": patch
---

The router keeps `&path=` in the hash: the live path a document was opened at (`<acctHash>/Projects/foo`), written whenever `patchwork:open-document` carries one and its segments kept exactly as encoded. A hash with `path=` and no `doc=` opens what the path names; one that doesn't resolve (yet) opens nothing and stays in the address bar. When `path=` names a different document than `doc=`, because something moved or was renamed, `doc=` wins and `path=` is dropped with a console warning. `path=` survives a tool switch on the same document. Paths under the site's own account resolve, and so do share links. The e2e suite gains a test that opens a document from `&path=` alone.
5 changes: 5 additions & 0 deletions .changeset/subdoc-opens-in-its-documents-tool.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@inkandswitch/patchwork-elements": patch
---

`<patchwork-view>` opens part of a document (`automerge:<id>/<path>`, a sub-handle) that has no `@patchwork` type of its own in the tool for the document it's part of, and hands that tool the sub-handle. Before, such a part, like a tldraw shape opened from the sidebar, failed with "missing @patchwork metadata".
53 changes: 53 additions & 0 deletions .github/workflows/spike-sw-sharedworker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: spike sw-sharedworker

# Records how each browser lets a service worker and a module SharedWorker
# message each other, and what it tells the service worker about the sender.
# See packages/e2e/spikes/sw-sharedworker. The JSON lands in the job summary.

permissions:
contents: read

on:
workflow_dispatch:
push:
paths:
- packages/e2e/spikes/sw-sharedworker/**
- .github/workflows/spike-sw-sharedworker.yml

jobs:
spike:
name: spike (${{ matrix.browser }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
browser: [chromium, firefox, webkit]
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4
with:
run_install: false

- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm

- name: install the e2e package
run: pnpm install --frozen-lockfile --filter @inkandswitch/patchwork-e2e

- name: install ${{ matrix.browser }}
working-directory: packages/e2e
run: pnpm exec playwright install --with-deps ${{ matrix.browser }}

- name: run the spike
working-directory: packages/e2e
run: pnpm exec playwright test -c spikes/sw-sharedworker/playwright.config.mjs --project=${{ matrix.browser }}

- uses: actions/upload-artifact@v4
if: always()
with:
name: spike-sw-sharedworker-${{ matrix.browser }}
path: packages/e2e/spikes/sw-sharedworker/results/
retention-days: 14
3 changes: 2 additions & 1 deletion core/bootloader/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@
},
"scripts": {
"build": "tsc && cp src/global.css dist/global.css",
"dev": "tsc -w --preserveWatchOutput"
"dev": "tsc -w --preserveWatchOutput",
"test": "vitest run"
}
}
25 changes: 24 additions & 1 deletion core/bootloader/src/automerge-protocol-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,12 @@ import initSubduction from "@automerge/automerge-subduction/slim";

import {
Repo,
parseAutomergeUrl,
type AutomergeUrl,
type DocumentId,
type PeerId,
} from "@automerge/automerge-repo/slim";
import { idhash } from "@inkandswitch/patchwork-filesystem";

import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb";
import type {
Expand All @@ -37,6 +40,7 @@ import {
HANDOFF_CHANNEL,
type HandoffOnlineMessage,
type HandoffRequestMessage,
type RegisterAccountMessage,
} from "./types.js";

declare const __SYNC_SERVER__: {
Expand All @@ -50,11 +54,30 @@ const syncServer =
? __SYNC_SERVER__
: { url: "wss://subduction.sync.inkandswitch.com" };

// acctHash → account url: the accounts whose `/@/` paths this worker resolves.
// Tabs tell us theirs; the hash is computed here, so no tab can name an
// account by the wrong one.
const accounts = new Map<string, AutomergeUrl>();

async function registerAccount({ url }: RegisterAccountMessage) {
try {
const { documentId } = parseAutomergeUrl(url as AutomergeUrl);
accounts.set(await idhash(documentId), url as AutomergeUrl);
} catch (error) {
console.warn("ignoring a register-account for %s", url, error);
}
}

const control = startWorkerControl("automerge-protocol-handler-worker", {
onRestart: drainForRestart,
// Held only so the tabs and the service worker can tell whether this
// instance is still around; nothing here waits for it.
lock: AUTOMERGE_WORKER_LOCK,
onMessage(data) {
if (data?.type === "register-account") {
void registerAccount(data as RegisterAccountMessage);
}
},
});
const log = control.log;

Expand Down Expand Up @@ -169,7 +192,7 @@ async function buildKeyhiveRepo(
// them can (one still booting, one from an older build, a site that brings
// its own Repo).

const resolver = createResolver(getRepo, log);
const resolver = createResolver(getRepo, log, () => accounts);
const handoffChannel = new BroadcastChannel(HANDOFF_CHANNEL);

const EVICT_IDLE_MS = 5_000;
Expand Down
64 changes: 64 additions & 0 deletions core/bootloader/src/content-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Bytes by their hash, in CacheStorage. Nothing here is trusted: any code on the origin can
// write these caches, so every read is hashed and checked against the hash a pinned URL vouches
// for, through its file list, before it's served (see pinned.ts). Reads and writes go through this one interface so the bytes can be
// encrypted at rest later.

export interface ContentStore {
get(hash: string): Promise<Uint8Array | undefined>;
put(hash: string, bytes: Uint8Array): Promise<void>;
delete(hash: string): Promise<void>;
}

export const CONTENT_CACHE_PREFIX = "patchwork-content-";

/** One store per account, apart from the `patchwork` cache of site pages and assets. */
export const contentCacheName = (acctHash: string) =>
`${CONTENT_CACHE_PREFIX}${acctHash}`;

/** A store in `cache`, keyed by `<origin>/.content/<hash>`: an internal key, never fetched. */
export function cacheStore(cache: Cache, origin: string): ContentStore {
const key = (hash: string) => `${origin}/.content/${hash}`;
return {
async get(hash) {
const response = await cache.match(key(hash));
return response ? new Uint8Array(await response.arrayBuffer()) : undefined;
},
async put(hash, bytes) {
await cache.put(key(hash), new Response(bytes as BlobPart));
},
async delete(hash) {
await cache.delete(key(hash));
},
};
}

/**
* {@link cacheStore} in the cache `name`, opened only when something is stored: a read or a
* delete of a cache that doesn't exist finds nothing, and creates nothing. Any `/@/` URL names an
* account, so opening on every request would leave a cache behind for each made-up one.
*/
export function lazyCacheStore(storage: CacheStorage, name: string, origin: string): ContentStore {
let opened: Promise<ContentStore> | undefined;
const open = () => (opened ??= storage.open(name).then((cache) => cacheStore(cache, origin)));
const existing = async () => (opened || (await storage.has(name)) ? open() : undefined);
return {
async get(hash) {
return (await existing())?.get(hash);
},
async put(hash, bytes) {
await (await open()).put(hash, bytes);
},
async delete(hash) {
await (await existing())?.delete(hash);
},
};
}

/**
* Forget everything stored for an account. There's no sign-out flow; a site calls this from its
* own. File lists the service worker holds in memory may still name these hashes, which then
* read as misses.
*/
export function clearAccountStore(acctHash: string): Promise<boolean> {
return caches.delete(contentCacheName(acctHash));
}
9 changes: 9 additions & 0 deletions core/bootloader/src/handoff-timing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// How long the service worker gives a tab to say it'll resolve a request before
// asking the next one. A tab that hasn't been handed its Repo yet says it can't
// at once, so this is for one that can't answer at all: a frozen background
// tab, or one from a build before tabs resolved.
//
// Its own module so the node test harness can widen it: there, a fake tab runs
// on the service worker's own event loop, and its "accepted" can wait behind
// its own resolving on a loaded machine.
export const ACCEPT_TIMEOUT_MS = 250;
Loading
Loading