Concepts

@xcwds copies Fastify's shape: a small core, and every feature a plugin with hooks, decorators and declared dependencies. If you know Fastify, the architecture RFC maps one onto the other. This page explains each idea on its own.

Plugins

A plugin is a function that gets the app and its options, and adds to it:

import { definePlugin } from '@xcwds/core';

export default definePlugin(
	(app, options: { greeting?: string }) => {
		app.addHook('onReady', () => app.log.info(options.greeting ?? 'hello'));
	},
	{ name: 'xcwds-plugin-hello' }
);

definePlugin attaches metadata, checked when the plugin loads:

Field Meaning
name Unique, usually the package name. Errors name the plugin by it.
core The semver range of @xcwds/core the plugin supports.
dependencies Plugins that must be registered before this one.
decorators Decorators that must already exist where this one is registered.
encapsulate false makes its decorators visible to the whole app (see below).
network false (the default) or the servers it contacts and why (see Privacy).
namespace Where its saved data lives (app:<namespace>:<key>); defaults to a short form of name.

Plugins load in order, depth first: a plugin that registers others finishes them before the next one starts, and one that takes more than 10 seconds fails with its name.

Three places to run

A web app runs code in three places, so a plugin package has up to three entries:

Entry Runs in Typically holds
. Node, while the build loads the config The factory you call in the config, and build hooks
./client The page (and Node, while pages prerender) Decorators, settings, saved data and runtime hooks
./worker The service worker Answers to requests, for offline and sharing

Calling a plugin in xcwds.config.ts, as in timers({ page: '/timers' }), doesn't run it: it returns { name, options }. The build loads the package's . entry, then generates imports of ./client for the page and ./worker for the service worker, and registers each with the same options. That is why options must be plain data: they travel from Node into the page and the worker. Build-only code never reaches the page, and page code never reaches the worker.

Pages

SvelteKit only has routes in src/routes/, and a package can't add files there. So a plugin's page is a component it exports, and the app keeps a small route file that renders it:

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

<main class="page-narrow"><SettingsPage /></main>

npm create @xcwds and xcwds add write these files for you. The plugin adds the page to the route registry with app.route({ path, title, emoji, parent }) from its build entry, so the header shows its title and back arrow and the build prerenders it.

Encapsulation

Each plugin sees its own view of the app. What it adds with app.decorate() stays inside it and the plugins it registers, unless it sets encapsulate: false. A plugin meant to give the whole app something, as most do, sets it.

Each plugin also gets a prefix (app.register(plugin, { prefix: '/tools' })): route hooks such as onNavigate only run for paths under it. And it gets a storage namespace: its saved keys start with app:<namespace>:, and no two plugins can share one.

This is tidiness, not a security boundary. Every script on the site can read all of its storage, so plugins are trusted code: only add ones you would trust with your users' data.

Hooks

Hooks are how plugins take part in what happens. app.addHook(name, fn) adds one; they run in the order they were added, and one that throws is reported to onError without stopping the others. There are three families, one per place code runs.

Build hooks run in Node:

Hook When Returns
onConfig The config has loaded A changed config, or nothing
onManifest The web app manifest is generated A changed manifest, or nothing
onHead The pre-paint script is built Plain ES5 that runs before the page shows
onWorker The service worker is generated Module specifiers to import into it
onPrerender The build lists pages to prerender Extra paths

Runtime hooks run in the page:

Hook When
onBoot The page has mounted (the first moment window and document are safe)
onReady Every plugin has booted
onNavigate Before a navigation: return a path to redirect, or false to cancel
afterNavigate The new page shows
onSettingsChange A setting changed, here or in another tab
onStorageChange Saved data changed in another tab, or was cleared or imported
onBeforeReload Before an update reloads the page: return a reason to wait
onHidden The page went into the background
onVisible The page came back
onError A hook or plugin failed (errors are only ever logged on the device)
onClose The app closes (tests, and dev reloads), in reverse order

Worker hooks run in the service worker:

Hook When
onInstall A new version installs, after the precache
onActivate It takes over
onFetch A request: return a Response to answer it (the first wins)
onMessage A page posted a message

A plugin can define hooks of its own by merging into the Hooks interface.

Decorators

app.decorate(name, value) adds a property to the app: app.toast, app.timers, app.update. Components reach it with useApp() from @xcwds/sveltekit. Declare its type by merging into the App interface, so every app that installs the plugin gets it typed:

declare module '@xcwds/core' {
	interface App {
		readonly timers?: AppTimers;
	}
}

Mark it optional (?) when the plugin might not be installed, so code that uses it checks.

Saved data and settings

Nothing an app saves leaves the device. Every saved value is a registered entry with a key, a label people can read and a parse function that returns the value if it is valid and undefined if not:

const presets = app.storage.entry('presets', { label: 'Timer presets', parse: parsePresets });
app.storage.write(presets, [{ label: 'Tea', ms: 180_000 }]); // true if it saved
app.storage.read(presets); // the value, or undefined: reads never throw

Because every value is registered, the settings page can export them all as a backup, import one, and clear them by group, and reading data that a bug or an old version left behind never breaks a page. Each namespace has a schema version: when a plugin changes what it saves, it adds a migration, which upgrades both the data on the device and older backups.

Settings are one app-wide object (saved as app:settings), to which each plugin adds fields with a default, a parse function and optionally a control that the settings page shows:

app.settings.field('alarm', {
	default: true,
	parse,
	label: 'Alarm sound',
	control: { type: 'switch' }
});

A missing or invalid field reads as its default, so a new field needs no migration. A field can also apply itself before the page first paints (prePaint), as the theme does, so a dark-mode user never sees a white flash.

In components, persist() from @xcwds/sveltekit ties an entry to a piece of state: it loads after mount, saves when the value changes and follows other tabs. settings.current is the settings object, reactively.

Updates

A reload in the middle of something loses it: a running timer, a half-typed note. So a new version never takes over by itself. The service worker installs it in the background and the page offers Update; until the user taps it, even a relaunch keeps the old version. Code that holds work in progress says so, with an onBeforeReload hook or app.update.markBusy(name, isBusy), and the banner asks before reloading. When one tab updates, hidden tabs with nothing busy reload quietly and the others offer Reload.

Privacy

"Never phone home" is checked, not promised. A plugin that contacts a server declares it:

definePlugin(fn, {
	name: 'xcwds-plugin-sync',
	network: { origins: ['https://sync.example.com'], reason: 'Syncs your notes between devices.' }
});

When the site is built, the build scans everything it produced for calls to other servers and fails on any origin no plugin declares. The page's Content Security Policy only allows the declared origins, which is what stops calls the scan can't see. The settings page lists each server and why. Privacy has the details.

Edit this page on GitHub