diff --git a/docs/adr/0003-distribute-prebuilt-worker-bundles.md b/docs/adr/0003-distribute-prebuilt-worker-bundles.md new file mode 100644 index 0000000..1d5c084 --- /dev/null +++ b/docs/adr/0003-distribute-prebuilt-worker-bundles.md @@ -0,0 +1,18 @@ +# Distribute pre-built worker bundles inside `@tabmesh/core` + +`@tabmesh/core` ships a pre-built `dist/worker.js` (the SharedWorker entry) and `dist/sw.js` (the Service Worker entry) alongside its library code. Consumers copy these files to their app's static-asset directory and TabMesh's `workerUrl` / `serviceWorker.scriptUrl` config points at the served paths. The README documents the one-line copy step for Vite, Webpack, and Next. + +We chose this over a Vite plugin or runtime Blob-URL inlining because pre-built files + a documented copy step is the lowest-friction option that works in every bundler and respects strict Content Security Policies. + +## Considered Options + +- **Pre-built bundles in `dist/` (chosen)**: Works in any bundler. CSP-friendly (served from the app origin). One manual copy step. +- **`@tabmesh/vite-plugin`**: Best DX for the ~70% of users on Vite. But adds a package to maintain and doesn't help Webpack / Next / esbuild / Rollup users — who would still need the manual path. We would also still need to ship the pre-built file for the plugin to copy. +- **Runtime Blob URL**: Bundle the worker source as a string and `URL.createObjectURL(new Blob([...]))` at runtime. Zero copy steps. But strict CSPs reject `blob:` worker sources, and `SharedWorker` from a Blob URL has spec edge cases (named workers across tabs may not coalesce reliably). + +## Consequences + +- The `dist/` layout for `@tabmesh/core` becomes part of the public contract. Renaming `dist/worker.js` is a breaking change. +- Users who upgrade `@tabmesh/core` to a version with a worker-protocol change must remember to re-copy the file. The `workerVersion` config (PR #11) makes this less catastrophic — old tabs talk to the old worker until reload. +- Build pipeline must run the worker/SW esbuild steps before `vite build` of the core package, and the resulting files must be included in `files` in `package.json`. +- A future Vite plugin can layer on top of this without breaking changes — it would just copy the same pre-built files. diff --git a/docs/adr/0004-vitepress-single-site-at-tabmesh-dev.md b/docs/adr/0004-vitepress-single-site-at-tabmesh-dev.md new file mode 100644 index 0000000..418a1b3 --- /dev/null +++ b/docs/adr/0004-vitepress-single-site-at-tabmesh-dev.md @@ -0,0 +1,20 @@ +# Single VitePress site at tabmesh.dev for docs, roadmap, and playground + +The TabMesh public surface (landing, guides, API reference, ADRs, roadmap, and the interactive playground) lives in a single VitePress site deployed to `tabmesh.dev` via Vercel. The current React playground (`packages/playground`) is embedded as an iframe inside a `/playground` route rather than re-implemented as a VitePress component, which keeps the existing Playwright e2e suite pointed at an unchanged target. + +We chose VitePress over Docusaurus, Nextra, and Mintlify because it shares the Vite/TypeScript stack already used everywhere else in the repo, has zero per-page hosting cost, and gives the same single-domain navigation experience that Vite, Vitest, and Vue's own sites use. + +## Considered Options + +- **VitePress, single site, iframe playground (chosen)**: One Vercel deploy, one repo, sidebar nav covers docs/roadmap/playground uniformly. Iframe means the playground stays a separate Vite app that tests can drive directly. +- **Docusaurus**: more popular and React-native, but heavier (Webpack-based vs Vite-based), and pulling in a React docs framework when our playground happens to be React but the rest of the repo is build-tool-agnostic feels arbitrary. Plugin ecosystem is broader, which we don't currently need. +- **Mintlify**: best out-of-the-box design and search. Hosted SaaS — gives up ownership of the deploy pipeline and the content lives in their format. Vendor lock-in is the main reason to skip for an OSS library. +- **Nextra (Next.js + MDX)**: solid choice for MDX-first docs. Adds a Next.js dependency we wouldn't otherwise need. Probably overkill for the size of the docs. +- **Two deploys (docs at root, playground at app.tabmesh.dev)**: avoids the iframe but doubles the deploy pipeline and breaks unified search/navigation. Net negative. + +## Consequences + +- The docs source lives in `docs/` (which already exists for ADRs). VitePress reads this directory; ADRs become a site section automatically rather than internal-only. +- Custom components in the docs site (interactive examples beyond the playground) must be Vue-flavored, since VitePress is Vue under the hood. For a small library this is fine; if we later want richer interactive content we'd evaluate the migration cost again. +- The playground iframe means cross-frame `postMessage` is the only escape hatch if the docs site ever needs to talk to the playground (e.g., "click here to load this todo into the playground"). Not currently planned. +- Vercel deploy is configured at the repo root; build command runs the docs build (`pnpm --filter ./docs build` or similar) plus the playground's existing build. Both static-asset directories are merged at deploy time. diff --git a/packages/core/package.json b/packages/core/package.json index ef52af6..2fd0700 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -40,20 +40,21 @@ "./service-worker": { "import": "./dist/service-worker/index.js", "types": "./dist/service-worker/index.d.ts" - } + }, + "./worker.js": "./dist/tabmesh-worker.js", + "./sw.js": "./dist/tabmesh-sw.js" }, "files": ["dist"], "sideEffects": false, "scripts": { - "build": "rm -rf dist tsconfig.tsbuildinfo && tsc && vite build", + "build": "rm -rf dist tsconfig.tsbuildinfo && tsc && vite build && node scripts/build-bundles.mjs", "typecheck": "tsc --noEmit", "test": "vitest", "test:coverage": "vitest --coverage" }, - "peerDependencies": {}, - "dependencies": {}, "devDependencies": { "@types/node": "^20.11.0", + "esbuild": "^0.28.0", "jsdom": "^23.0.0", "typescript": "^5.2.0", "vite": "^5.0.0", diff --git a/packages/core/scripts/build-bundles.mjs b/packages/core/scripts/build-bundles.mjs new file mode 100644 index 0000000..46e627b --- /dev/null +++ b/packages/core/scripts/build-bundles.mjs @@ -0,0 +1,49 @@ +/** + * Bundles the SharedWorker and Service Worker entry points into single-file + * IIFEs that ship as part of `@tabmesh/core`'s `dist/`. Consumers copy these + * files to their app's static-asset directory (e.g. `public/`) and point + * TabMesh's `workerUrl` / `serviceWorker.scriptUrl` at the served paths. + * + * Usage: node scripts/build-bundles.mjs + * + * Why bundled IIFEs and not ESM: + * - SharedWorker / ServiceWorker constructors accept a script URL. The + * browser fetches and parses that script; ESM imports inside it would + * require classic-vs-module worker types that vary across environments. + * IIFE is the simplest format that "just works" everywhere. + * + * See: docs/adr/0003-distribute-prebuilt-worker-bundles.md + */ + +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, '..'); + +const targets = [ + { + entry: resolve(root, 'src/worker/tabmesh-worker.ts'), + outfile: resolve(root, 'dist/tabmesh-worker.js'), + }, + { + entry: resolve(root, 'src/service-worker/tabmesh-sw.ts'), + outfile: resolve(root, 'dist/tabmesh-sw.js'), + }, +]; + +await Promise.all( + targets.map((t) => + build({ + entryPoints: [t.entry], + bundle: true, + format: 'iife', + target: 'es2020', + outfile: t.outfile, + sourcemap: false, + legalComments: 'none', + logLevel: 'info', + }) + ) +); diff --git a/packages/playground/public/tabmesh-sw.js b/packages/playground/public/tabmesh-sw.js index 3a64756..dae6bfa 100644 --- a/packages/playground/public/tabmesh-sw.js +++ b/packages/playground/public/tabmesh-sw.js @@ -1,6 +1,6 @@ "use strict"; (() => { - // ../core/src/service-worker/tabmesh-sw.ts + // src/service-worker/tabmesh-sw.ts var config = null; var STORE_NAME = "events"; var SYNC_TAG_PREFIX = "tabmesh-sync:"; diff --git a/packages/playground/public/tabmesh-worker.js b/packages/playground/public/tabmesh-worker.js index 1995909..fc9b735 100644 --- a/packages/playground/public/tabmesh-worker.js +++ b/packages/playground/public/tabmesh-worker.js @@ -1,6 +1,6 @@ "use strict"; (() => { - // ../core/src/storage/EventOutbox.ts + // src/storage/EventOutbox.ts var STORE_NAME = "events"; var DB_VERSION = 1; var DEFAULTS = { @@ -197,10 +197,10 @@ } }; - // ../core/src/types.ts + // src/types.ts var PROTOCOL_VERSION = 1; - // ../core/src/worker/tabmesh-worker.ts + // src/worker/tabmesh-worker.ts var ports = /* @__PURE__ */ new Map(); var drainScheduled = false; var drainRunning = false; diff --git a/packages/playground/scripts/build-sw.mjs b/packages/playground/scripts/build-sw.mjs index b2a1bfb..a2f618d 100644 --- a/packages/playground/scripts/build-sw.mjs +++ b/packages/playground/scripts/build-sw.mjs @@ -1,26 +1,28 @@ /** - * Bundles the Service Worker entry from @tabmesh/core into a single IIFE - * served at /tabmesh-sw.js. Run after touching core/src/service-worker/*. + * Copies the pre-built Service Worker bundle from `@tabmesh/core` into + * the playground's `public/`. The actual bundling lives in + * `packages/core/scripts/build-bundles.mjs` so the playground and external + * consumers pull from the same source of truth. * * Usage: node scripts/build-sw.mjs + * + * Requires `@tabmesh/core` to be built first. */ +import { copyFileSync, existsSync, mkdirSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { build } from 'esbuild'; const here = dirname(fileURLToPath(import.meta.url)); const root = resolve(here, '..'); -const entry = resolve(root, '../core/src/service-worker/tabmesh-sw.ts'); -const outfile = resolve(root, 'public/tabmesh-sw.js'); +const src = resolve(root, '../core/dist/tabmesh-sw.js'); +const dest = resolve(root, 'public/tabmesh-sw.js'); + +if (!existsSync(src)) { + console.error(`[build-sw] Missing ${src}. Run \`pnpm --filter @tabmesh/core build\` first.`); + process.exit(1); +} -await build({ - entryPoints: [entry], - bundle: true, - format: 'iife', - target: 'es2020', - outfile, - sourcemap: false, - legalComments: 'none', - logLevel: 'info', -}); +mkdirSync(dirname(dest), { recursive: true }); +copyFileSync(src, dest); +console.log(`[build-sw] copied ${src} → ${dest}`); diff --git a/packages/playground/scripts/build-worker.mjs b/packages/playground/scripts/build-worker.mjs index e1a1dfa..435fbeb 100644 --- a/packages/playground/scripts/build-worker.mjs +++ b/packages/playground/scripts/build-worker.mjs @@ -1,26 +1,29 @@ /** - * Bundles the SharedWorker entry from @tabmesh/core into a single IIFE - * served at /tabmesh-worker.js. Run after touching core/src/worker/*. + * Copies the pre-built SharedWorker bundle from `@tabmesh/core` into the + * playground's `public/`. The actual bundling lives in + * `packages/core/scripts/build-bundles.mjs` so the playground and external + * consumers pull from the same source of truth. * * Usage: node scripts/build-worker.mjs + * + * Requires `@tabmesh/core` to be built first — `pnpm --filter + * "@tabmesh/playground^..." build` does that as part of the e2e pipeline. */ +import { copyFileSync, existsSync, mkdirSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { build } from 'esbuild'; const here = dirname(fileURLToPath(import.meta.url)); const root = resolve(here, '..'); -const entry = resolve(root, '../core/src/worker/tabmesh-worker.ts'); -const outfile = resolve(root, 'public/tabmesh-worker.js'); +const src = resolve(root, '../core/dist/tabmesh-worker.js'); +const dest = resolve(root, 'public/tabmesh-worker.js'); + +if (!existsSync(src)) { + console.error(`[build-worker] Missing ${src}. Run \`pnpm --filter @tabmesh/core build\` first.`); + process.exit(1); +} -await build({ - entryPoints: [entry], - bundle: true, - format: 'iife', - target: 'es2020', - outfile, - sourcemap: false, - legalComments: 'none', - logLevel: 'info', -}); +mkdirSync(dirname(dest), { recursive: true }); +copyFileSync(src, dest); +console.log(`[build-worker] copied ${src} → ${dest}`); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0145b29..33b0c04 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: '@types/node': specifier: ^20.11.0 version: 20.19.39 + esbuild: + specifier: ^0.28.0 + version: 0.28.0 jsdom: specifier: ^23.0.0 version: 23.2.0