@inkandswitch/patchwork 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,70 @@
1
1
  # @inkandswitch/patchwork
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 61a152a: Add a `frameToolId` option to `setup`, and stop seeding a frame tool into new accounts.
8
+
9
+ `createDefaultAccount` wrote `frameToolId: "threepane"` into every account it created — a tool id belonging to one particular tool bundle, hardcoded in core. A site whose `packageListURL` didn't ship `threepane` gave every new user an account pointing at a tool that never registers, so the root view never mounted.
10
+
11
+ New accounts now leave `frameToolId` unset, and the router resolves the frame each boot:
12
+
13
+ ```
14
+ #frame= → the account's frameToolId → setup({frameToolId}) → first tool tagged frame-tool
15
+ ```
16
+
17
+ The field is still written to the account when a user picks a frame, so it stays a user preference; it is no longer decided for them at signup. Existing accounts already have it set and are unaffected.
18
+
19
+ Sites relying on the old seeded default should pass `frameToolId: "threepane"` to `setup`.
20
+
21
+ - 61a152a: Make `vite dev` work without a site hand-rolling the dev server.
22
+
23
+ The build emits the service worker, the automerge shared worker, the module-loader worker, and three wasm binaries. None of them existed in serve mode, so every one 404'd and dev only worked if a site served a previous production build's `dist/` behind vite. The plugin now serves all of them itself:
24
+
25
+ - The three worker entries are bundled on demand with esbuild and rebuilt per request, so they always reflect the source on disk. Their heavy imports resolve to the dev server's optimized-dep URLs, mirroring how the build rewrites them to `/packages/...` — import maps don't apply to `type: "module"` workers, so the URLs have to be real either way.
26
+ - `automerge.wasm`, `keyhive_wasm.wasm`, and `subduction.wasm` are served from the bootloader's own node_modules. `@inkandswitch/patchwork-bootloader/externals` gains `wasmAssets()`, which `emitWasmAssets` now uses too.
27
+ - `global.css` 404'd in dev: the generated `index.html` links it by bare specifier, which only the build resolves. Both patchwork's and the bootloader's stylesheets are now served under root-absolute paths, and the link points at them.
28
+ - `@patchwork/service-worker` called `emitFile` from `buildStart` unconditionally, which throws in serve mode — it logged "This plugin is likely not vite-compatible" three times on every dev-server start. It now skips emission when serving.
29
+ - Dep pre-bundling runs esbuild outside the plugin pipeline that applies `define`, so in dev the page fell back to the default storage prefix while the workers used the configured one — the two would have opened different IndexedDB databases. The defines are now passed to the optimizer as well.
30
+
31
+ Sites no longer need to filter `@patchwork/service-worker` out of the plugin list, serve stylesheets themselves, or keep a built `dist/` around for `vite dev`.
32
+
33
+ ### Patch Changes
34
+
35
+ - 846cfac: Update the pinned `@automerge/*` versions to `2.6.0-subduction.47`. These are exact pins in `dependencies` and `peerDependencies`, so both packages need to ship the new version together — installing a `.46` and a `.47` package side by side loads two copies of automerge-repo, and document handles from one are not recognised by the other.
36
+ - Updated dependencies [846cfac]
37
+ - Updated dependencies [61a152a]
38
+ - @inkandswitch/patchwork-bootloader@0.6.1
39
+
40
+ ## 0.5.0
41
+
42
+ ### Minor Changes
43
+
44
+ - 98be594: Collapse `siteName`, `title`, and `setup({name})` into a single `title`.
45
+
46
+ A site's name was three options across two files: `siteName` and `title` in the vite config, `name` at `setup`. They fed the same handful of strings and could disagree with each other.
47
+
48
+ `title` is now the only one. It names the html `<title>`, `apple-mobile-web-app-title`, and the manifest's `name`/`short_name` as before, and is also emitted as the `__SITE_TITLE__` define, which supplies the brand word the router appends to the document title as `"<doc> | <title>"`. It defaults to `"Patchwork"`.
49
+
50
+ - `siteName` and the `__SITE_NAME__` define are removed. If you used `siteName` only for display, rename it to `title`; if you relied on it to namespace storage, see `storagePrefix`.
51
+ - `setup({name})` is now `setup({title})`, and is only needed to override the build-time value.
52
+
53
+ - 98be594: Namespace IndexedDB and peer ids with a new build-time `storagePrefix` option.
54
+
55
+ The tab and the shared automerge worker are separate bundles that must open the same databases. Both now read the name from one place, `@inkandswitch/patchwork-bootloader/storage`, resolved from the `__STORAGE_PREFIX__` define the vite plugin emits unconditionally.
56
+
57
+ Previously each side resolved `__SITE_NAME__` itself with a different fallback — `"patchwork.inkandswitch.com"` in the worker, `"patchwork"` in the tab — so a site that never set `siteName` had its tab and worker on two different keyhive databases, and one that passed `setup({name})` split them the same way, since a runtime option never reaches the worker.
58
+
59
+ - `storagePrefix` defaults to `"patchwork"` and is settable only in the build config. Sites sharing an origin must use distinct prefixes. It is deliberately not derived from any display name: changing it points a site at empty storage, so a rebrand must not be able to change it by accident.
60
+ - Sites that relied on `siteName` to namespace their storage must now set `storagePrefix` explicitly to that same value to keep their existing databases.
61
+ - `createRepo` in `@inkandswitch/patchwork` no longer takes a site name argument.
62
+
63
+ ### Patch Changes
64
+
65
+ - Updated dependencies [98be594]
66
+ - @inkandswitch/patchwork-bootloader@0.6.0
67
+
3
68
  ## 0.4.0
4
69
 
5
70
  ### Minor Changes
package/dist/client.d.ts CHANGED
@@ -12,7 +12,8 @@ import "@inkandswitch/patchwork-elements";
12
12
  import "@inkandswitch/patchwork-providers";
13
13
 
14
14
  declare global {
15
- const __SITE_NAME__: string;
15
+ const __SITE_TITLE__: string;
16
+ const __STORAGE_PREFIX__: string;
16
17
 
17
18
  interface ImportMetaEnv {
18
19
  /**
@@ -20,7 +20,6 @@ export const createDefaultAccount = async (accountHandle, repo) => {
20
20
  }),
21
21
  ]);
22
22
  accountHandle.change((doc) => {
23
- doc.frameToolId = "threepane";
24
23
  doc.rootFolderUrl = rootFolder.url;
25
24
  doc.moduleSettingsUrl = moduleSettings.url;
26
25
  doc.contactUrl = contact.url;
package/dist/index.js CHANGED
@@ -53,8 +53,8 @@ export function setup(options = {}) {
53
53
  }
54
54
  export default setup;
55
55
  async function doSetup(options) {
56
- const siteName = options.name ??
57
- (typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "patchwork");
56
+ const siteTitle = options.title ??
57
+ (typeof __SITE_TITLE__ !== "undefined" ? __SITE_TITLE__ : "Patchwork");
58
58
  const moduleSources = resolveDefaultModules(options);
59
59
  const routing = options.routing ?? "hash";
60
60
  log("booting", options);
@@ -85,7 +85,7 @@ async function doSetup(options) {
85
85
  }
86
86
  });
87
87
  let workerAdapter = new MessageChannelNetworkAdapter(workerPort);
88
- ({ repo, hive, signerIdentity } = await createRepo(siteName, workerAdapter));
88
+ ({ repo, hive, signerIdentity } = await createRepo(workerAdapter));
89
89
  // The worker was recreated with cold state: wire the repo onto the fresh
90
90
  // port and drop the adapter stranded on the dead one.
91
91
  const bootHive = hive;
@@ -154,7 +154,8 @@ async function doSetup(options) {
154
154
  rootElement,
155
155
  repo,
156
156
  accountDocHandle,
157
- siteName,
157
+ siteTitle,
158
+ frameToolId: options.frameToolId,
158
159
  });
159
160
  }
160
161
  installReveal(rootElement, router, toolsLoaded);
package/dist/repo.d.ts CHANGED
@@ -3,7 +3,7 @@ import { type AutomergeRepoKeyhive } from "@automerge/automerge-repo-keyhive";
3
3
  import setupServiceWorker from "@inkandswitch/patchwork-bootloader";
4
4
  import type { SignerIdentity } from "./types.js";
5
5
  export declare function initWasm(): Promise<void>;
6
- export declare function createRepo(siteName: string, workerAdapter: MessageChannelNetworkAdapter): Promise<{
6
+ export declare function createRepo(workerAdapter: MessageChannelNetworkAdapter): Promise<{
7
7
  repo: Repo;
8
8
  hive?: AutomergeRepoKeyhive;
9
9
  signerIdentity?: SignerIdentity;
package/dist/repo.js CHANGED
@@ -5,6 +5,7 @@ import { initKeyhiveWasm, initializeAutomergeRepoKeyhiveWithRepo, } from "@autom
5
5
  // @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
6
6
  import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
7
7
  import { MemorySigner } from "@automerge/automerge-subduction/slim";
8
+ import { keyhiveStorageName, storagePrefix, } from "@inkandswitch/patchwork-bootloader/storage";
8
9
  import debug from "debug";
9
10
  const log = debug("patchwork:setup:repo");
10
11
  const syncServer = typeof __SYNC_SERVER__ !== "undefined"
@@ -27,14 +28,14 @@ export function initWasm() {
27
28
  }
28
29
  return wasmReady;
29
30
  }
30
- export async function createRepo(siteName, workerAdapter) {
31
+ export async function createRepo(workerAdapter) {
31
32
  if (syncServer.keyhive) {
32
33
  log("setting up keyhive");
33
34
  initKeyhiveWasm();
34
35
  const { hive, repo } = await initializeAutomergeRepoKeyhiveWithRepo({
35
36
  createRepo: (repoConfig) => new Repo(repoConfig),
36
- storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
37
- peerIdSuffix: siteName + Math.random().toString(36).slice(2),
37
+ storage: new IndexedDBWorkerStorageAdapter(keyhiveStorageName),
38
+ peerIdSuffix: storagePrefix + Math.random().toString(36).slice(2),
38
39
  networkAdapter: workerAdapter,
39
40
  automaticArchiveIngestion: true,
40
41
  cachingMode: "periodic",
@@ -61,7 +62,7 @@ export async function createRepo(siteName, workerAdapter) {
61
62
  return peerId.includes("automerge-worker");
62
63
  },
63
64
  enableRemoteHeadsGossiping: true,
64
- peerId: `${siteName}-tab-${crypto.randomUUID()}`,
65
+ peerId: `${storagePrefix}-tab-${crypto.randomUUID()}`,
65
66
  });
66
67
  const signerIdentity = {
67
68
  peerId: signer.peerId().toString(),
package/dist/router.d.ts CHANGED
@@ -9,10 +9,11 @@ export interface RouterParams {
9
9
  rootElement: HTMLElement;
10
10
  repo: Repo;
11
11
  accountDocHandle: DocHandle<AccountDoc>;
12
- siteName: string;
12
+ siteTitle: string;
13
+ frameToolId?: string;
13
14
  }
14
15
  export interface Router {
15
16
  /** Apply `location.hash` to the view. */
16
17
  route(): Promise<void>;
17
18
  }
18
- export declare function createRouter({ rootElement, repo, accountDocHandle, siteName, }: RouterParams): Router;
19
+ export declare function createRouter({ rootElement, repo, accountDocHandle, siteTitle, frameToolId, }: RouterParams): Router;
package/dist/router.js CHANGED
@@ -1,4 +1,4 @@
1
- import { isValidAutomergeUrl, isValidDocumentId, stringifyAutomergeUrl, } from "@automerge/vanillajs/slim";
1
+ import { isValidAutomergeUrl, isValidDocumentId, parseAutomergeUrl, stringifyAutomergeUrl, } from "@automerge/vanillajs/slim";
2
2
  import { openDocument } from "@inkandswitch/patchwork-elements";
3
3
  import { getRegistry, } from "@inkandswitch/patchwork-plugins";
4
4
  // Legacy big-patchwork hash shape: `<slug>--<documentId>[?…]`. The slug can
@@ -6,12 +6,12 @@ import { getRegistry, } from "@inkandswitch/patchwork-plugins";
6
6
  // anchor on the `--` before the base58 document id rather than a strict slug
7
7
  // charset.
8
8
  const BIG_PATCHWORK_HASH_REGEX = /^(?<title>[^=&?/#]*)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)/;
9
- // The `doc=` value is an automerge URL, kept literal rather than
9
+ // The `doc=` and `draft=` values are automerge URLs, kept literal rather than
10
10
  // percent-encoded so links stay readable.
11
- const RAW_HASH_KEYS = new Set(["doc"]);
11
+ const RAW_HASH_KEYS = new Set(["doc", "draft"]);
12
12
  // A stable order means re-serializing the same logical params is
13
13
  // byte-identical, avoiding spurious `hashchange` round-trips.
14
- const HASH_KEY_ORDER = ["doc", "tool", "type", "title", "frame"];
14
+ const HASH_KEY_ORDER = ["doc", "tool", "type", "title", "frame", "draft"];
15
15
  function serializeHashParams(params) {
16
16
  const keys = [...HASH_KEY_ORDER, ...params.keys()];
17
17
  const parts = [];
@@ -47,14 +47,17 @@ function registeredFrameToolId() {
47
47
  .filter((tool) => !!tool.tags?.includes("frame-tool") && !tool.unlisted)
48
48
  .at(0)?.id;
49
49
  }
50
- export function createRouter({ rootElement, repo, accountDocHandle, siteName, }) {
50
+ export function createRouter({ rootElement, repo, accountDocHandle, siteTitle, frameToolId, }) {
51
51
  const route = async () => {
52
52
  // The first call seeds the root view's tool/doc so it can mount; later
53
53
  // calls reconcile the mounted view with the hash.
54
54
  if (!rootElement.hasAttribute("tool-id")) {
55
55
  const params = new URLSearchParams(location.hash.slice(1));
56
56
  const frame = params.get("frame");
57
- const toolId = frame ?? accountDocHandle.doc().frameToolId ?? registeredFrameToolId();
57
+ const toolId = frame ??
58
+ accountDocHandle.doc().frameToolId ??
59
+ frameToolId ??
60
+ registeredFrameToolId();
58
61
  if (!toolId) {
59
62
  console.error("patchwork: no frame tool registered, nothing to mount");
60
63
  return;
@@ -107,6 +110,14 @@ export function createRouter({ rootElement, repo, accountDocHandle, siteName, })
107
110
  // `doc` is the full automerge URL, so heads live inside it and the separate
108
111
  // `heads=` param is gone.
109
112
  params.delete("heads");
113
+ // `draft` is doc-scoped (owned by the drafts plugin, opaque here):
114
+ // navigating to a different document invalidates the selection, while
115
+ // same-doc navigation (tool switches, heads changes) keeps it.
116
+ const prevDoc = docParamToUrl(params.get("doc"));
117
+ if (prevDoc &&
118
+ parseAutomergeUrl(prevDoc).documentId !== parseAutomergeUrl(url).documentId) {
119
+ params.delete("draft");
120
+ }
110
121
  params.set("doc", url);
111
122
  for (const [key, value] of [
112
123
  ["tool", toolId],
@@ -130,7 +141,7 @@ export function createRouter({ rootElement, repo, accountDocHandle, siteName, })
130
141
  return;
131
142
  const docTitle = datatype.module.getTitle(doc);
132
143
  if (docTitle)
133
- document.title = `${docTitle} | ${siteName}`;
144
+ document.title = `${docTitle} | ${siteTitle}`;
134
145
  }
135
146
  catch (e) {
136
147
  console.error("Failed to update document title", e);
@@ -1,4 +1,4 @@
1
- import type { PatchworkSiteOptions } from "./options.js";
1
+ import { type PatchworkSiteOptions } from "./options.js";
2
2
  export declare function escapeHtml(value: string): string;
3
3
  /** Builds the generated index.html as a plain string — no bundler involved. */
4
4
  export declare function buildHtml(options: PatchworkSiteOptions): string;
@@ -1,3 +1,4 @@
1
+ import { DEFAULT_TITLE } from "./options.js";
1
2
  import { resolveSyncServers, PRELOAD_WASM_ASSETS } from "./sync-servers.js";
2
3
  import { ICON_SPECS } from "./icons.js";
3
4
  const HTML_ESCAPES = {
@@ -12,7 +13,7 @@ export function escapeHtml(value) {
12
13
  }
13
14
  /** Builds the generated index.html as a plain string — no bundler involved. */
14
15
  export function buildHtml(options) {
15
- const title = options.title ?? options.siteName ?? "Patchwork";
16
+ const title = options.title ?? DEFAULT_TITLE;
16
17
  const lang = (options.html && options.html.lang) || "en";
17
18
  const entry = options.entry ?? "/src/main.ts";
18
19
  const syncServers = resolveSyncServers(options);
@@ -1,3 +1,3 @@
1
- import type { PatchworkSiteOptions } from "./options.js";
1
+ import { type PatchworkSiteOptions } from "./options.js";
2
2
  /** Builds the generated manifest.webmanifest object — no bundler involved. */
3
3
  export declare function buildManifest(options: PatchworkSiteOptions): Record<string, unknown>;
@@ -1,7 +1,8 @@
1
+ import { DEFAULT_TITLE } from "./options.js";
1
2
  import { ICON_SPECS } from "./icons.js";
2
3
  /** Builds the generated manifest.webmanifest object — no bundler involved. */
3
4
  export function buildManifest(options) {
4
- const title = options.title ?? options.siteName ?? "Patchwork";
5
+ const title = options.title ?? DEFAULT_TITLE;
5
6
  const icons = !options.icons
6
7
  ? []
7
8
  : ICON_SPECS.filter((spec) => spec.fileName === "apple-touch-icon.png" || spec.manifestPurpose).map((spec) => ({
@@ -37,10 +37,23 @@ export type PatchworkSyncServersOptions = {
37
37
  /** wss:// URL for the legacy automerge-repo sync-server channel (connected on demand via connectClassicSync). Default: wss://sync3.automerge.org. Pass false to skip its preconnect hint. */
38
38
  classic?: string | false;
39
39
  } & PatchworkPrimarySyncServerOptions;
40
+ export declare const DEFAULT_TITLE = "Patchwork";
40
41
  export interface PatchworkSiteOptions {
41
- /** -> __SITE_NAME__ define */
42
- siteName?: string;
43
- /** <title>, apple-mobile-web-app-title, manifest name */
42
+ /**
43
+ * Namespace for this site's IndexedDB databases and peer ids
44
+ * (-> __STORAGE_PREFIX__ define). Defaults to `"patchwork"`. Sites sharing
45
+ * an origin MUST use distinct prefixes.
46
+ *
47
+ * The tab and the shared automerge worker are separate bundles that have to
48
+ * open the same databases, so this is settable only here, where both of them
49
+ * receive it. Changing it on an existing site points it at empty storage.
50
+ */
51
+ storagePrefix?: string;
52
+ /**
53
+ * This site's name: `<title>`, apple-mobile-web-app-title, manifest name,
54
+ * and — via the __SITE_TITLE__ define — the brand word the router appends to
55
+ * the document title as `"<doc> | <title>"`. Defaults to `"Patchwork"`.
56
+ */
44
57
  title?: string;
45
58
  /** manifest short_name (defaults to title) */
46
59
  shortName?: string;
@@ -1 +1 @@
1
- export {};
1
+ export const DEFAULT_TITLE = "Patchwork";
package/dist/types.d.ts CHANGED
@@ -47,13 +47,24 @@ export interface PatchworkOptions {
47
47
  accountKey?: string;
48
48
  createAccount?: AccountCreator;
49
49
  /**
50
- * Brand word for this site: appended to the document title as
51
- * `"<doc> | <name>"` when a document is open (the separator is provided
52
- * for you), and used to namespace this site's storage and peer ids.
50
+ * This site's name, appended to the document title as `"<doc> | <title>"`
51
+ * when a document is open (the separator is provided for you).
53
52
  *
54
- * Defaults to the build-time `__SITE_NAME__` define, then `"patchwork"`.
53
+ * Defaults to the build-time `__SITE_TITLE__` define the vite plugin's
54
+ * `title` option, which also names the html `<title>` and the manifest —
55
+ * then `"Patchwork"`.
55
56
  */
56
- name?: string;
57
+ title?: string;
58
+ /**
59
+ * Id of the tool that frames this site — what mounts in the root view when
60
+ * the user hasn't chosen one on their account and the URL doesn't name one
61
+ * via `#frame=`. Must be a tool one of the {@link
62
+ * PatchworkOptions.packageListURL} sources registers.
63
+ *
64
+ * Falls back to the first registered tool tagged `frame-tool`, which with
65
+ * more than one of them depends on module load order.
66
+ */
67
+ frameToolId?: string;
57
68
  /** DOM id of the `<patchwork-view>` hosting the root tool. Defaults to "root". */
58
69
  rootElementId?: string;
59
70
  /**
@@ -2,7 +2,14 @@ import type { Plugin } from "vite";
2
2
  import wasm from "vite-plugin-wasm";
3
3
  import type { PatchworkVitePluginOptions } from "./patchwork-plugin.js";
4
4
  /**
5
- * Owns envPrefix, define (__SITE_NAME__/sync-server configuration),
5
+ * The build-time constants both the page bundle and the workers are compiled
6
+ * against. Values are already JSON — the shape vite's `define` and esbuild's
7
+ * `define` both take.
8
+ */
9
+ export declare function buildDefines(options?: PatchworkVitePluginOptions): Record<string, string>;
10
+ /**
11
+ * Owns envPrefix, define (__SITE_TITLE__/__STORAGE_PREFIX__/sync-server
12
+ * configuration),
6
13
  * server/preview CORS defaults, worker format + the wasm plugin, and build
7
14
  * defaults (firefox150 target, unminified, sourcemapped) — everything a site
8
15
  * used to hand-write in its own vite.config.ts. Each is switched off
@@ -1,8 +1,27 @@
1
1
  import wasm from "vite-plugin-wasm";
2
+ import { DEFAULT_STORAGE_PREFIX } from "@inkandswitch/patchwork-bootloader/storage";
3
+ import { DEFAULT_TITLE } from "../site-kit/options.js";
2
4
  import { DEFAULT_SYNC_SERVERS, resolvePrimarySyncServer, } from "../site-kit/sync-servers.js";
3
5
  const CORS_HEADERS = { "Access-Control-Allow-Origin": "*" };
4
6
  /**
5
- * Owns envPrefix, define (__SITE_NAME__/sync-server configuration),
7
+ * The build-time constants both the page bundle and the workers are compiled
8
+ * against. Values are already JSON — the shape vite's `define` and esbuild's
9
+ * `define` both take.
10
+ */
11
+ export function buildDefines(options = {}) {
12
+ const classicSyncServer = options.syncServers && typeof options.syncServers.classic === "string"
13
+ ? options.syncServers.classic
14
+ : DEFAULT_SYNC_SERVERS.classic;
15
+ return {
16
+ __SYNC_SERVER__: JSON.stringify(resolvePrimarySyncServer(options)),
17
+ __CLASSIC_SYNC_SERVER__: JSON.stringify(classicSyncServer),
18
+ __SITE_TITLE__: JSON.stringify(options.title ?? DEFAULT_TITLE),
19
+ __STORAGE_PREFIX__: JSON.stringify(options.storagePrefix ?? DEFAULT_STORAGE_PREFIX),
20
+ };
21
+ }
22
+ /**
23
+ * Owns envPrefix, define (__SITE_TITLE__/__STORAGE_PREFIX__/sync-server
24
+ * configuration),
6
25
  * server/preview CORS defaults, worker format + the wasm plugin, and build
7
26
  * defaults (firefox150 target, unminified, sourcemapped) — everything a site
8
27
  * used to hand-write in its own vite.config.ts. Each is switched off
@@ -12,20 +31,17 @@ export function configPlugin(options = {}) {
12
31
  return {
13
32
  name: "@patchwork/config",
14
33
  config() {
15
- const primarySyncServer = resolvePrimarySyncServer(options);
16
- const classicSyncServer = options.syncServers && typeof options.syncServers.classic === "string"
17
- ? options.syncServers.classic
18
- : DEFAULT_SYNC_SERVERS.classic;
19
- const define = {
20
- __SYNC_SERVER__: JSON.stringify(primarySyncServer),
21
- __CLASSIC_SYNC_SERVER__: JSON.stringify(classicSyncServer),
22
- };
23
- if (options.siteName) {
24
- define.__SITE_NAME__ = JSON.stringify(options.siteName);
25
- }
26
34
  return {
27
35
  envPrefix: ["VITE_", "PATCHWORK_"],
28
- define,
36
+ define: buildDefines(options),
37
+ optimizeDeps: {
38
+ exclude: ["@automerge/automerge-repo-storage-indexeddb"],
39
+ // Dep pre-bundling runs esbuild directly, outside the plugin
40
+ // pipeline that applies `define`. Without this the page would fall
41
+ // back to the built-in storage prefix while the workers used the
42
+ // configured one, and they have to open the same databases.
43
+ esbuildOptions: { define: buildDefines(options) },
44
+ },
29
45
  server: options.server === false
30
46
  ? undefined
31
47
  : {
@@ -0,0 +1,3 @@
1
+ import type { Plugin } from "vite";
2
+ import type { PatchworkVitePluginOptions } from "./patchwork-plugin.js";
3
+ export declare function devPlugin(options?: PatchworkVitePluginOptions): Plugin;
@@ -0,0 +1,129 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { fileURLToPath } from "node:url";
3
+ import * as esbuild from "esbuild";
4
+ import { wasmAssets } from "@inkandswitch/patchwork-bootloader/externals";
5
+ import { buildDefines } from "./config-plugin.js";
6
+ import { builtins, devDependencyId } from "./importmap-plugin.js";
7
+ import { workers } from "./service-worker-plugin.js";
8
+ const PATCHWORK_CSS = "/@inkandswitch/patchwork/global.css";
9
+ const BOOTLOADER_CSS = "/@inkandswitch/patchwork-bootloader/global.css";
10
+ // The generated index.html links the stylesheet by bare specifier, which the
11
+ // build resolves and the dev server does not. Serving the two files under
12
+ // these paths, and pointing the link at the first one, covers the gap.
13
+ const stylesheets = {
14
+ [PATCHWORK_CSS]: fileURLToPath(import.meta.resolve("@inkandswitch/patchwork/global.css")),
15
+ [BOOTLOADER_CSS]: fileURLToPath(import.meta.resolve("@inkandswitch/patchwork-bootloader/global.css")),
16
+ };
17
+ /**
18
+ * Workers are `type: "module"` scripts the browser fetches directly, so import
19
+ * maps don't apply to them and their heavy imports have to resolve to real
20
+ * URLs. The build rewrites those to the `/packages/...` chunks it emits; here
21
+ * they become the dev server's own optimized-dep URLs.
22
+ */
23
+ function externalBuiltins() {
24
+ return {
25
+ name: "patchwork-dev-externals",
26
+ setup(build) {
27
+ build.onResolve({ filter: /.*/ }, (args) => {
28
+ if (!(args.path in builtins))
29
+ return null;
30
+ return {
31
+ path: `/@id/${devDependencyId(args.path)}`,
32
+ external: true,
33
+ };
34
+ });
35
+ },
36
+ };
37
+ }
38
+ function workerContexts(options) {
39
+ const contexts = new Map();
40
+ for (const { specifier, fileName } of workers) {
41
+ contexts.set(`/${fileName}`, esbuild.context({
42
+ entryPoints: [fileURLToPath(import.meta.resolve(specifier))],
43
+ bundle: true,
44
+ write: false,
45
+ format: "esm",
46
+ platform: "browser",
47
+ target: "firefox115",
48
+ sourcemap: "inline",
49
+ define: buildDefines(options),
50
+ plugins: [externalBuiltins()],
51
+ }));
52
+ }
53
+ return contexts;
54
+ }
55
+ export function devPlugin(options = {}) {
56
+ let serve = false;
57
+ let contexts;
58
+ const wasm = new Map(wasmAssets().map(({ fileName, path }) => [`/${fileName}`, path]));
59
+ return {
60
+ name: "@patchwork/dev",
61
+ configResolved(config) {
62
+ serve = config.command === "serve";
63
+ },
64
+ transformIndexHtml(html) {
65
+ if (!serve)
66
+ return html;
67
+ return html.replace(`href="@inkandswitch/patchwork/global.css"`, `href="${PATCHWORK_CSS}"`);
68
+ },
69
+ async buildEnd() {
70
+ if (!contexts)
71
+ return;
72
+ for (const context of contexts.values())
73
+ (await context).dispose();
74
+ contexts = undefined;
75
+ },
76
+ configureServer(server) {
77
+ contexts = workerContexts(options);
78
+ server.middlewares.use(async (request, response, next) => {
79
+ const pathname = request.url?.split("?")[0] ?? "";
80
+ const stylesheet = stylesheets[pathname];
81
+ if (stylesheet) {
82
+ try {
83
+ const css = await readFile(stylesheet, "utf8");
84
+ response.setHeader("Content-Type", "text/css");
85
+ response.setHeader("Cache-Control", "no-cache");
86
+ response.end(pathname === PATCHWORK_CSS
87
+ ? css.replace(`"@inkandswitch/patchwork-bootloader/global.css"`, `"${BOOTLOADER_CSS}"`)
88
+ : css);
89
+ }
90
+ catch {
91
+ next();
92
+ }
93
+ return;
94
+ }
95
+ const binary = wasm.get(pathname);
96
+ if (binary) {
97
+ try {
98
+ response.setHeader("Content-Type", "application/wasm");
99
+ response.setHeader("Cache-Control", "no-cache");
100
+ response.end(await readFile(binary));
101
+ }
102
+ catch {
103
+ next();
104
+ }
105
+ return;
106
+ }
107
+ const context = contexts?.get(pathname);
108
+ if (!context)
109
+ return next();
110
+ try {
111
+ // Rebuilt per request rather than watched: a worker is fetched once
112
+ // when the browser starts it, and esbuild's incremental rebuild is
113
+ // cheaper than keeping a watcher per entry.
114
+ const result = await (await context).rebuild();
115
+ response.setHeader("Content-Type", "text/javascript");
116
+ response.setHeader("Cache-Control", "no-cache");
117
+ response.end(result.outputFiles[0].text);
118
+ }
119
+ catch (error) {
120
+ // A worker that fails to build is otherwise a silent 500 in a
121
+ // context with no console of its own.
122
+ server.config.logger.error(`[patchwork] failed to bundle ${pathname}: ${error}`);
123
+ response.statusCode = 500;
124
+ response.end(`console.error(${JSON.stringify(String(error))})`);
125
+ }
126
+ });
127
+ },
128
+ };
129
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Plugin } from "vite";
2
2
  import type { PatchworkVitePluginOptions } from "./patchwork-plugin.js";
3
3
  export declare const builtins: Record<string, string>;
4
+ export declare function devDependencyId(id: string): string;
4
5
  export declare function importmap(options?: PatchworkVitePluginOptions): Plugin;
@@ -37,7 +37,7 @@ function createImportMap(options) {
37
37
  Object.assign(importmap.imports, builtins);
38
38
  return { importmap, builtins };
39
39
  }
40
- function devDependencyId(id) {
40
+ export function devDependencyId(id) {
41
41
  if (id === "@inkandswitch/patchwork")
42
42
  return id;
43
43
  if (id === "@inkandswitch/patchwork-bootloader") {
@@ -1,6 +1,7 @@
1
1
  import { importmap } from "./importmap-plugin.js";
2
2
  import { serviceworker } from "./service-worker-plugin.js";
3
3
  import { configPlugin, wasm } from "./config-plugin.js";
4
+ import { devPlugin } from "./dev-plugin.js";
4
5
  import { iconsPlugin } from "./icons.js";
5
6
  import { htmlPlugin } from "./html-plugin.js";
6
7
  import { manifestPlugin } from "./manifest-plugin.js";
@@ -36,5 +37,6 @@ export default function patchwork(options) {
36
37
  netlifyPlugin(options),
37
38
  importmap(options),
38
39
  serviceworker(),
40
+ devPlugin(options),
39
41
  ].filter((plugin) => plugin != null);
40
42
  }
@@ -1,2 +1,6 @@
1
1
  import type { Plugin } from "vite";
2
+ export declare const workers: {
3
+ specifier: string;
4
+ fileName: string;
5
+ }[];
2
6
  export declare function serviceworker(): Plugin;
@@ -10,7 +10,7 @@ const self = fileURLToPath(import.meta.url);
10
10
  // own chunks. Their heavy imports are marked external and resolved to
11
11
  // /packages/... URLs (both workers are created with type:"module", so the
12
12
  // browser fetches those as regular network requests).
13
- const workers = [
13
+ export const workers = [
14
14
  {
15
15
  specifier: "@inkandswitch/patchwork-bootloader/service-worker",
16
16
  fileName: "service-worker.js",
@@ -26,10 +26,17 @@ const workers = [
26
26
  ];
27
27
  export function serviceworker() {
28
28
  const entryIds = new Set();
29
+ let serve = false;
29
30
  return {
30
31
  name: "@patchwork/service-worker",
31
32
  enforce: "pre",
33
+ configResolved(config) {
34
+ serve = config.command === "serve";
35
+ },
32
36
  async buildStart() {
37
+ // emitFile throws in serve mode.
38
+ if (serve)
39
+ return;
33
40
  for (const { specifier, fileName } of workers) {
34
41
  const resolved = await this.resolve(specifier, self);
35
42
  entryIds.add(resolved.id);
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "url": "git+https://github.com/inkandswitch/patchwork-system.git",
6
6
  "directory": "core/patchwork"
7
7
  },
8
- "version": "0.4.0",
8
+ "version": "0.6.0",
9
9
  "author": "Ink & Switch",
10
10
  "type": "module",
11
11
  "license": "MIT",
@@ -33,23 +33,23 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "@automerge/automerge": "3.3.2",
36
- "@automerge/automerge-repo": "2.6.0-subduction.46",
36
+ "@automerge/automerge-repo": "2.6.0-subduction.47",
37
37
  "@automerge/automerge-repo-keyhive": "0.3.0-alpha.sub.8b",
38
- "@automerge/automerge-repo-storage-indexeddb": "2.6.0-subduction.46",
38
+ "@automerge/automerge-repo-storage-indexeddb": "2.6.0-subduction.47",
39
39
  "@automerge/automerge-subduction": "0.16.0",
40
- "@automerge/vanillajs": "2.6.0-subduction.46",
40
+ "@automerge/vanillajs": "2.6.0-subduction.47",
41
41
  "@types/debug": "^4.1.13",
42
42
  "debug": "^4.4.3",
43
+ "esbuild": "^0.23.1",
43
44
  "sharp": "^0.35.3",
44
45
  "vite-plugin-wasm": "^3.6.0",
46
+ "@inkandswitch/patchwork-filesystem": "^0.2.5",
47
+ "@inkandswitch/patchwork-bootloader": "^0.6.1",
45
48
  "@inkandswitch/patchwork-plugins": "^1.2.0",
46
49
  "@inkandswitch/patchwork-providers": "^0.5.0",
47
- "@inkandswitch/patchwork-elements": "^6.0.0",
48
- "@inkandswitch/patchwork-filesystem": "^0.2.5",
49
- "@inkandswitch/patchwork-bootloader": "^0.5.4"
50
+ "@inkandswitch/patchwork-elements": "^6.0.0"
50
51
  },
51
52
  "devDependencies": {
52
- "esbuild": "^0.23.1",
53
53
  "rollup": "^4.61.1",
54
54
  "vite": "^7.3.5"
55
55
  },
package/src/client.d.ts CHANGED
@@ -12,7 +12,8 @@ import "@inkandswitch/patchwork-elements";
12
12
  import "@inkandswitch/patchwork-providers";
13
13
 
14
14
  declare global {
15
- const __SITE_NAME__: string;
15
+ const __SITE_TITLE__: string;
16
+ const __STORAGE_PREFIX__: string;
16
17
 
17
18
  interface ImportMetaEnv {
18
19
  /**
@@ -40,7 +40,6 @@ export const createDefaultAccount: AccountCreator<AccountDoc> = async (
40
40
  ]);
41
41
 
42
42
  accountHandle.change((doc) => {
43
- doc.frameToolId = "threepane";
44
43
  doc.rootFolderUrl = rootFolder.url;
45
44
  doc.moduleSettingsUrl = moduleSettings.url;
46
45
  doc.contactUrl = contact.url;
package/src/index.ts CHANGED
@@ -64,7 +64,7 @@ import { createDefaultAccount } from "./createAccount.js";
64
64
 
65
65
  const log = debug("patchwork:setup");
66
66
 
67
- declare const __SITE_NAME__: string;
67
+ declare const __SITE_TITLE__: string;
68
68
 
69
69
  declare global {
70
70
  interface Window {
@@ -114,9 +114,9 @@ export function setup(options: PatchworkOptions = {}): Promise<Patchwork> {
114
114
  export default setup;
115
115
 
116
116
  async function doSetup(options: PatchworkOptions): Promise<Patchwork> {
117
- const siteName =
118
- options.name ??
119
- (typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "patchwork");
117
+ const siteTitle =
118
+ options.title ??
119
+ (typeof __SITE_TITLE__ !== "undefined" ? __SITE_TITLE__ : "Patchwork");
120
120
  const moduleSources = resolveDefaultModules(options);
121
121
  const routing = options.routing ?? "hash";
122
122
 
@@ -151,10 +151,7 @@ async function doSetup(options: PatchworkOptions): Promise<Patchwork> {
151
151
  });
152
152
 
153
153
  let workerAdapter = new MessageChannelNetworkAdapter(workerPort);
154
- ({ repo, hive, signerIdentity } = await createRepo(
155
- siteName,
156
- workerAdapter
157
- ));
154
+ ({ repo, hive, signerIdentity } = await createRepo(workerAdapter));
158
155
 
159
156
  // The worker was recreated with cold state: wire the repo onto the fresh
160
157
  // port and drop the adapter stranded on the dead one.
@@ -246,7 +243,8 @@ async function doSetup(options: PatchworkOptions): Promise<Patchwork> {
246
243
  rootElement,
247
244
  repo,
248
245
  accountDocHandle,
249
- siteName,
246
+ siteTitle,
247
+ frameToolId: options.frameToolId,
250
248
  });
251
249
  }
252
250
 
package/src/repo.ts CHANGED
@@ -17,6 +17,10 @@ import {
17
17
  import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
18
18
  import { MemorySigner } from "@automerge/automerge-subduction/slim";
19
19
  import setupServiceWorker from "@inkandswitch/patchwork-bootloader";
20
+ import {
21
+ keyhiveStorageName,
22
+ storagePrefix,
23
+ } from "@inkandswitch/patchwork-bootloader/storage";
20
24
  import type { SignerIdentity } from "./types.js";
21
25
  import debug from "debug";
22
26
 
@@ -50,7 +54,6 @@ export function initWasm(): Promise<void> {
50
54
  }
51
55
 
52
56
  export async function createRepo(
53
- siteName: string,
54
57
  workerAdapter: MessageChannelNetworkAdapter
55
58
  ): Promise<{
56
59
  repo: Repo;
@@ -62,8 +65,8 @@ export async function createRepo(
62
65
  initKeyhiveWasm();
63
66
  const { hive, repo } = await initializeAutomergeRepoKeyhiveWithRepo({
64
67
  createRepo: (repoConfig) => new Repo(repoConfig),
65
- storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
66
- peerIdSuffix: siteName + Math.random().toString(36).slice(2),
68
+ storage: new IndexedDBWorkerStorageAdapter(keyhiveStorageName),
69
+ peerIdSuffix: storagePrefix + Math.random().toString(36).slice(2),
67
70
  networkAdapter: workerAdapter,
68
71
  automaticArchiveIngestion: true,
69
72
  cachingMode: "periodic",
@@ -91,7 +94,8 @@ export async function createRepo(
91
94
  return peerId.includes("automerge-worker");
92
95
  },
93
96
  enableRemoteHeadsGossiping: true,
94
- peerId: `${siteName}-tab-${crypto.randomUUID()}` as AutomergeRepo.PeerId,
97
+ peerId:
98
+ `${storagePrefix}-tab-${crypto.randomUUID()}` as AutomergeRepo.PeerId,
95
99
  });
96
100
  const signerIdentity = {
97
101
  peerId: signer.peerId().toString(),
package/src/router.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  isValidAutomergeUrl,
3
3
  isValidDocumentId,
4
+ parseAutomergeUrl,
4
5
  stringifyAutomergeUrl,
5
6
  type AutomergeUrl,
6
7
  type DocHandle,
@@ -23,12 +24,12 @@ import {
23
24
  const BIG_PATCHWORK_HASH_REGEX =
24
25
  /^(?<title>[^=&?/#]*)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)/;
25
26
 
26
- // The `doc=` value is an automerge URL, kept literal rather than
27
+ // The `doc=` and `draft=` values are automerge URLs, kept literal rather than
27
28
  // percent-encoded so links stay readable.
28
- const RAW_HASH_KEYS = new Set(["doc"]);
29
+ const RAW_HASH_KEYS = new Set(["doc", "draft"]);
29
30
  // A stable order means re-serializing the same logical params is
30
31
  // byte-identical, avoiding spurious `hashchange` round-trips.
31
- const HASH_KEY_ORDER = ["doc", "tool", "type", "title", "frame"];
32
+ const HASH_KEY_ORDER = ["doc", "tool", "type", "title", "frame", "draft"];
32
33
 
33
34
  function serializeHashParams(params: URLSearchParams): string {
34
35
  const keys = [...HASH_KEY_ORDER, ...params.keys()];
@@ -64,7 +65,8 @@ export interface RouterParams {
64
65
  rootElement: HTMLElement;
65
66
  repo: Repo;
66
67
  accountDocHandle: DocHandle<AccountDoc>;
67
- siteName: string;
68
+ siteTitle: string;
69
+ frameToolId?: string;
68
70
  }
69
71
 
70
72
  export interface Router {
@@ -82,7 +84,8 @@ export function createRouter({
82
84
  rootElement,
83
85
  repo,
84
86
  accountDocHandle,
85
- siteName,
87
+ siteTitle,
88
+ frameToolId,
86
89
  }: RouterParams): Router {
87
90
  const route = async () => {
88
91
  // The first call seeds the root view's tool/doc so it can mount; later
@@ -91,7 +94,10 @@ export function createRouter({
91
94
  const params = new URLSearchParams(location.hash.slice(1));
92
95
  const frame = params.get("frame");
93
96
  const toolId =
94
- frame ?? accountDocHandle.doc().frameToolId ?? registeredFrameToolId();
97
+ frame ??
98
+ accountDocHandle.doc().frameToolId ??
99
+ frameToolId ??
100
+ registeredFrameToolId();
95
101
  if (!toolId) {
96
102
  console.error("patchwork: no frame tool registered, nothing to mount");
97
103
  return;
@@ -166,6 +172,16 @@ export function createRouter({
166
172
  // `doc` is the full automerge URL, so heads live inside it and the separate
167
173
  // `heads=` param is gone.
168
174
  params.delete("heads");
175
+ // `draft` is doc-scoped (owned by the drafts plugin, opaque here):
176
+ // navigating to a different document invalidates the selection, while
177
+ // same-doc navigation (tool switches, heads changes) keeps it.
178
+ const prevDoc = docParamToUrl(params.get("doc"));
179
+ if (
180
+ prevDoc &&
181
+ parseAutomergeUrl(prevDoc).documentId !== parseAutomergeUrl(url).documentId
182
+ ) {
183
+ params.delete("draft");
184
+ }
169
185
  params.set("doc", url);
170
186
  for (const [key, value] of [
171
187
  ["tool", toolId],
@@ -192,7 +208,7 @@ export function createRouter({
192
208
  const docTitle = (datatype.module as DatatypeImplementation).getTitle(
193
209
  doc
194
210
  );
195
- if (docTitle) document.title = `${docTitle} | ${siteName}`;
211
+ if (docTitle) document.title = `${docTitle} | ${siteTitle}`;
196
212
  } catch (e) {
197
213
  console.error("Failed to update document title", e);
198
214
  }
@@ -1,4 +1,4 @@
1
- import type { PatchworkSiteOptions } from "./options.js";
1
+ import { DEFAULT_TITLE, type PatchworkSiteOptions } from "./options.js";
2
2
  import { resolveSyncServers, PRELOAD_WASM_ASSETS } from "./sync-servers.js";
3
3
  import { ICON_SPECS } from "./icons.js";
4
4
 
@@ -16,7 +16,7 @@ export function escapeHtml(value: string): string {
16
16
 
17
17
  /** Builds the generated index.html as a plain string — no bundler involved. */
18
18
  export function buildHtml(options: PatchworkSiteOptions): string {
19
- const title = options.title ?? options.siteName ?? "Patchwork";
19
+ const title = options.title ?? DEFAULT_TITLE;
20
20
  const lang = (options.html && options.html.lang) || "en";
21
21
  const entry = options.entry ?? "/src/main.ts";
22
22
  const syncServers = resolveSyncServers(options);
@@ -1,11 +1,11 @@
1
- import type { PatchworkSiteOptions } from "./options.js";
1
+ import { DEFAULT_TITLE, type PatchworkSiteOptions } from "./options.js";
2
2
  import { ICON_SPECS } from "./icons.js";
3
3
 
4
4
  /** Builds the generated manifest.webmanifest object — no bundler involved. */
5
5
  export function buildManifest(
6
6
  options: PatchworkSiteOptions
7
7
  ): Record<string, unknown> {
8
- const title = options.title ?? options.siteName ?? "Patchwork";
8
+ const title = options.title ?? DEFAULT_TITLE;
9
9
  const icons = !options.icons
10
10
  ? []
11
11
  : ICON_SPECS.filter(
@@ -42,10 +42,24 @@ export type PatchworkSyncServersOptions = {
42
42
  classic?: string | false;
43
43
  } & PatchworkPrimarySyncServerOptions;
44
44
 
45
+ export const DEFAULT_TITLE = "Patchwork";
46
+
45
47
  export interface PatchworkSiteOptions {
46
- /** -> __SITE_NAME__ define */
47
- siteName?: string;
48
- /** <title>, apple-mobile-web-app-title, manifest name */
48
+ /**
49
+ * Namespace for this site's IndexedDB databases and peer ids
50
+ * (-> __STORAGE_PREFIX__ define). Defaults to `"patchwork"`. Sites sharing
51
+ * an origin MUST use distinct prefixes.
52
+ *
53
+ * The tab and the shared automerge worker are separate bundles that have to
54
+ * open the same databases, so this is settable only here, where both of them
55
+ * receive it. Changing it on an existing site points it at empty storage.
56
+ */
57
+ storagePrefix?: string;
58
+ /**
59
+ * This site's name: `<title>`, apple-mobile-web-app-title, manifest name,
60
+ * and — via the __SITE_TITLE__ define — the brand word the router appends to
61
+ * the document title as `"<doc> | <title>"`. Defaults to `"Patchwork"`.
62
+ */
49
63
  title?: string;
50
64
  /** manifest short_name (defaults to title) */
51
65
  shortName?: string;
package/src/types.ts CHANGED
@@ -65,13 +65,25 @@ export interface PatchworkOptions {
65
65
  createAccount?: AccountCreator;
66
66
 
67
67
  /**
68
- * Brand word for this site: appended to the document title as
69
- * `"<doc> | <name>"` when a document is open (the separator is provided
70
- * for you), and used to namespace this site's storage and peer ids.
68
+ * This site's name, appended to the document title as `"<doc> | <title>"`
69
+ * when a document is open (the separator is provided for you).
71
70
  *
72
- * Defaults to the build-time `__SITE_NAME__` define, then `"patchwork"`.
71
+ * Defaults to the build-time `__SITE_TITLE__` define the vite plugin's
72
+ * `title` option, which also names the html `<title>` and the manifest —
73
+ * then `"Patchwork"`.
73
74
  */
74
- name?: string;
75
+ title?: string;
76
+
77
+ /**
78
+ * Id of the tool that frames this site — what mounts in the root view when
79
+ * the user hasn't chosen one on their account and the URL doesn't name one
80
+ * via `#frame=`. Must be a tool one of the {@link
81
+ * PatchworkOptions.packageListURL} sources registers.
82
+ *
83
+ * Falls back to the first registered tool tagged `frame-tool`, which with
84
+ * more than one of them depends on module load order.
85
+ */
86
+ frameToolId?: string;
75
87
 
76
88
  /** DOM id of the `<patchwork-view>` hosting the root tool. Defaults to "root". */
77
89
  rootElementId?: string;
@@ -1,5 +1,7 @@
1
1
  import type { Plugin } from "vite";
2
2
  import wasm from "vite-plugin-wasm";
3
+ import { DEFAULT_STORAGE_PREFIX } from "@inkandswitch/patchwork-bootloader/storage";
4
+ import { DEFAULT_TITLE } from "../site-kit/options.js";
3
5
  import type { PatchworkVitePluginOptions } from "./patchwork-plugin.js";
4
6
  import {
5
7
  DEFAULT_SYNC_SERVERS,
@@ -9,7 +11,30 @@ import {
9
11
  const CORS_HEADERS = { "Access-Control-Allow-Origin": "*" };
10
12
 
11
13
  /**
12
- * Owns envPrefix, define (__SITE_NAME__/sync-server configuration),
14
+ * The build-time constants both the page bundle and the workers are compiled
15
+ * against. Values are already JSON — the shape vite's `define` and esbuild's
16
+ * `define` both take.
17
+ */
18
+ export function buildDefines(
19
+ options: PatchworkVitePluginOptions = {}
20
+ ): Record<string, string> {
21
+ const classicSyncServer =
22
+ options.syncServers && typeof options.syncServers.classic === "string"
23
+ ? options.syncServers.classic
24
+ : DEFAULT_SYNC_SERVERS.classic;
25
+ return {
26
+ __SYNC_SERVER__: JSON.stringify(resolvePrimarySyncServer(options)),
27
+ __CLASSIC_SYNC_SERVER__: JSON.stringify(classicSyncServer),
28
+ __SITE_TITLE__: JSON.stringify(options.title ?? DEFAULT_TITLE),
29
+ __STORAGE_PREFIX__: JSON.stringify(
30
+ options.storagePrefix ?? DEFAULT_STORAGE_PREFIX
31
+ ),
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Owns envPrefix, define (__SITE_TITLE__/__STORAGE_PREFIX__/sync-server
37
+ * configuration),
13
38
  * server/preview CORS defaults, worker format + the wasm plugin, and build
14
39
  * defaults (firefox150 target, unminified, sourcemapped) — everything a site
15
40
  * used to hand-write in its own vite.config.ts. Each is switched off
@@ -21,22 +46,17 @@ export function configPlugin(
21
46
  return {
22
47
  name: "@patchwork/config",
23
48
  config() {
24
- const primarySyncServer = resolvePrimarySyncServer(options);
25
- const classicSyncServer =
26
- options.syncServers && typeof options.syncServers.classic === "string"
27
- ? options.syncServers.classic
28
- : DEFAULT_SYNC_SERVERS.classic;
29
- const define: Record<string, string> = {
30
- __SYNC_SERVER__: JSON.stringify(primarySyncServer),
31
- __CLASSIC_SYNC_SERVER__: JSON.stringify(classicSyncServer),
32
- };
33
- if (options.siteName) {
34
- define.__SITE_NAME__ = JSON.stringify(options.siteName);
35
- }
36
-
37
49
  return {
38
50
  envPrefix: ["VITE_", "PATCHWORK_"],
39
- define,
51
+ define: buildDefines(options),
52
+ optimizeDeps: {
53
+ exclude: ["@automerge/automerge-repo-storage-indexeddb"],
54
+ // Dep pre-bundling runs esbuild directly, outside the plugin
55
+ // pipeline that applies `define`. Without this the page would fall
56
+ // back to the built-in storage prefix while the workers used the
57
+ // configured one, and they have to open the same databases.
58
+ esbuildOptions: { define: buildDefines(options) },
59
+ },
40
60
  server:
41
61
  options.server === false
42
62
  ? undefined
@@ -0,0 +1,154 @@
1
+ import type { Plugin } from "vite";
2
+ import { readFile } from "node:fs/promises";
3
+ import { fileURLToPath } from "node:url";
4
+ import * as esbuild from "esbuild";
5
+ import { wasmAssets } from "@inkandswitch/patchwork-bootloader/externals";
6
+ import type { PatchworkVitePluginOptions } from "./patchwork-plugin.js";
7
+ import { buildDefines } from "./config-plugin.js";
8
+ import { builtins, devDependencyId } from "./importmap-plugin.js";
9
+ import { workers } from "./service-worker-plugin.js";
10
+
11
+ const PATCHWORK_CSS = "/@inkandswitch/patchwork/global.css";
12
+ const BOOTLOADER_CSS = "/@inkandswitch/patchwork-bootloader/global.css";
13
+
14
+ // The generated index.html links the stylesheet by bare specifier, which the
15
+ // build resolves and the dev server does not. Serving the two files under
16
+ // these paths, and pointing the link at the first one, covers the gap.
17
+ const stylesheets: Record<string, string> = {
18
+ [PATCHWORK_CSS]: fileURLToPath(
19
+ import.meta.resolve("@inkandswitch/patchwork/global.css")
20
+ ),
21
+ [BOOTLOADER_CSS]: fileURLToPath(
22
+ import.meta.resolve("@inkandswitch/patchwork-bootloader/global.css")
23
+ ),
24
+ };
25
+
26
+ /**
27
+ * Workers are `type: "module"` scripts the browser fetches directly, so import
28
+ * maps don't apply to them and their heavy imports have to resolve to real
29
+ * URLs. The build rewrites those to the `/packages/...` chunks it emits; here
30
+ * they become the dev server's own optimized-dep URLs.
31
+ */
32
+ function externalBuiltins(): esbuild.Plugin {
33
+ return {
34
+ name: "patchwork-dev-externals",
35
+ setup(build) {
36
+ build.onResolve({ filter: /.*/ }, (args) => {
37
+ if (!(args.path in builtins)) return null;
38
+ return {
39
+ path: `/@id/${devDependencyId(args.path)}`,
40
+ external: true,
41
+ };
42
+ });
43
+ },
44
+ };
45
+ }
46
+
47
+ function workerContexts(
48
+ options: PatchworkVitePluginOptions
49
+ ): Map<string, Promise<esbuild.BuildContext>> {
50
+ const contexts = new Map<string, Promise<esbuild.BuildContext>>();
51
+ for (const { specifier, fileName } of workers) {
52
+ contexts.set(
53
+ `/${fileName}`,
54
+ esbuild.context({
55
+ entryPoints: [fileURLToPath(import.meta.resolve(specifier))],
56
+ bundle: true,
57
+ write: false,
58
+ format: "esm",
59
+ platform: "browser",
60
+ target: "firefox115",
61
+ sourcemap: "inline",
62
+ define: buildDefines(options),
63
+ plugins: [externalBuiltins()],
64
+ })
65
+ );
66
+ }
67
+ return contexts;
68
+ }
69
+
70
+ export function devPlugin(options: PatchworkVitePluginOptions = {}): Plugin {
71
+ let serve = false;
72
+ let contexts: Map<string, Promise<esbuild.BuildContext>> | undefined;
73
+ const wasm = new Map(
74
+ wasmAssets().map(({ fileName, path }) => [`/${fileName}`, path])
75
+ );
76
+
77
+ return {
78
+ name: "@patchwork/dev",
79
+ configResolved(config) {
80
+ serve = config.command === "serve";
81
+ },
82
+ transformIndexHtml(html) {
83
+ if (!serve) return html;
84
+ return html.replace(
85
+ `href="@inkandswitch/patchwork/global.css"`,
86
+ `href="${PATCHWORK_CSS}"`
87
+ );
88
+ },
89
+ async buildEnd() {
90
+ if (!contexts) return;
91
+ for (const context of contexts.values()) (await context).dispose();
92
+ contexts = undefined;
93
+ },
94
+ configureServer(server) {
95
+ contexts = workerContexts(options);
96
+
97
+ server.middlewares.use(async (request, response, next) => {
98
+ const pathname = request.url?.split("?")[0] ?? "";
99
+
100
+ const stylesheet = stylesheets[pathname];
101
+ if (stylesheet) {
102
+ try {
103
+ const css = await readFile(stylesheet, "utf8");
104
+ response.setHeader("Content-Type", "text/css");
105
+ response.setHeader("Cache-Control", "no-cache");
106
+ response.end(
107
+ pathname === PATCHWORK_CSS
108
+ ? css.replace(
109
+ `"@inkandswitch/patchwork-bootloader/global.css"`,
110
+ `"${BOOTLOADER_CSS}"`
111
+ )
112
+ : css
113
+ );
114
+ } catch {
115
+ next();
116
+ }
117
+ return;
118
+ }
119
+
120
+ const binary = wasm.get(pathname);
121
+ if (binary) {
122
+ try {
123
+ response.setHeader("Content-Type", "application/wasm");
124
+ response.setHeader("Cache-Control", "no-cache");
125
+ response.end(await readFile(binary));
126
+ } catch {
127
+ next();
128
+ }
129
+ return;
130
+ }
131
+
132
+ const context = contexts?.get(pathname);
133
+ if (!context) return next();
134
+ try {
135
+ // Rebuilt per request rather than watched: a worker is fetched once
136
+ // when the browser starts it, and esbuild's incremental rebuild is
137
+ // cheaper than keeping a watcher per entry.
138
+ const result = await (await context).rebuild();
139
+ response.setHeader("Content-Type", "text/javascript");
140
+ response.setHeader("Cache-Control", "no-cache");
141
+ response.end(result.outputFiles![0]!.text);
142
+ } catch (error) {
143
+ // A worker that fails to build is otherwise a silent 500 in a
144
+ // context with no console of its own.
145
+ server.config.logger.error(
146
+ `[patchwork] failed to bundle ${pathname}: ${error}`
147
+ );
148
+ response.statusCode = 500;
149
+ response.end(`console.error(${JSON.stringify(String(error))})`);
150
+ }
151
+ });
152
+ },
153
+ };
154
+ }
@@ -65,7 +65,7 @@ function createImportMap(options?: PatchworkVitePluginOptions) {
65
65
  return { importmap, builtins };
66
66
  }
67
67
 
68
- function devDependencyId(id: string): string {
68
+ export function devDependencyId(id: string): string {
69
69
  if (id === "@inkandswitch/patchwork") return id;
70
70
  if (id === "@inkandswitch/patchwork-bootloader") {
71
71
  return `@inkandswitch/patchwork > ${id}`;
@@ -3,6 +3,7 @@ import type { Plugin, ServerOptions, PreviewOptions, BuildOptions } from "vite";
3
3
  import { importmap } from "./importmap-plugin.js";
4
4
  import { serviceworker } from "./service-worker-plugin.js";
5
5
  import { configPlugin, wasm } from "./config-plugin.js";
6
+ import { devPlugin } from "./dev-plugin.js";
6
7
  import { iconsPlugin } from "./icons.js";
7
8
  import { htmlPlugin } from "./html-plugin.js";
8
9
  import { manifestPlugin } from "./manifest-plugin.js";
@@ -40,6 +41,7 @@ export default function patchwork(options?: PatchworkVitePluginOptions) {
40
41
  netlifyPlugin(options),
41
42
  importmap(options),
42
43
  serviceworker(),
44
+ devPlugin(options),
43
45
  ].filter((plugin): plugin is Plugin => plugin != null);
44
46
  }
45
47
 
@@ -13,7 +13,7 @@ const self = fileURLToPath(import.meta.url);
13
13
  // own chunks. Their heavy imports are marked external and resolved to
14
14
  // /packages/... URLs (both workers are created with type:"module", so the
15
15
  // browser fetches those as regular network requests).
16
- const workers = [
16
+ export const workers = [
17
17
  {
18
18
  specifier: "@inkandswitch/patchwork-bootloader/service-worker",
19
19
  fileName: "service-worker.js",
@@ -30,11 +30,17 @@ const workers = [
30
30
 
31
31
  export function serviceworker(): Plugin {
32
32
  const entryIds = new Set<string>();
33
+ let serve = false;
33
34
 
34
35
  return {
35
36
  name: "@patchwork/service-worker",
36
37
  enforce: "pre",
38
+ configResolved(config) {
39
+ serve = config.command === "serve";
40
+ },
37
41
  async buildStart() {
42
+ // emitFile throws in serve mode.
43
+ if (serve) return;
38
44
  for (const { specifier, fileName } of workers) {
39
45
  const resolved = await this.resolve(specifier, self);
40
46
  entryIds.add(resolved!.id);