Architecture

@xcwds is a framework for making installable, offline-first PWAs from a config file and a list of plugins. It generalises what xcwds.github.io built by hand, and copies Fastify's shape: a small core whose features all come from encapsulated plugins with hooks, decorators and declared dependencies.

This RFC records the decisions every later issue builds on. Where it says "the kernel", it means @xcwds/core; "the integration" is @xcwds/sveltekit (#10).

Fastify, mapped

Fastify @xcwds
fastify.register(plugin, opts), child contexts app.register(plugin, opts); each plugin gets a child context
fastify-plugin (skip-override) definePlugin(fn, { encapsulate: false })
Plugin metadata: name, dependencies, decorators, range Same, with core as the semver range of @xcwds/core, plus network (#22)
avvio: ordered async boot, after, ready, pluginTimeout Same semantics: depth-first load, await app.ready(), a 10 s per-plugin timeout
Hooks: onRequest, preHandler, onError, onReady, ... Build, runtime and service-worker hook families (#5)
decorate, hasDecorator Same; components read them with useApp()
Route prefix prefix scopes a plugin's routes and route-bound hooks to a path
JSON-schema validation Validators on config, plugin options, settings fields and saved data
fastify.log, setErrorHandler app.log (local console only) and the onError hook
Declaration merging on FastifyInstance Declaration merging on App, Settings
fastify.inject() @xcwds/testing (#21)
@fastify/*, fastify-* @xcwds/plugin-* first-party, xcwds-plugin-* community (npm keyword xcwds-plugin)
create-fastify npm create @xcwds (#23)

Decisions

1. Plugins reach three environments through descriptors and entry points

A server plugin runs in one process. A PWA plugin may need code in three places: the build (Node, via Vite), the page (the browser, but also Node while SvelteKit prerenders) and the service worker.

Decision. A plugin package exposes up to three entry points as package exports:

Export Runs in Holds
. Node, while loading the config The plugin factory and its build hooks
./client The page, and Node at prerender The runtime plugin (definePlugin(...)), components
./worker The service worker The worker plugin (onFetch, onInstall, ...)

Calling a plugin in xcwds.config.ts returns a descriptor: the package name plus its options. The integration turns the descriptor list into generated imports of each package's ./client and ./worker, so build-only code never reaches the bundle and page code never reaches the worker.

// xcwds.config.ts
export default defineConfig({
	brand: { name: 'Pocketbox' },
	plugins: [timers({ prefix: '/utils/timer' })] // โ†’ { name: '@xcwds/plugin-timers', options: {...} }
});

Consequence. Options cross from Node into the page and the worker, so they must be JSON-serialisable. The config validator rejects anything else (a function, a class instance, undefined inside an array) with the plugin's name. A plugin needing code from the app takes a module path as an option and imports it from its own entry.

Each entry is a kernel plugin (definePlugin(...)): ./client and ./worker as their default export, and the . entry as a named build export beside the factory. The integration registers each with the plugin's options in its own app (build, page, worker), so build hooks and routes (app.route()) are added in Node, and runtime and worker hooks where they run.

Spike. Test 1: hello({ greeting: 'hi' }) in xcwds.config.ts becomes virtual:xcwds/client, whose onBoot sets data-hello="hi" in the browser. The same module is imported at prerender without error, so client entries must not touch browser globals at import time (onBoot and later hooks only run in the browser). Their plugin functions do run at prerender, so pages render with their decorators and settings fields.

The integration loads xcwds.config.ts with Vite's runnerImport (Vite โ‰ฅ 6.1), so TypeScript configs need no separate compiler.

2. Plugin pages are thin route files

SvelteKit only has filesystem routes; a package can't add one.

Decision. An app keeps one small route file per plugin page, which renders a component the plugin exports:

<!-- src/routes/settings/+page.svelte -->
<script>
	import SettingsPage from '@xcwds/plugin-settings/SettingsPage.svelte';
</script>

<SettingsPage />

npm create @xcwds writes these files for the plugins it installs, and xcwds add <plugin> writes them later (#23). Plugins register each page in the route registry (title, emoji, back target, width) so the shell and prerender entries know about it.

Why not a catch-all route? A single [...path] route that dispatches to plugin pages needs no files, but loses SvelteKit's per-route code splitting and +page.ts loaders, and makes a stack trace point at a dispatcher. It stays an option for later.

Spike. Test 2: src/routes/hello/+page.svelte renders the plugin's Page.svelte; it is prerendered into hello.html and hydrates.

3. The service worker imports a generated file

SvelteKit 2 builds src/service-worker.ts in a separate Vite build with configFile: false and only its own $service-worker plugin (build_service_worker.js in @sveltejs/kit 2.49), so a Vite plugin's virtual modules don't resolve there.

Decision. The integration writes real files into .xcwds/ (git-ignored) when svelte.config.js loads, which happens before svelte-kit sync, svelte-check, vite dev and vite build. src/service-worker.ts imports .xcwds/worker.js, which imports each plugin's ./worker entry and onWorker modules, and starts the worker runtime from @xcwds/sveltekit/worker with the $service-worker lists. Normal package resolution works in SvelteKit's worker build, so nothing else is needed. The runtime runs onFetch hooks first and otherwise serves a baseline offline strategy (precache, then this version's cache first), so every app installs and works offline. The strategy reads a cache policy (app.worker.policy: extra precache paths, exclusions, the offline fallback page, runtime caching) once every worker plugin has registered, so @xcwds/plugin-offline (#11) changes the policy instead of answering fetches itself, and other plugins' onFetch hooks still run first.

SvelteKit 3 builds the worker as a Vite environment (serviceWorker), so virtual modules would work there. The generated-file approach works on both, so it stays the single mechanism until SvelteKit 2 support is dropped.

Spike. Test 3: the worker built from .xcwds/worker.js answers /__xcwds/hello from the plugin's onFetch hook.

4. The pre-paint script is injected by a server hook and hashed for the CSP

app.html is a static template, and Vite's transformIndexHtml doesn't apply to SvelteKit pages.

Decision. Plugins return plain ES5 snippets from the onHead build hook, and settings fields add theirs with prePaint (app.settings.prePaintScript()). A handle hook (exported by the integration for src/hooks.server.ts) wraps each snippet in its own try, joins them into one inline <script> after the manifest, icon and iOS tags, and replaces a %xcwds.head% placeholder in app.html using transformPageChunk. Handle hooks run at prerender, including for adapter-static's 404.html fallback.

onHead snippets run before the settings' snippets, so a build entry can hand build-time data to its field's prePaint (@xcwds/plugin-theme (#14) sets the brand's theme colours this way). Page code can't eval a snippet under the hashed CSP, so a plugin that applies a setting again at runtime ships the same logic as a function too, and tests that the two agree.

The placeholder goes after %sveltekit.head%, because SvelteKit puts its CSP <meta> first in that output and a <meta> policy only covers what follows it. withXcwds() sets SvelteKit's CSP in hash mode; SvelteKit hashes its own inline boot script, and handle adds the pre-paint script's sha256- hash to script-src in that <meta> (or the CSP header, in vite dev). The hash is added by handle rather than in svelte.config.js because settings fields come from the plugins' ./client entries, which only load in the page bundle, after the config. Comments in app.html must not contain %sveltekit.*% text: SvelteKit replaces placeholders anywhere in the file.

Spike. Test 4: /, /hello and an unknown URL (served 404.html) all carry the CSP and set data-prepaint with every JavaScript file blocked, so the attribute comes from the inline script. Removing the hash from script-src makes the test fail, so the CSP really is enforced.

5. Encapsulation is a route prefix plus a storage namespace

Fastify scopes decorators and hooks by a context tree. Here a plugin's child context also has a route prefix (route-bound hooks such as onNavigate only fire under it) and a storage namespace (app:<plugin>:<key>). One namespace belongs to one plugin: a second plugin that derives or asks for the same one fails to load, since sharing would mix their data and migration versions.

This is API hygiene, not isolation. Every script on the origin can read all of localStorage, so plugins are trusted code. The docs say so, and #22 lists each plugin's declared network use.

6. The kernel is framework-agnostic

@xcwds/core is plain TypeScript with no Svelte or DOM-framework imports. State it exposes (settings, saved values) offers get, set and subscribe; @xcwds/sveltekit wraps those in Svelte 5 runes. A React or Vue integration could follow without kernel changes. SvelteKit is the only integration planned for v1.

7. Navigations are the request lifecycle

The closest thing to a request is a navigation: onNavigate(to, from) can redirect or cancel (like onRequest), and afterNavigate(to) runs once the page shows. In the worker, onFetch hooks form a chain where the first returned Response wins (like onRequest plus reply.send()), before the default caching strategy.

8. Close means "safe to reload"

A reload swaps code out from under the user. onBeforeReload hooks return a reason to wait (a running timer), as does a name passed to app.update.markBusy(name, isBusy) for state a component already holds; the update banner shows them (#12). The new worker never calls skipWaiting() on install: it waits until the user taps Update. app.close() runs onClose hooks in reverse registration order, in tests and on Vite HMR dispose, so dev reloads don't stack listeners.

9. Everything honours the base path

Apps on user.github.io/repo live under SvelteKit's paths.base. The manifest's id, scope and start_url, the worker's scope and precache list, share URLs and route registry paths all include it. The kernel stores paths without the base; the integration adds it at the edges.

10. "Never phone home" is checked at build time and enforced by the CSP

Decision. Plugins declare network: false | { origins, reason } (default false), read from their build entry. After SvelteKit builds the site, withXcwds()'s adapter wrapper scans SvelteKit's output (page bundle, service worker, static files, prerendered pages) for URLs on other origins where they load or send something, and fails the build on any origin that no plugin declares and privacy.allowOrigins doesn't list, naming the plugin whose package mentions it. Scripts never load from other origins. The hash-mode CSP (decision 4) allows the declared origins in connect-src (and https: ones for images, fonts, media and styles) and nothing else, which is the real enforcement, since a static scan can't see URLs built at runtime. The settings page lists the declarations. See docs/privacy.md.

The scan runs in the adapter because it is the first point where the worker (built in its own Vite build, decision 3) and the prerendered pages both exist.

Hook families

Defined in #5; listed here so the decisions above have names to point at.

  • Build: onConfig, onManifest, onHead, onWorker, onPrerender
  • Runtime: onBoot, onReady, onNavigate, afterNavigate, onSettingsChange, onStorageChange, onBeforeReload, onHidden, onVisible, onError, onClose
  • Worker: onInstall, onActivate, onFetch, onMessage

Not decided here

  • Plugin option schemas: the kernel accepts any validator function returning the parsed value or undefined, like the storage entries. A schema library can be layered on later.
  • Translations: out of scope for v1.

Edit this page on GitHub