Deployment

MassingViewer is a static site. There is no server component, no database, and no required backend — LocalKernel runs entirely in the browser, so npm run build --workspace @massing/demo produces a directory you can serve from anything. RemoteKernel adds an authoring service if you want one; nothing else changes.

The two postures

The difference between them is one HTTP header pair, and it decides whether SharedArrayBuffer is available.

Static host (GitHub Pages, S3, a CDN) Server you control (nginx, Caddy, Cloudflare Workers)
COOP/COEP Cannot be set. Applied by the service worker, if enabled Set as response headers — the correct place
SharedArrayBuffer Only via crossOriginIsolation: true, after one reload Available on first load
Offline caching Yes, always Yes, always
Which to choose The public demo, and any deployment where a static host is the point Anything needing shared memory on first paint

Neither posture is a downgrade today. Nothing in this repository uses SharedArrayBuffer — see docs/adr/0010-one-service-worker.md — so the static host is fully featured. The distinction becomes load-bearing only when something arrives that needs shared memory.

Static host

Build and serve. apps/demo/vite.config.ts sets base: "./", so a repository subpath works with no rebuild.

npm run build --workspace @massing/demo

The build emits a service worker at the root of the output (sw.js — generated, so not a tracked path) and injects its registration into index.html. The worker precaches the shell and every hashed asset, so a reload with no network opens the app rather than failing.

.github/workflows/pages.yml does this, and then asserts the deployed page actually renders a building. That post-deploy check is the point of the workflow, not an extra: "deployed successfully" and "works" are different claims, and massing's own Pages demo has been live and broken for a long time on exactly that gap.

Server you control

Set the headers and skip the worker's isolation pass entirely:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Two consequences worth knowing before you do:

The published site: documentation at the root, the demo at "/demo/"

scripts/build-site.mjs renders every markdown file in the repository into a static site and copies the demo build in underneath it.

npm run build --workspace @massing/demo   # the demo first
npm run site                             # docs + demo → site/
npm run site:docs                        # docs only, for the link check

The plan lists "docs site" under Cut from v1, and this reverses that deliberately. It was the right call when there was nothing to publish. There are now twenty-five documents whose entire value is explaining why the code is the way it is, and they were readable only by browsing GitHub.

Four properties, each of which is the reason for a decision rather than a feature:

What it deliberately does not do: no search, no versioned docs, no syntax highlighting, and no client-side JavaScript of any kind. Each of those is a dependency in the browser, and none of them is worth the first one.

Turning on cross-origin isolation

One option:

import { massingPwa } from "@massing/pwa";

export default defineConfig({
  plugins: [massingPwa({ crossOriginIsolation: true })],
});

The generated worker then rewrites navigation responses with COOP/COEP, and the injected registration script reloads the page once on first install. That reload is not avoidable: the worker cannot add headers to the navigation that installed it, because that response was already on the wire before the worker existed.

Do this in the same change as whatever needs SharedArrayBuffer, not in advance.

Content Security Policy

The policy that survives WASM:

script-src 'self' 'wasm-unsafe-eval';
worker-src 'self' blob:;
object-src 'none';
base-uri 'none';
frame-ancestors 'none';
require-trusted-types-for 'script';

'wasm-unsafe-eval' is the narrow grant that permits WebAssembly compilation. It is not 'unsafe-eval', and the difference matters: the broad form re-enables eval and new Function for all script, which is most of what a CSP is for.

worker-src 'self' blob: is required because LocalKernel's worker is constructed from a blob URL — that is what makes it worker-only by construction, with no synchronous path a caller could accidentally take.

Is it actually offline?

Two properties, checked in different places, and they are not the same claim:

To check by hand: load the page, go offline in DevTools, reload. It should open. Before the service worker landed it did not — the session survived, the refresh did not.

Both are covered by E2E, and the coverage is uneven in a way worth stating:

Verifying a deployment

npm run gate:bundle

Per-package budgets, checked in CI and again in .github/workflows/pages.yml, because the Pages artifact is what users download. scripts/bundle-budget.mjs parses the entry point out of index.html rather than matching a filename pattern — a lazily-loaded index-<hash>.js vendor chunk was once miscounted as shell, and the fix was to stop guessing from names.

For the deployed page: the Threads row in the Model panel reads shared memory when isolation is in effect and single when it is not. That row exists so the answer is on screen rather than discovered later from a performance profile.