Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 18 additions & 0 deletions docs/adr/0003-distribute-prebuilt-worker-bundles.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions docs/adr/0004-vitepress-single-site-at-tabmesh-dev.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 5 additions & 4 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
49 changes: 49 additions & 0 deletions packages/core/scripts/build-bundles.mjs
Original file line number Diff line number Diff line change
@@ -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',
})
)
);
2 changes: 1 addition & 1 deletion packages/playground/public/tabmesh-sw.js
Original file line number Diff line number Diff line change
@@ -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:";
Expand Down
6 changes: 3 additions & 3 deletions packages/playground/public/tabmesh-worker.js
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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;
Expand Down
32 changes: 17 additions & 15 deletions packages/playground/scripts/build-sw.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
33 changes: 18 additions & 15 deletions packages/playground/scripts/build-worker.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading