@ogygia/cli 0.0.0-stage → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +27 -2
- package/ai/SKILL.md +363 -0
- package/dist/cli.mjs +40652 -0
- package/dist/mcp.js +1245 -0
- package/dist/perf/bisect.js +69 -0
- package/dist/perf/cli.js +221 -0
- package/dist/perf/compare.js +166 -0
- package/dist/perf/measure.js +102 -0
- package/dist/perf/report.js +55 -0
- package/package.json +59 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Puru Vijayvargia
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,28 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @ogygia/cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The [ogygia](https://ogygiajs.dev) command line: project and docs-site setup, the AI skill and MCP server, perf budgets, and federation keys and catalogs.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
You rarely install it yourself. The `ogygia` package's `ogygia` command runs it: the app's installed copy, or the matching version through your package manager.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx ogygia init # wire ogygia into a SvelteKit app
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
To pin it, add it beside `ogygia`:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pnpm add -D @ogygia/cli
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
It is not a plugin: nothing goes in vite.config for it.
|
|
20
|
+
|
|
21
|
+
## Commands
|
|
22
|
+
|
|
23
|
+
- `ogygia init` (`--markdown` adds content) and `ogygia site init`: set up an app, or a docs site.
|
|
24
|
+
- `ogygia ai`: install the ogygia skill and register the MCP server for AI agents. `ogygia mcp` runs the server.
|
|
25
|
+
- `ogygia perf`: render pages under the profiler and judge them against a budget in CI.
|
|
26
|
+
- `ogygia keys <name>`: make an Ed25519 key pair for federation. `ogygia fragments <origin>`: typed stubs for a peer's widgets.
|
|
27
|
+
|
|
28
|
+
Docs: [ogygiajs.dev](https://ogygiajs.dev).
|
package/ai/SKILL.md
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ogygia
|
|
3
|
+
description: Use when writing or changing code that uses ogygia (SSR islands for SvelteKit) — islands, regions, wake/render import attributes, held regions, content collections, site(), markdown, the SPA router, the server router (routes/page/$infer), fragment federation (expose/mount), experiments & feature flags, streamed pages, cross-island state/context, or packaging islands in an npm dependency. Read BEFORE inventing a workaround — nearly every "hard" problem here has a one-line primitive, and the complicated solution is usually the wrong one.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# ogygia — SSR islands for SvelteKit
|
|
7
|
+
|
|
8
|
+
Astro-style islands on stock SvelteKit (no Kit patches). Pages ship as server HTML with `csr = false`; only marked components get JS. The runtime is a small custom element (`<ogygia-region>`) plus only the features the app uses (~8 KB min+brotli for a load-only app). Not a new framework — it sits on Kit; remote functions, form actions, async Svelte all work.
|
|
9
|
+
|
|
10
|
+
**The one mental model:** ogygia has ONE primitive, the **region** — a piece of the page with its own rendering and waking rules. Everything (island, lake, server island, held region, content body, block, federated fragment) is the same envelope with different dial settings. If you understand the two dials, you understand all of it.
|
|
11
|
+
|
|
12
|
+
## The two dials (import attributes)
|
|
13
|
+
|
|
14
|
+
Marked on a component import: `import X from './X.svelte' with { … }`.
|
|
15
|
+
|
|
16
|
+
| Key | Question | Values |
|
|
17
|
+
| -------- | ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
18
|
+
| `render` | Where/when does the HTML come from? | `'static'` (default, inline in the page's SSR pass) · `'deferred'` (fetched later from a signed endpoint) · `'live'` (baked, revalidates in background) |
|
|
19
|
+
| `wake` | When does an island's JS run (static) / when does a live region revalidate? | `'load'` · `'idle'` · `'visible'` · `'interaction'` (first pointer/key/focus inside wakes it) · `'(media query)'` · `'none'` (frozen — a lake) |
|
|
20
|
+
| `fetch` | When does a `render: 'deferred'` hole's HTML arrive? Pairs with `render: 'deferred'` only | `'load'` (default) · `'idle'` · `'visible'` · `'interaction'` (an **on-demand hole** — first hover/focus/touch inside fetches it, morphed in) · `'(media query)'`. `render: 'deferred'` + `wake` fails the build — write `fetch` |
|
|
21
|
+
| `region` | Held-across-a-boundary marker (registries the transform can't see into) | `'raw'` (only value) |
|
|
22
|
+
| `keep` | Island keeps its live node + `$state` across SPA navigation | a name (unique per class of thing) |
|
|
23
|
+
| `preset` | Named bundle from `ogygia({ presets })` | preset name (literal) |
|
|
24
|
+
| `margin` | IntersectionObserver rootMargin for `visible` | e.g. `'200px'` |
|
|
25
|
+
|
|
26
|
+
## Choose the right primitive (decision table)
|
|
27
|
+
|
|
28
|
+
| You need | Use | Not |
|
|
29
|
+
| --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
|
30
|
+
| Interactive widget (counter, menu, form) | `with { wake: 'load' \| 'visible' \| … }` | wrapping the page in one island |
|
|
31
|
+
| Static content — not inside an island | nothing. Unmarked markup is free server HTML | an island "to be safe" |
|
|
32
|
+
| Heavy static subtree INSIDE an island (prose, chart, code) | `with { wake: 'none' }` (lake) | letting it compile into the island bundle |
|
|
33
|
+
| Request-specific but NOT interactive HTML (greeting, unread count, slow upstream) | `with { render: 'deferred' }` (server island) + `ogygiaFallback` snippet | a client island that fetches |
|
|
34
|
+
| Personalized AND interactive | server island whose own `.svelte` file contains a nested `wake` island | stacking both dials on one import |
|
|
35
|
+
| Server picks WHICH component (search results, mixed feed, SDUI) | held region: `region(Comp, props)` returned from a remote, rendered `<Region of={…}>` | client-side branching over imported components |
|
|
36
|
+
| Slow data below the fold, paint the rest NOW | streamed page `page(async function* …)` or a late region: promise handed to `<Region of={p}>` + `placeholder` snippet | client fetch-on-mount |
|
|
37
|
+
| A whole app section owned by another team/deploy | federation: `federate({ peers })` then `'/cms/[...rest]': mount(cms)` in the shell's routes table; the MFE `federate({ expose })`s its router | iframes, module federation |
|
|
38
|
+
| A/B test, rollout, kill switch, or flag, zero client JS | `flag()` from `'@ogygia/flags'` (read by calling it), `flag.pick({...})` as a page slot; `decide({ source })` for OpenFeature | client-side bucketing / a vendor SDK in the bundle |
|
|
39
|
+
| Calling your own endpoints with types | `api<App>()` from `'@ogygia/router/api'` | hand-rolled fetch + casts |
|
|
40
|
+
| Run client code, render nothing (telemetry, listeners, store boot) | boot island: headless component whose job is its `$effect` | `onMount` in the page (dead code on csr=false) |
|
|
41
|
+
| Code that must run BEFORE first paint (theme, font kickoff) | `{@html script(fn, ...args)}` from `'ogygia'` in `<svelte:head>` | hand-rolled `<script>` strings |
|
|
42
|
+
| JS chunk that downloads only after a click | plain `await import('./Widget.svelte')` (no attributes) inside a host island — mounts a regular component | `import('X.svelte', { with: { wake } })` — **build error by design** |
|
|
43
|
+
| Delay a real island until click | keep the static marked import, gate `<X />` with `{#if}` | dynamic import tricks |
|
|
44
|
+
| Share live state between two known islands | wired class passed as a prop (see boundary laws) | store libraries, events, DOM hacks |
|
|
45
|
+
| Value for many islands down a subtree (theme, user, cart) | `<Provide values={…}>` / drop-in `setContext` from `'ogygia'`; read with plain `getContext` | prop drilling every island |
|
|
46
|
+
| Set of typed content (docs, blog, CMS) | `content({ loader, schema })` in a `.server.ts` | hand-rolled `import.meta.glob` plumbing |
|
|
47
|
+
| A corpus that reads as a _site_ (nav, search, prev/next, shell) | `site({ outline })` + `remotes()` | assembling nav/search by hand |
|
|
48
|
+
| CMS/visual-builder page tree | `blocks(source, registry)` + `import.meta.og.regions('./blocks/*.svelte')` | N hand-written imports |
|
|
49
|
+
| Static page + per-visitor holes at CDN speed | `export const prerender = true` + `render: 'deferred'` holes (PPR) | SSR-per-request |
|
|
50
|
+
| Shipping islands/route tables in an npm package | `"ogygia": { "files": […] }` in the dep's package.json (see Packaging) | asking apps to scan node_modules |
|
|
51
|
+
|
|
52
|
+
## Wiring (once per app)
|
|
53
|
+
|
|
54
|
+
- `vite.config.ts`: `plugins: [ogygia(), sveltekit()]` — **ogygia() must come before sveltekit()**.
|
|
55
|
+
- `src/routes/+layout.ts`: `export const csr = false;` (per route or app-wide; islands also work on `csr = true` Kit pages — there Kit owns navigation and hydrates them inline, and a marked component's client wrapper is LAZY: only islands the document rendered load before Kit hydrates, so a block registry imported by a csr=true route links only what it rendered).
|
|
56
|
+
- `hooks.server.ts`: `export const handle = ogygia.handle()` from `'ogygia/server'` — serves the signed endpoint for `deferred`/`live`. Compose with `sequence()`.
|
|
57
|
+
- **A post-render HTML pass (a web-component server render, a proxy, an editor) goes in `ogygia.handle({ transform: (html, { kind, csr, event }) => … })`**, NEVER in the app's own `transformPageChunk` (which never reaches hole answers, and whose reshaping of island markup breaks hydration). It runs on every document (`kind: 'document'`) and region answer (`'region'`). ogygia marks the HTML first: a custom element marked `og-h` is one it restores — reshape it only by rendering its plan (the moved children inside `<slot>` wrappers, `og-shadow="key …"` naming `<template data-og-head="key">` shadow sheets, optional `og-keep="attr …"` for added attributes true of the live host); one without `og-shadow` is left exactly as Svelte wrote it; carry every other `og-*` mark along. The restorer runs before any hydration. Never put your own relocation marker between an ogygia mark comment and its node. A transform that caches renders by markup keys on `localizeMarks(block).html` and maps back with `.back(rendered)` (ogygia numbers hosts per document). Test a transform with `transformMarkup` + `checkScript` + `restore` from `'ogygia/markup'` (restore runs in jsdom as is) (also `scanRegions` / `liftRegions` / `restoreRegions`; `ogygia/rewrite` and `transformHole` no longer exist). Docs: /docs/app/server-transforms.
|
|
58
|
+
- `src/hooks.ts`: `export const transport = { ...ogygia.transport }` — needed only when a held region crosses the wire (remotes, live content).
|
|
59
|
+
- `src/ogygia.d.ts`: `/// <reference types="ogygia/types" />` — or svelte-check flags `virtual:ogygia/*`. With content, add `/// <reference types="@ogygia/content/types" />` (the loader/code/md macro types).
|
|
60
|
+
- **Features beyond regions are PLUGIN PACKAGES**, installed separately; each is a Vite plugin listed ONCE beside `ogygia()`: `plugins: [ogygia(), content(), flags(), postrender(), federation(), router(), devtools(), sveltekit()]`, each factory from `'<package>/vite'` (`ogygia()` finds them there; one listed without `ogygia()` fails the build). `ogygia.handle()` runs every listed plugin's server half, the runtime boots its browser half; the ONE plugin with a hooks line of its own is postrender (`sequence(postrender.handle({ store, edge }), ogygia.handle())` — its live store/edges). Packages: `@ogygia/content` (`content({ markdown, presets })`), `@ogygia/flags` (`flags()`; native `flag()` works without it, `decide({ source, exposure })` needs it), `@ogygia/postrender` (`postrender({ ttl, default })`), `@ogygia/federation` (`federation()`), `@ogygia/router` (`router()` — links the CSS of its pages), `@ogygia/devtools` (`devtools({ dock, profiler })`), plus `@ogygia/testing` (test helper, no plugin) and `@ogygia/cli` (the command; `npx ogygia …` runs it). All release at ogygia's version. Old option keys (`plugins`, `content`, `freeze`, `devtools`, `profiler`, `regions`) are build errors naming the new spelling.
|
|
61
|
+
- Markdown: `plugins: [ogygia(), content({ markdown: {} }), sveltekit()]` in vite config; `extensions: extensions()` + `preprocess: [vitePreprocess(), ...preprocess()]` (both from `'@ogygia/content/vite'`) in **svelte.config.js** (never inline on `sveltekit()` — Kit would ignore svelte.config.js and hide them from svelte-check). No separate mdsvex plugin ever.
|
|
62
|
+
- A server island (`render: 'deferred'`) that takes a loading snippet DECLARES it in its own props: `ogygiaFallback?: Snippet`. This is unavoidable ceremony — svelte-check/language-tools type the ORIGINAL source and ignore preprocessor output for diagnostics (probe-proven; do NOT attempt a preprocessor/type-rewrite workaround, it cannot work at this seam).
|
|
63
|
+
- Production env: `ORIGIN` (server-island/command/form POSTs go through Kit CSRF), `OGYGIA_SECRET` (stable HMAC across deploys; required in practice for PPR so old static pages' holes keep verifying).
|
|
64
|
+
- `npx ogygia init` does the whole wiring; `npx ogygia site init` scaffolds a docs site.
|
|
65
|
+
- `npx ogygia ai` installs this skill + registers the MCP server (below) into the app's `.claude/` — run it once so any agent on the repo gets both.
|
|
66
|
+
|
|
67
|
+
## AI tooling — the ogygia MCP server (use it)
|
|
68
|
+
|
|
69
|
+
`npx ogygia mcp` runs a stdio MCP server that runs the REAL `transformHost` in Node. When it is wired
|
|
70
|
+
up (`npx ogygia ai`, or `claude mcp add ogygia -- npx ogygia mcp`), you have eleven tools — **prefer them
|
|
71
|
+
over guessing** what the transform (or the running app) does:
|
|
72
|
+
|
|
73
|
+
- `ogygia_check(source)` — **run this on any ogygia component you write or change before trusting it.** It
|
|
74
|
+
runs the real transform and returns the exact `[ogygia]` rule violation (captured-state write, illegal
|
|
75
|
+
nesting, non-literal macro arg, island dynamic-import) or `✅ passes`. This is ground truth, not a guess.
|
|
76
|
+
- `ogygia_compile(source)` — the island map (which imports became islands, their render/wake dials, real
|
|
77
|
+
ids) + the rewritten host module. Use it to SEE the transform's output instead of imagining it.
|
|
78
|
+
- `ogygia_islands(source)` — the island map alone (structured JSON), the fast overview.
|
|
79
|
+
- `ogygia_explain(source)` — prose runtime story per island (where its HTML comes from, when JS wakes,
|
|
80
|
+
how props cross). Good for explaining a design to a human.
|
|
81
|
+
- `ogygia_debug({ url })` — **the runtime half.** Loads a page of the RUNNING app in a headless browser,
|
|
82
|
+
lets islands hydrate (scrolls for `visible`; pass `click` to wake an `interaction` one), and returns the
|
|
83
|
+
ACTUAL per-island story from the devtools bus: SSR → wire → connected → woke → hydrated (with ms), plus
|
|
84
|
+
anomalies (SSR'd-but-never-connected, hydration failures). Use it to see what really happened when an
|
|
85
|
+
island misbehaves. Requires `devtools()` (from `'@ogygia/devtools/vite'`, dock on) beside `ogygia()` in
|
|
86
|
+
vite.config and Playwright installed; on a build, pass the URL with `?og-devtools=on` for the server half.
|
|
87
|
+
- `ogygia_profile({ url })` — **SSR cost.** Runs the ogygia SSR profiler on a route (N renders) and digests
|
|
88
|
+
its report: verdict (compute/io-bound), render p50, CPU findings, where the time went by category, the
|
|
89
|
+
hottest functions + components, network calls, heap growth (+ links to the full report/.cpuprofile). Use
|
|
90
|
+
it when a route renders slowly. Needs `devtools({ profiler: true })` (from `'@ogygia/devtools/vite'`) beside `ogygia()` in vite.config — the tool tells the
|
|
91
|
+
user how if it is missing. Profile a PROD build for real numbers (dev is dominated by Vite + instrument cost).
|
|
92
|
+
- `ogygia_profile_open({ file })` — open a downloaded `.ogp` profiler report and return the same digest as
|
|
93
|
+
`ogygia_profile` (for reports a user hands you).
|
|
94
|
+
- `ogygia_observatory({ files })` — bundle the user's files into a CLIENT-ONLY Observatory link (gzip-packed into the URL # fragment, which browsers never send to a server) so they can SEE their code compiled + running live in the browser. Great for showing how one of their real pages becomes islands.
|
|
95
|
+
- `ogygia_scan({ dir })` — scan a WHOLE project: every island/lake/server-island by file, plus a lint pass (hard `[ogygia]` errors + soft anti-patterns: island-in-island). Use it to understand or audit a real app, not one snippet.
|
|
96
|
+
- `ogygia_flags({ dir })` — inventory every `flag()` call site: reads the build manifest (`node_modules/.ogygia/flags-manifest.json`) when present, else live-scans the source with the real AST collector. Names, file:line, drift vs the last build — use it to audit rollouts and find dead flags.
|
|
97
|
+
- `ogygia_fragment({ origin })` — probe a federated app: fetches its unsigned `__catalog` widget manifest (names + props) and reports whether the fragment endpoint verifies signatures (401 without a signature = signed, 200 = OPEN). Use it when wiring a shell to a peer, before writing `mount(peer)` / `peer.page()` / `peer.widget()` code.
|
|
98
|
+
|
|
99
|
+
If the tools are not present, tell the user to run `npx ogygia ai` (or add the server manually).
|
|
100
|
+
|
|
101
|
+
## The boundary laws (why islands feel different)
|
|
102
|
+
|
|
103
|
+
Each island is its own hydration root. Props cross by **value** (devalue), never by reference. Everything below follows from that.
|
|
104
|
+
|
|
105
|
+
1. **Props must serialize**: plain data, `Date`, `Map`, `Set`, `BigInt`, nested — fine. **Functions/callbacks never cross** (no `onSave` props — do the work inside the island or call a remote function).
|
|
106
|
+
2. **Children and snippets DO cross — automatically, as region snippets.** Static content freezes to server HTML (ships no JS); a `{#snippet}` whose body needs to wake compiles to its own entry and hydrates on the far side (params supported). Plain wrapper components can forward children into islands — it just works. `region.snippet()` is the manual form (raw-HTML `createRawSnippet` mirror). The `ogygiaFallback` snippet on a server island is the reserved loading slot.
|
|
107
|
+
3. **Captured host state is a snapshot.** A host variable read into island markup is serialized once; mutating it inside the island updates nothing (build error on captured writes; dev proxy warns at runtime). Own mutable state inside the island: `let count = $state(props.initial)`. **Store auto-subscriptions obey the same law**: a `$store` read inside a snippet that crosses into an island is auto-captured as the store's VALUE at render time — the compiler rewrites the crossed copy and warns with a file:line trace (silence it by reading the store into a plain host variable outside the snippet). A store OBJECT handed across arrives as a disconnected copy, never live (warned); `$$props` in a crossing snippet is a build error.
|
|
108
|
+
4. **Live class across islands = wire codec.** A class opts into crossing as ONE live shared object:
|
|
109
|
+
```ts
|
|
110
|
+
export class Cart {
|
|
111
|
+
items = $state<string[]>([]);
|
|
112
|
+
static wire = import.meta.og.wire({
|
|
113
|
+
encode: (c: Cart) => $state.snapshot(c.items),
|
|
114
|
+
decode: (items: string[]) => Object.assign(new Cart(), { items }),
|
|
115
|
+
// optional: id: 'cart' → session lifetime (survives SPA nav, tab-scoped);
|
|
116
|
+
// merge: (live, fresh) => { … } reconciles server truth into the live instance (live wins by default)
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
Must be a top-level `export class`. Liveness is identity: every island holding the prop reunites into the same instance; `$state` inside is reactive across all of them. Server never memoizes (no cross-request leaks) — make the instance per request. Related live primitives: `og_derived` (a derived whose recipe crosses and re-derives against the reunified live sources on the far side) and `SharedState` (cross-fragment shared page store, server-seedable) — both from `'ogygia'`, see docs 03-data-state.
|
|
121
|
+
5. **Context**: Svelte's tree-`setContext` can't reach inside islands on the client (separate roots). ogygia bridges via the DOM:
|
|
122
|
+
- Drop-in: swap `import { setContext } from 'svelte'` → `from 'ogygia'` in a csr=false layout; string-keyed values reach child islands, read with **unchanged `getContext('key')`**. Flat page root.
|
|
123
|
+
- Scoped: `<Provide values={{ theme: 'dark' }}>…</Provide>` wraps a subtree (array form merges entries, falsy skipped). Shadows the page root on the same key.
|
|
124
|
+
- Typed sugar: `const theme = createContext<'light'|'dark'>('theme', 'light')` — `theme('dark')` makes a `<Provide>` entry, `theme.get()` reads typed. Same string key as plain `getContext`.
|
|
125
|
+
- Values must serialize (wired classes OK, functions never). Read during island **setup**. A `render: 'deferred'` island renders in isolation on the endpoint — it sees only defaults; put a context reader in a nested `wake` island instead.
|
|
126
|
+
6. **Module-level `$state` in a `.svelte.ts` already shares across islands** with zero ceremony (one client chunk = one instance). Reach for `wire` only when the instance is born on the server and handed down as a prop.
|
|
127
|
+
- **`requestEvent()` from `'ogygia'`** reads Kit's `RequestEvent` (`locals`, `cookies`, `url`) inside ANY region's component during SSR — a server island on its endpoint, a lake, an island's server pass — without importing `$app/server` (which Kit's client guard rejects in a component shared with a csr=true page) and without a remote function. Call it during component init, BEFORE the first `await`. Returns `null` in the browser. A server island's own request has no page URL — pass the page's locale/id as a prop when the hole needs it.
|
|
128
|
+
- **`keepFallback()` from `'ogygia'`**: a server island saying "the page's `ogygiaFallback` is right for this visitor" — the endpoint answers 204 (a batch parcel carries a marker), the runtime keeps the fallback DOM and marks the region done: no bytes, no swap. Call it as soon as the hole knows (e.g. `if (!user) keepFallback()`); it throws a signal, nothing after it runs. The shape for personal holes whose fallback is the anonymous markup: the majority of visitors cost 204s, only the logged-in ones a render.
|
|
129
|
+
7. `page.data` works inside islands: `$app/state`/`$app/stores` resolve to shims fed by a document-level devalue seed the handle injects (Kit alone leaves them empty under csr=false). The seed ships only when an island reads `$page`, and only the top-level `page.data` keys the page's islands read (the build pins `page.data.x` reads per island; a read it cannot pin — `page.data` passed whole, aliased, spread, indexed dynamically — ships everything). Read literal keys to keep the seed small. On a Kit-booted (csr=true) document the shims read Kit's REAL `page` instead, through the kit-world thread (two lines appended to Kit's client entry publish it on a global symbol) — so a component shared between an island and a Kit page reads correct `page.data` in BOTH worlds. `$app/navigation` inside islands: prefer `'ogygia/app'` (`goto`, `invalidate`, `invalidateAll`, `preloadData`, `beforeNavigate`, …).
|
|
130
|
+
|
|
131
|
+
## Held regions and the wire law
|
|
132
|
+
|
|
133
|
+
`region(Comp, props)` packages a component + props into a value; `<Region of={value} />` renders it. Type-checked at the call site.
|
|
134
|
+
|
|
135
|
+
- **Marks decide the schedule**: `with { wake: 'load' }` on the import bakes interactivity; `with { region: 'raw' }` bakes nothing (HTML only — zero JS). `region: 'raw'` is for registries where the transform can't see the `region()` call; a direct `region(Comp, …)` of a plain import needs no mark. Per-data schedules: `region(block, data, (d) => ({ wake: d.interactive ? 'load' : undefined }))`.
|
|
136
|
+
- **The wire law**: _a region is the only unit of code that crosses; everything else is data; any shape crosses if its leaves are regions._ Arrays, records, nested regions-as-props — the transport walks the value and signs one capability per leaf.
|
|
137
|
+
- **`await region(…)` bakes its SSR HTML into the ticket** (paints instantly, no extra fetch). A bare `return region(…)` from an async remote does the same (the language awaits thenables at return). Leaves nested inside containers cross as addresses and fetch on mount. To force a lazy top-level return (CDN-cacheable endpoint): `({ ...r })` — the spread drops the non-enumerable `then`.
|
|
138
|
+
- **Loading UI lives on the region.** Pass the **promise** straight to `of` and use the `placeholder` snippet on `<Region>`. Don't `await` it yourself in a boundary — the boundary un-suspends before the paint (HTML swap + stylesheet load) finishes. (`ogygiaFallback` is server islands only; `placeholder` is held regions.) How the promise waits depends on where it renders: in an app with Svelte's **async mode** it is **awaited in the server render** (a Kit page arrives with the region in it, Kit seeds a remote query's result, hydration awaits the same value; a later value — a refresh — morphs in); on the **server router**'s streamed document it becomes a **late region** (the placeholder flushes with the document, the resolved HTML streams down the same response); otherwise the server renders the placeholder and the browser resolves it after hydration. Islands inside a region's HTML always wake on the runtime, on csr=true pages too (Kit never walks that HTML).
|
|
139
|
+
- **CSS travels with the region**: the page never imported the component, so its scoped stylesheet rides the response and is hoisted to `<head>`; the runtime never paints unstyled. Kit's `inlineStyleThreshold` governs this channel too: a region sheet under it ships as `<style data-ogygia-region-css="href">` (no render-blocking request) and is hoisted/deduped by that href like a link; unset (Kit's default) = links as always.
|
|
140
|
+
- **Live**: `query.live` `yield region(…)` per tick → HTML rides the SSE channel. Raw regions **morph** in place (focus/typed text survive); interactive ones **keep alive** (props pushed into the mounted island, local state intact). One slot = latest tick; collect into an array yourself for feeds.
|
|
141
|
+
- **Single-flight mutation**: a `command` returning a region repaints the mounted `<Region>` at that address from the command's own response — one round trip mutates and repaints.
|
|
142
|
+
- **A region rendered outside its page sees its page.** A hole, or a region a remote `query` / `command` renders, reads the calling page's `page.url` / `route` / `params` / `page.data` (the same-origin Referer's page; its server-load data looked up through Kit's data request, with the visitor's cookies, now; only when the region's tree reads `page.data`, once per request). Universal-load keys don't exist there (dev names them); `locals` a load sets aren't part of the page.
|
|
143
|
+
- **A lake that can refresh** = `<Region of={headerRegion()} />` + `query(async () => region(Header, {}))` + a `command` that changes server state and `await headerRegion().refresh()`. It morphs in place: islands keep state and see the new page data; a hole refetches only if its address (call + the page data its tree reads) changed, its old answer on screen meanwhile. Put the region's remote in its own module (the header tree importing it is a cycle); on a csr=true page `await invalidateAll()` before the refresh (Kit owns `page.data`).
|
|
144
|
+
- `preload(regionValue)` from `'ogygia'` warms a hole's HTML ahead of its schedule.
|
|
145
|
+
- `document(regionValue)` from `'ogygia/server'` renders a held region into a complete `Response` — routeless documents (the profiler UI is built on it).
|
|
146
|
+
|
|
147
|
+
## Timing and nesting
|
|
148
|
+
|
|
149
|
+
- `interaction` on an island: zero JS until first pointer/key/focus **inside** the region. The waking click is captured and replayed; typing survives; hover prefetches the chunk. Caveat: the replayed event is not a trusted gesture — `window.open`/clipboard/fullscreen will be blocked; detect with `hydratedBy() === 'interaction'` and render e.g. a real `<a target="_blank">`.
|
|
150
|
+
- `interaction` on a server island = an **on-demand hole**: zero bytes until the visitor shows intent inside it — the first hover (`pointerover`, which bubbles through the boxless `display:contents` wrapper from a child), focus, touch, or key — then ONE fetch, **morphed** in so what the visitor already opened in the static fallback stays open (no click capture/replay — the fallback handles the gesture natively). Hover is the trigger, so the HTML is usually there before the visitor reaches a submenu. To have it there BEFORE the first hover, add `prefetch: 'idle'` (or `load` / `visible` / a media query — deferred-only, never `interaction`): the HTML is warmed into the frame store at that schedule and the hover swaps from it with no request. The shape for a mega menu: fallback = the menu's L1/L2 (today's SSR HTML, byte-identical), hole = the same menu with L3/L4 rendered on the server — no L4 in the initial HTML, no menu JS, no feed download in the browser.
|
|
151
|
+
- Nesting — one rule, **closest marked parent decides**: island-in-island shares the parent's JS (one tree, child's schedule ignored, dev warns); lake-in-island stays static; island-in-lake wakes on its own; server-island-in-island renders inline (`deferred` ignored); an island inside a server island's `ogygiaFallback` is a full region that wakes on its own (the fallback belongs to the page, and a `keepFallback()`-kept fallback stays interactive).
|
|
152
|
+
- **An island hydrates against its own server markup.** The element keeps the HTML it connected with; if another script edited the island while it slept (a design-system runtime stripping whitespace, an A/B tool, a translator) and Svelte's walk mismatches on wake, the runtime puts the server markup back and hydrates that (`data-og-healed`, dev warning) instead of re-rendering client-side; only a second failure falls back to Svelte's recovery (`data-og-recovered`). Foreign scripts should still leave `<ogygia-region>` subtrees alone — whatever they added is gone after a heal.
|
|
153
|
+
- **A lake on a csr=true (Kit-hydrated) page is adopted, not hydrated.** Kit's pass keeps the lake's SSR element as opaque DOM (its component ships no client JS — not even in Kit's bundle), and everything authored INSIDE the lake stays in ogygia's world: islands and server islands there emit real regions and wake on the runtime. This is how a site header (static markup + `render: 'deferred'` personal holes + a `wake: 'interaction'` search) stays zero-JS-for-anonymous on csr=true pages too: mark the header import `with { wake: 'none' }` in the layout. Caveat: a lake first mounted during a Kit client-side navigation has no server HTML and renders empty (dev warns) — keep chrome lakes in a layout that persists, or serve the route csr=false. A hole rendered OUTSIDE a lake on a csr=true page is Kit-hydrated around; if Kit gives up hydrating the document (`Failed to hydrate`: a component threw) and mounts it fresh, the hole is rendered again with no address — the runtime restores it from the document's holes record (by identity, `data-og-hole`), so it still fetches; a hole rendered only in the browser has no record and keeps its fallback.
|
|
154
|
+
- Dedupe: same component + same strategy/options = ONE wrapper module and ONE client chunk, however many instances (each instance still gets its own region + props payload).
|
|
155
|
+
- A hole's timing is `fetch`, never `wake`: `with { render: 'deferred', fetch: 'visible' }`. Any `wake` on a `render: 'deferred'` import fails the build, naming the `fetch` to write; a `fetch` without `render: 'deferred'` fails too.
|
|
156
|
+
|
|
157
|
+
## csr=false lifecycle rules
|
|
158
|
+
|
|
159
|
+
- The page/layout `<script>` **never runs in the browser**. Top-level `onMount`, `$effect`, listeners = dead code. Any "run this on the client" need is an island (usually a boot island).
|
|
160
|
+
- Inline `<script>` tags in markup run **once on first full load, never on SPA navigations** (body swaps don't execute scripts — standard browser behavior). Per-navigation code = an island (`wake: 'load'` islands remount and re-run per nav; add `keep` to persist instead).
|
|
161
|
+
- Pre-paint code = `script(fn, ...args)` from `'ogygia'` — fn is inlined via `toString`, must be self-contained (browser globals only; pass closed-over values as args, JSON-serialized). For attributes or data, one object: `script({ run, args, type: 'module', async, nonce, id, 'data-*' })` (a module never holds first paint — pre-paint code stays classic; a module that needs modules declares `imports: { name: url }` and reads them from `run`'s first param — never `import()` inside `run`, the bundler rewrites it), or exactly one of `json` / `ld` / `importmap` / `speculation` instead of `run`. `preference()` is the built-in persisted theme/tabs primitive.
|
|
162
|
+
- **Lifecycle DOM events** (router on): `document` fires `og:before-swap` / `og:after-swap` around each body swap and `og:page-load` after every page view **including the initial load** (Astro parity — the inline-script substitute for analytics/per-view code). Regions also dispatch `ogygia:server` / `ogygia:live` / `ogygia:hydrated` as they progress.
|
|
163
|
+
|
|
164
|
+
## SPA router facts
|
|
165
|
+
|
|
166
|
+
- **On by default, app-wide, no component to render.** Config lives only in the plugin: `ogygia({ router: false })` opts out (tree-shakes it); `{ router: { viewTransitions: false } }` keeps SPA nav without VT; `{ router: { forms: false } }` disables form-field continuity; `{ router: { serverDelta: true } }` (opt-in) sends `x-ogygia-known` so the server skips re-rendering islands the client already has live. Per-page VT opt-out: `<meta name="ogygia-router" content="plain">` in that page's head.
|
|
167
|
+
- Kit's `data-sveltekit-preload-*` attributes work unchanged.
|
|
168
|
+
- **Single-flight navigation** is automatic: incoming page's `deferred` load-holes are prescanned and batched into one request, streamed back out of order.
|
|
169
|
+
- `data-ogygia-keep="name"` on a subtree relocates its DOM (island state, scroll, playing media) across navigations. `with { keep: 'name' }` does it per island (needs the router; props are pushed fresh, `$state` survives).
|
|
170
|
+
- `invalidateAll()` is a **soft seed refresh**, not a navigation: no body swap, no VT, no island remount, no nav hooks. After a mutation, update queries with `.refresh()`, or `submit().updates(query)` **paired with** server `requested(query).refreshAll()` — `updates` alone sends keys but does not populate the response.
|
|
171
|
+
- MPA mode (`router: false`) auto-injects native Speculation Rules (prerenders likely next pages); control with `data-ogygia-speculate="off"` / `"on"`. With the router on, rules are deliberately not emitted (a body-swap router can't consume speculation caches) — prefetch + module warming covers it.
|
|
172
|
+
- **PPR**: `export const prerender = true` + `render: 'deferred'` holes = static CDN shell, per-visitor holes. Prerendered capabilities are signed effectively-forever; set `OGYGIA_SECRET`. `sessionCookie` can't bind prerendered holes — personalize inside the island.
|
|
173
|
+
|
|
174
|
+
## The server router (`@ogygia/router`) — routes as values
|
|
175
|
+
|
|
176
|
+
Programmatic routing for dashboards, CMS catchalls, federation. Not a Kit replacement — mount it inside Kit (a catchall `+server.ts`, or `app.handle` in hooks) or any fetch host. Install `@ogygia/router` and list `router()` (from `'@ogygia/router/vite'`) so its pages' component CSS links.
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
import { routes, page, layout, load, action, GET, POST, redirect, error, fail } from '@ogygia/router';
|
|
180
|
+
|
|
181
|
+
const admin = layout('admin', AdminShell, { load: loadUser, error: AdminError });
|
|
182
|
+
const app = routes({
|
|
183
|
+
'/': page(Home),
|
|
184
|
+
'/docs/[slug]': page(DocPage, { params: slugSchema, load: (c) => …, entries: () => [{ slug: 'a' }] }),
|
|
185
|
+
'/api/todo/[id]': { GET: GET(get_todo), POST: POST(todoSchema, add_todo) }, // c.input = validated body
|
|
186
|
+
...admin({ '/admin': page(AdminHome) }),
|
|
187
|
+
}, { error: RootError, visitor: anonymousVisitor(), flags: [hero] });
|
|
188
|
+
|
|
189
|
+
export type App = typeof app.$infer; // TYPE-ONLY — reading $infer at runtime throws
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
- Patterns are Kit grammar (`[slug]`, `[...rest]`, `[[optional]]`). `app.fetch(request)` returns `null` on no-match (fall through to Kit); `app.href('/docs/[slug]', { slug })` is the typed URL builder; `app.entries()` feeds prerender.
|
|
193
|
+
- `page(component, server?)`: `params` schema fail → 404, `search` → 400; `load` is **memoized per request** (Kit's `parent()` generalized — share one `load()` def across pages/layouts, it runs once); `actions` are Kit-shaped; `fallback` snippet = deferred-page loading slot.
|
|
194
|
+
- Layouts wrap sub-tables (spread them in); an `error` boundary renders INSIDE the surviving chrome at the shallowest failing layer; duplicate layout names are a build error. `redirect`/`error` throw; `fail(status, data)` is **returned** (form re-render + status).
|
|
195
|
+
- `$infer`: pages → `{ data, form, params, search }` (data = merged root→layout→page); layouts (`App['(name)']`) → `{ data, children }`; endpoints → lowercase verbs `{ get: { out, in }, … }`.
|
|
196
|
+
- The page slot can be: a component · `exp.pick({...})` · a `PageSlotResolver` (`{ __ogpick: (c, data) => … }`) · a `PageHtmlView` (`{ __oghtml, html, css?, title?, head?, status? }` — `status >= 400` becomes the response status, no 200-wrapped errors). Scoped `<style>` on router page components reaches the document.
|
|
197
|
+
- **Streamed pages**: `page(async function* (c, data) { yield fast; yield await slow; })` — the first yield flushes immediately with status/title/headers; later yields ride the SAME response as `<template>` chunks an inline boot script swaps in during parse. Yield regions or HTML strings. Non-GET runs to completion (final yield only). Streamed responses set `cache-control: no-transform`.
|
|
198
|
+
- **Late regions** compose with it: any load can hand a PROMISE of a `region()` to `<Region of={p} >` + `placeholder` — each hole flushes its placeholder and streams its resolution in completion order, failures isolated per hole.
|
|
199
|
+
- **`when(gate, entry)`** — flag-gate ANY table entry (page, endpoint, `mount()`): OFF = the route DOES NOT EXIST for that request (404/error page under an owned `base`, fall-through to the rest of the app without). The gate is any `(c) => boolean` — a boolean `flag()` slots in directly (`'/checkout-v2': when(checkoutV2, page(NewCheckout))`, `'/api/export': when(exportsFlag, { GET })`, `'/cms/[...]': when(cmsRollout, mount(cms))`). Decided AFTER `decide({ source })` primes (a vendor kill switch gates whole routes) and carried like any flag read. Type-transparent to `$infer`.
|
|
200
|
+
- **Typed client**: `import { api, ApiError } from '@ogygia/router/api'` (browser-safe) — `const c = api<App>(base); await c.get('/api/todo/[id]', { params: { id } })`. `params` required exactly when the pattern has them; `body` typed by the endpoint schema; non-2xx throws `ApiError { status, body }`.
|
|
201
|
+
|
|
202
|
+
## Federation v2 — fragments across deploys (EXPERIMENTAL)
|
|
203
|
+
|
|
204
|
+
One team's app renders inside another team's page, server-side, with security + identity handled. Every federating app installs `@ogygia/federation` (+ `@ogygia/router`) and lists `federation()` from `'@ogygia/federation/vite'`. ONE `federate()` per app, in hooks.server.ts (or a `.server.ts` it imports) — NO route files. It returns typed peer handles keyed by name.
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
// MFE (the cms app): serve its table as fragments + a widget catalog
|
|
208
|
+
import { federate, mount } from '@ogygia/federation';
|
|
209
|
+
export const { dash } = federate({
|
|
210
|
+
name: 'cms', key: env.CMS_SIGNING_KEY, base: '/cms', expose: cms_router,
|
|
211
|
+
peers: { shell: { origin: SHELL, key: env.SHELL_PUBLIC_KEY }, dash: { origin: DASH, key: env.DASH_PUBLIC_KEY } },
|
|
212
|
+
widgets: { kpis: { props: ['org'], make: (props, { user }) => region(Kpis, { org: props.org, viewer: user }) } }
|
|
213
|
+
});
|
|
214
|
+
// Shell: peers + one table entry to mount the whole cms app
|
|
215
|
+
export const { cms, dash } = federate({
|
|
216
|
+
name: 'shell', key: env.SHELL_SIGNING_KEY, visitor: () => session(),
|
|
217
|
+
peers: { cms: { origin: CMS, key: env.CMS_PUBLIC_KEY, timeout: 800, cache: { ttl: 30_000 } }, dash: { origin: DASH, key: env.DASH_PUBLIC_KEY } }
|
|
218
|
+
});
|
|
219
|
+
'/cms/[...rest]': mount(cms), // whole app under shell chrome
|
|
220
|
+
<Region of={dash.widget('kpis', { org })} /> // a remote WIDGET as a region
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
- **The `peers` map is SYMMETRIC + bidirectional**: `peers.X.key` (X's PUBLIC key(s), a list = rotation) verifies inbound calls + thaw notices FROM X; the top-level `key` (this app's PRIVATE key) signs outbound. That is the whole trust config. `open: true` = an intentionally-unauthenticated endpoint (mTLS/mesh); exposing with no peer keys warns loudly (fail-closed). Keys: `npx ogygia keys <name>` → `<NAME>_SIGNING_KEY`/`<NAME>_PUBLIC_KEY`; read them via `$env/dynamic/private` (NOT `process.env` — Vite may not expose it to server modules).
|
|
224
|
+
- **A remote fragment IS a region** — `peer.page(path, dials?)` (a remote ROUTE) and `peer.widget(name, props?, dials?)` (a remote WIDGET) return `Promise<RegionValue>`, rendered `<Region of={…} />`. `render: 'static'` (default) bakes the fragment HTML into THIS render (one buffered signed hop) + adopts its postrender source-tags; `render: 'deferred'` is a hole the browser fetches from the SHELL's OWN signed endpoint (`/og/frag?…`) — the shell derives the visitor's claims server-side at hole time, so the browser chooses nothing (no open-proxy risk, no allowlist). A peer handle is a SERVER value → build the region in a load and render `data.x`. A router load passes it in-process; a Kit `+page.server.ts` load crosses it over the wire (the baseline `transport = { ...ogygia.transport }` hook already handles regions). GOTCHA (internal): a bake-static inline region MUST carry the html on the TOP-LEVEL `html` field (not just `props.html`) or `encode_region` throws "not awaited" — fixed in `federation/peer.ts` bake_static.
|
|
225
|
+
- **`mount(peer)`** (from `@ogygia/federation`) = whole remote app under shell chrome; status/redirects/form-actions translate, a mounted 4xx/5xx renders the MFE's error body WITH the real status. Canary: `mount(v2.pick({ off: cmsV1, on: cmsV2 }))` or `mount((c) => peer)`. Rollout: `when(cmsRollout, mount(cms))`. Stream: `mount(cms, { stream: true | { fallback } })` (GET only). `mount.kit(peer)` from a plain Kit catchall (`export const load = mount.kit(cms).load`).
|
|
226
|
+
- **Cross-app THAW (the headline)**: every `postrender.invalidate()` on one app notifies its peers, which drop the stored pages + cached documents that baked its fragments, and forward up the chain; a new deploy broadcasts a thaw-all on boot (`deploy: 'auto'` default, `'manual'` off). So a publish/deploy on the cms team thaws the shell pages that embedded cms fragments — no config. Provenance rides the wire (`sources` + `build`); the consumer files its stored page under peer-prefixed tags (`a:<peer>`/`p:<peer>:<path>`/`r:<peer>:<tag>`) so a thaw matches exactly. Pairs with [postrender](#postrender-kits-prerender-at-request-time).
|
|
227
|
+
- **Signing**: Ed25519, every hop; covers method+path+body+claims+audience(target host)+nonce, ±120s window, replay-rejected. Visitor identity rides INSIDE the signature (on-behalf-of), attaches in-process via a Symbol (an HTTP caller can't set a JS symbol), never transits the browser. `user(c)` reads the verified claims in an exposed router's loads/actions/widgets. `__catalog` (`/og/fragment/__catalog`) is the UNSIGNED manifest; `npx ogygia fragments <origin>` → typed stubs, `--check` fails CI on drift.
|
|
228
|
+
- Tracing: W3C `traceparent` continuity + per-team `Server-Timing` on every hop. CORS: a foreign island's chunk is a cross-origin `import()` — the MFE's `/_app/immutable/*` MUST send `Access-Control-Allow-Origin` (CDN/proxy; adapter-node does not).
|
|
229
|
+
- **`page` does not cross.** On the MFE server `$app/state` is the MFE's page; in the browser a mounted island hydrates inside the SHELL's document and the same read is the shell's page — same URL, but the shell's `route`/`params`/`page.data`. Dev warns once per island. Pass the value as a prop, or read it with a remote function.
|
|
230
|
+
- Tracing: W3C `traceparent` continuity + per-team `Server-Timing` on every hop.
|
|
231
|
+
- **`page` does not cross.** On the MFE server `$app/state` is the MFE's page; in the browser a mounted island hydrates inside the SHELL's document and the same read (`$app/state` and `$app/stores`) is the shell's page — same URL, but the shell's `route`/`params`/`page.data` (the MFE's seed stops at the fragment boundary). SSR paints the MFE value, hydrate repaints the shell's. Dev warns once per island (`[ogygia] page.data was read inside a mounted MFE island (<entry>, from <origin>)`). Pass the value as a prop, or read it with a remote function. The shell owns the chrome; the MFE's own layouts still render INSIDE the slot — keep them section chrome, or expose a table placed outside the site-header wrap.
|
|
232
|
+
|
|
233
|
+
## Flags & experiments (ONE primitive, zero client JS)
|
|
234
|
+
|
|
235
|
+
`import { flag, decide } from '@ogygia/flags'` (list `flags()` from `'@ogygia/flags/vite'`) — a kill switch, rollout, targeting rule, and A/B/n test are ALL `flag()`, decided server-side during SSR (csr=false pages A/B for free). You READ a flag by CALLING it; you branch with `.pick()`; you wire a vendor with one `decide()`. `experiment`/`layer`/`allowOverrides`/`onExposure`/`batchExposures`/`.bucket`/`.on` are GONE — collapsed into these.
|
|
236
|
+
|
|
237
|
+
- **Declare** — second arg is the shape: `flag('x')` (kill switch, off) · `flag('x', 10)` (number → sticky rollout %) · `flag('x', (c) => boolean|undefined)` (targeting: `true` on, `false` definitively off, `undefined` falls through) · `flag('x', { control: 80, bold: 20 })` (record → weighted variants; weights are RATIOS, first key is control). Third arg = `{ layer?, value?, fallback? }`.
|
|
238
|
+
- **Read by calling**: `checkout(c)` → boolean, `hero(c)` → `'control'|'bold'` (sticky per visitor, hash of name+`c.visitor.sub`).
|
|
239
|
+
- **`.pick()` — one verb, two forms**: `hero.pick({ control: A, bold: B })` (no `c`) = a `page()` slot (`ComponentPick`); `hero.pick(c, { control: 7, bold: 30 })` = this visitor's VALUE (typed + total, return = union of the map). At 3+ variants; a ternary is fine for 2.
|
|
240
|
+
- **`.value(c)`** — vendor-authored payload validated by the flag's `value` schema (Standard Schema); invalid → the required `fallback` (never undefined). `.stamp(c)` → `'hero:bold'` for `data-og-exp`/logs.
|
|
241
|
+
- **`decide({ source?, overrides?, exposure?, batch? })`** — ONE setup (hooks.server.ts). `source` is OpenFeature/OFREP/any resolver, resolved ONCE per request over the app's declared flags, then reads stay sync; `overrides` gates `?og-exp` beyond dev; `exposure` gets a batched array (built-in batching, drained at request end). No `decide()` = pure native, still sticky + zero-JS.
|
|
242
|
+
- **OpenFeature**: `import { openfeature, ofrep } from '@ogygia/flags/openfeature'` (no vendor SDK dependency — you pass your own client). `decide({ source: openfeature(client) })` or `ofrep({ url })`. A source variant not in the declared set is ignored; a slow/down source degrades to the native rule (never gates first byte).
|
|
243
|
+
- **Decision order**: `?og-exp=name:variant` override (dev-only unless `decide({ overrides })`) → carried signed claims (federation) → source → layer gate → native rule (targeting/weights/rollout) → control. Anonymous (no visitor sub) → control.
|
|
244
|
+
- **Layers (mutual exclusion)**: members sharing `{ layer: 'group' }` split visitors into equal LANES — one sticky hash of (group, visitor) over the SORTED member names picks each visitor's OWNER; non-owners answer control before their own rule runs (two experiments on one surface never share a visitor, so measurements stay attributable). Real exposure = split ÷ member count (50% in a 2-member layer = 25% of traffic). Override/carried/source sit ABOVE the gate.
|
|
245
|
+
- **Federation auto-carry**: every flag DECIDED this request self-registers its bucket into the signed claims — mounted teams render the same world, no hand-list. A shell that only routes (never reads the flag) pre-decides it: `routes(table, { flags: [csrMode] })`.
|
|
246
|
+
- **Manifest**: every literal-named `flag()` call site (AST-resolved `@ogygia/flags` imports, renames + namespaces) lands in `node_modules/.ogygia/flags-manifest.json` on the client build — `ogygia_flags` reads it; diff in CI to catch dead flags.
|
|
247
|
+
- **Plain Kit** (no ogygia router): `import` your flags module in hooks.server.ts so the source primes them before the first request (router apps register all at startup).
|
|
248
|
+
|
|
249
|
+
## Content
|
|
250
|
+
|
|
251
|
+
Package `@ogygia/content`, plugin `content({ markdown, presets })` from `'@ogygia/content/vite'`. **The server/wire split is the whole architecture.** The corpus (compiled markdown, possibly MB) lives in server modules; only tickets and plain data cross.
|
|
252
|
+
|
|
253
|
+
- Define collections in **`.server.ts`** (or `.remote.ts` / `src/lib/server/`): `content({ loader: import.meta.og.loader.markdown('../content/docs'), schema, filter })`. Defining one elsewhere = build warning (a client import would drag the corpus into the bundle).
|
|
254
|
+
- **Loaders** (macros, literal dir/glob/spec only): `.markdown('./docs')` (all .svx/.md, `meta.headings` free), `.folder('./docs')` (`+doc.svx` pages, `+meta.json` section labels, `NN-` ordering — what docs sites use), `.json('./data')`, `.git('owner/repo@ref:path')` (shallow checkout at build, cached). Lazy by default — each file is its own chunk, loaded on `get(id)`. Optional `{ preset: 'name' }` compiles the whole collection through a `content({ presets })` markdown variant (per-file module variants — same file in two collections with two presets works).
|
|
255
|
+
- **Reads**: `get(id)` (server-only) → `{ id, data, meta, body, rel, backlinks }`; returns `null` for unknown/filtered (404 in `load` with `error()`). `refs()` = shallow catalog. `body` is a held region → `<Region of={e.body} />` in the same SSR pass, islands inside wake. **To cross the wire, `await e.body`** — baking is the ONLY body representation that crosses (returning a raw body from a server load/remote is a caught error).
|
|
256
|
+
- **Wire**: `withRemotes(collection).list({ map })` / `.live.list` in a `.remote.ts`. Custom sources are just `{ refs, get }` (+ async-generator `live()` yielding change signals — anything = full re-read, `string[]` = incremental); `defineSource`/`toRawSource`/`mapRaw` build them.
|
|
257
|
+
- **site()** (in `.server.ts`, `outline` is the only required key) arranges collections into a navigable site: `nav/meta/page/search/switcher/emit.*/check/entries/load`. `remotes(site, { base })` from `'@ogygia/content/server'` mints the wire (`nav`, `meta`, `page`, `search`) — prerendered by default, `modes: { page: 'query' }` for per-request/preview (pairs with `site({ context })`). Route wiring: `export const entries = docs.entries; export const load = docs.load;` (`docs.load` = 404 guard + `redirect_from` 308s + dev checks — the piece people forget). Shell: `<DocsShell meta={await meta()}>` from `'@ogygia/content/docs-shell'` + theme.css/shell.css; `<Doc {view} />` renders a page view. `checks: [links()]` fails the build on broken in-prose links.
|
|
258
|
+
- **Blocks** (CMS/builder trees): registry maps `type` → component; `import.meta.og.regions('./blocks/*.svelte')` globs one `region: 'raw'` import per file, keyed by basename (spread manual `wake:` imports over the top for interactive blocks). `blocks(source, registry)` is a content source; page JSON tree → `entry.body`. **Call `get()` in a universal `+page.ts` load, NOT `+page.server.ts`** — a server load's data is serialized and a body is a same-pass live render (ogygia errors with exactly this). `blocks.resolve(tree, registry)` for a tree you already hold (server-side only — signing key). A 1000-block registry costs a page only the chunks/sheets of blocks it names.
|
|
259
|
+
- **Markdown authoring dialect** (zero imports in content; components auto-injected): `::: tip|warning|…` admonitions, `::: code-group` / `::: tabs` (same-label groups sync via localStorage), fence meta (`title=`, `{2-4}` highlights, `twoslash`), `+++`/`---` diff markers (opt-in transformers from `'@ogygia/content/markdown'`). Marked island imports work inside `.svx` — live demos in prose.
|
|
260
|
+
- Inline snippets in components: `import.meta.og.code(src, lang, meta?)` (one highlighted block through the app's own fence pipeline) and `import.meta.og.md(text)` (whole markdown passage, static only) — both return regions, build-rendered, cached.
|
|
261
|
+
|
|
262
|
+
## Macros (`import.meta.og.*`)
|
|
263
|
+
|
|
264
|
+
Compile constructs — rewritten by the Vite plugin, no runtime object exists. Family: `loader.*`, `code`, `md`, `regions`, `wire`, `bake`, `asRegion`, `$`, `source`. Shared laws: **literal arguments** (a `${…}` interpolation is a build error), AST-detected, loud `[ogygia]` build errors, zero runtime cost.
|
|
265
|
+
|
|
266
|
+
- `bake(fn)` runs `fn` at build (self-contained: may use module imports, not other locals; no `$app/*`; result devalue-serializable) and inlines the answer — imports that only fed it are dropped from the output.
|
|
267
|
+
- `asRegion(Comp, 'load')` marks a barrel/named import as a region where import attributes can't reach (re-exports, named exports).
|
|
268
|
+
- `$(fn)` brands a function so a REF can cross context into an island and rebind on the far side (bound captures).
|
|
269
|
+
|
|
270
|
+
## Packaging — islands from npm dependencies
|
|
271
|
+
|
|
272
|
+
A dependency ships islands, marked components, or whole route tables by declaring its own compile surface:
|
|
273
|
+
|
|
274
|
+
```jsonc
|
|
275
|
+
// the DEPENDENCY's package.json
|
|
276
|
+
{ "ogygia": { "files": ["./src/widgets", "./src/pages/**/*.svelte", "./src/routes.ts"] } }
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
- npm-`files`-style, package-relative: bare dirs recurse, globs allowed, escaping the package errors, missing entries warn + skip.
|
|
280
|
+
- The app's plugin auto-discovers **direct** deps with the field — no blind node_modules scan. Declared files are compiled exactly like app `src/` (`.svelte` AND `.ts`, `with { }` marks intact), and the package is auto-added to `ssr.noExternal` + `optimizeDeps.exclude`.
|
|
281
|
+
- Island ids key on `<pkg>/<rel>` — install-independent (pnpm store paths never leak; prod HTML keeps matching chunks across machines).
|
|
282
|
+
- A dep using `with { }` marks WITHOUT declaring the field gets a **loud build warning** (marks would otherwise be silently inert).
|
|
283
|
+
- If your package imports `ogygia` itself, keep `ogygia` external in your lib build.
|
|
284
|
+
|
|
285
|
+
## Plugin config + env (one grammar)
|
|
286
|
+
|
|
287
|
+
`ogygia({ … })` keys — the root IS the region system. Retired keys (`regions`, `regionTtl`, `preload`, `continuity`, `content`, `freeze`, `devtools`, `profiler`) are hard errors naming the new spelling, never aliases:
|
|
288
|
+
|
|
289
|
+
- `visible: { margin }` — default rootMargin for `visible` islands/holes
|
|
290
|
+
- `presets: { name: { render, wake, fetch, margin, maxAge, onExpire, revalidate, keep, prefetch, stitch } }` — named dial bundles (`with { preset: 'name' }`)
|
|
291
|
+
- `ttl: 3600` — capability-URL lifetime in seconds (clamped 60–86400); anonymous holes on a page a load marks shared-cacheable (`setHeaders({ 'cache-control': 'public, s-maxage=…' })`) are signed for the page's cache life, and a hole on an older cached copy renews itself (`&renew=1`) when it wakes
|
|
292
|
+
- `maxPageAge` — how long after expiry a cached page's anonymous hole may still renew (default 8 days, `0` = off)
|
|
293
|
+
- `router: false | { viewTransitions?, forms?, serverDelta? }` (defaults true/true/false)
|
|
294
|
+
- `plugins: [...]` — the feature packages (see Wiring). Their options live on their factories:
|
|
295
|
+
- `content({ markdown?, presets? })`
|
|
296
|
+
- `devtools({ dock = true, profiler? })` — `profiler: true | { secret?, path = '/__profiler', sampleInterval, maxReports, network, heap, trap, sample, sink, … }` is the ONLY profiler wiring (the handle auto-mounts it; nothing in hooks). A `secret` passed here is baked into the server bundle (for hosts with no runtime env, like Amplify); leave it out to read `OGYGIA_PROFILER_SECRET` at runtime. The dock: on in the dev server; in a build a small launcher (warned once at build) that stays off for every visitor until a browser turns it on (`?og-devtools=on`, `ogygia.devtools.on()`, the `og_devtools=on` cookie). A switched-on page is sent `private, no-store` and never stored. Keep it out of production: `plugins: [...(mode !== 'production' ? [devtools()] : [])]`. Profiler code: `@ogygia/devtools/profiler` (`span`, `instrument`), `/profiler/client` (`mark`), `/profiler/storage/sqlite|redis|postgres`
|
|
297
|
+
- `postrender({ ttl, default })`, `flags()`, `federation()`, `router()` — see their sections
|
|
298
|
+
- `rateLimit: false | { max = 60, windowMs = 60_000 }` — per-IP budget on the signed island endpoint
|
|
299
|
+
- `sessionCookie: false | '<name>'` — seals capability URLs to a cookie (not compatible with PPR holes)
|
|
300
|
+
- `importKeys` — rename the `with { }` attribute keys the transform claims
|
|
301
|
+
- `barrels: true | { packages?, force?, keep?, importers?, followPackages?, report?, debug? }` — barrel imports → leaf imports at transform time (dev graph + build chunk graph hold only used leaves). `true` = every PURE project barrel (only imports/re-exports/types); dependencies opt-in via `packages` (dev pre-bundling would double a bypassed dep leaf); `force` rewrites an impure barrel and keeps `import 'barrel'`; marked imports (`with { wake }` …) are NEVER rewritten (use `asRegion` for a barrel binding). Same pass standalone: `debarrel()` from `'ogygia/vite'`. Build prints a report: 8 biggest barrels + count (`report: 'all'` = every barrel, `false` = silent)
|
|
302
|
+
|
|
303
|
+
Env: `OGYGIA_SECRET` (stable MACs + island-id salt across deploys; required for PPR; prod rejects short secrets) · `ORIGIN` (Kit CSRF — POSTs 403 without it) · `OGYGIA_PROFILER_SECRET` (profiler UI auth in prod) · federation keys are app-chosen names from `npx ogygia keys` (passed explicitly, never auto-read).
|
|
304
|
+
|
|
305
|
+
Event layer: `ogygia/events` (`add_sink`, `snapshot`), compiled in only when a plugin asks (devtools does). Tests: `@ogygia/testing` (`withOgygia(test, expect)`; the server under test lists `devtools()`).
|
|
306
|
+
|
|
307
|
+
**Writing a plugin (first-party only, UNSTABLE — no promise to outside authors):** `definePlugin({ name: '<package name>', server?: import.meta.resolve('./server.ts'), client?: import.meta.resolve('./client.ts'), options /* JSON */, enforce?, build(b) })` from `'ogygia/vite'` — it RETURNS a Vite plugin the app lists beside `ogygia()`; a factory that needs Vite hooks of its own returns `[definePlugin({…}), itsVitePlugin]`; `b.routeConfig(key)` (a key inside Kit's `config` page option, read at build, answered by `ctx.routeConfig(key)`) · `b.foreignHydrate()` · `b.macro(name, fn)` · `b.markup(source)` · `b.events({ lazy })` · `b.measure()` · `b.files(scan)` · `b.pageModules({ importers, files })`. Realms are NAMED by the plugin (no package export needed): the `server` module default-exports `defineServer({ setup(app), state(), handle(ctx, next), request, page, response, done, …sharedHooks })` from `'ogygia/plugin'` (hooks: `request`/`page` first-Response-wins, `response` may replace, `done` always, `handle` wraps all); shared hooks via `app.notify.x()` / `app.ask.x()` (core names: `invalidate`, `invalidated`, `lastBuild`; declaration-merge `ServerHooks`); `ctx.facts` (`storable`, `perVisitor`, `streamed`, `private`, `answerForm`, `sources`, `assignments`; merge `RequestFacts`); the `client` module default-exports `defineClient({ boot({ kitPage }), regionMount(el, { entry, urls }), regionUnmount(el, { entry }) })` from `'ogygia/plugin/client'`. Helpers: `ogygia/plugin/utils`, `ogygia/plugin/css`, `ogygia/plugin/testing` (`startPlugin`). Docs: /docs/reference/plugins.
|
|
308
|
+
|
|
309
|
+
CLI (`@ogygia/cli`; `npx ogygia …` runs it): `npx ogygia init` · `site init` · `keys [name]` (Ed25519 pair to stdout, never a file) · `fragments <origin> [--out f|--check f]` (typed widget stubs; `--check` exits 1 on drift) · `ai` (installs this skill + MCP into `.claude/`) · `mcp` (stdio server).
|
|
310
|
+
|
|
311
|
+
## Postrender (Kit's prerender, at request time)
|
|
312
|
+
|
|
313
|
+
`@ogygia/postrender`, listed as `postrender()` (from `'@ogygia/postrender/vite'`) beside `ogygia()`, stores pages at request time: a GET page whose render observed NO personalization (non-default cookie/header/locals/flag reads, cookie writes, set-cookie, non-200, streamed loads, query strings — each named in a dev note) renders ONCE on its first request, is stored WHOLE (html + headers; permanent redirects too) and is served as bytes until an invalidation clears it — Kit, loads, and Svelte never run on a hit. Stampedes single-flight (N concurrent colds = ONE render). Holes stay live: `render: 'deferred'` regions inside a stored shell fetch fresh per visitor (their capabilities mint prerender-grade; keep `OGYGIA_SECRET` stable). The name mirrors `prerender` (build time) → `postrender` (request time).
|
|
314
|
+
|
|
315
|
+
- Vite config is the switch + policy ONLY: `postrender({ ttl, default })` (TTL backstop in seconds, ≤24h). Live objects are the plain argument of a Kit handle that goes BEFORE ogygia's: `export const handle = sequence(postrender.handle({ store, edge }), ogygia.handle())` from `'@ogygia/postrender'`; `postrender.handle()` with no store = in-process LRU. It is NOT a key of `ogygia.handle()`. Wrong order = dev error naming the fix (production: one warning, still serves); no `postrender.handle()` at all = memory store + one warning with the line to paste.
|
|
316
|
+
- **Whole-app vs opt-in**: `postrender()` (or `{ default: true }`) = whole app on, observed-purity refuses personalized pages, opt a route OUT in Kit's own page option: `export const config = { postrender: false }`. `postrender({ default: false })` = OPT-IN: nothing stores unless a page/layout sets `config = { postrender: true }`. Merged exactly like Kit merges `config` (layouts → page, page's key wins; at one level the universal module beats the server one), read at BUILD time — literal `true`/`false` only (a computed value is a build error). Nothing is stripped: Kit accepts any key inside `config`, so no plugin invents exports in Kit's files. Opting a route in only makes it eligible, the purity check still decides. Enumerates every route with a `+page.*` (svelte / universal / server — redirect-only routes included).
|
|
317
|
+
- **Programmatic routes** take the same `config` in table grammar: `page(C, { config: { postrender: false } })`, `layout(name, C, { config })`, `routes(table, { config })` — merged table → layouts → page. The table registers a matcher at construction; the handle asks it for any request Kit's file router didn't claim (`route.id` null under `sequence(postrender.handle(), ogygia.handle(), app.handle)`, or a catch-all `+server.ts`). Endpoint routes and unclaimed paths follow the vite-config default. The router's default `cache-control: no-store` document stamp yields to the verdict on eligible requests (stored → `public, s-maxage`; refused → `private, no-store`; an explicit page header always wins).
|
|
318
|
+
- Invalidation (any server code, e.g. a CMS webhook endpoint; reaches the store/edges `postrender.handle()` was given): `postrender.invalidate('/fr/fr/solar/')` (exact URL), `postrender.invalidateWhere({ prefix: '/fr/fr/' })` (subtree = locale/section nuke), `postrender.invalidate(fn, [args])` (declared sources).
|
|
319
|
+
- Declared sources (the reverse index): `export const loadDoc = import.meta.og.source(async (…) => …)` — top-level `export const` only (macro laws). Every page whose render calls it files a receipt; `invalidate(loadDoc, [args])` evicts exactly the consuming pages, at origin AND every edge. `{ key: (…args) => string }` canonicalizes exotic args.
|
|
320
|
+
- Stores: `memoryStore` (default) · `valkey(client)` · `upstash({ url, token })` · `cloudflareKv(binding)`. Edges: `akamai(cfg)` · `cloudfront(cfg)` · `cloudflare(cfg)`. Fan-out is allSettled inside ogygia — one edge down never fails a publish or a request.
|
|
321
|
+
- The vary law is the cookie-less CDN key, formalized: DEFAULT-valued reads still store (that render IS the canonical), and once minted everyone gets the canonical copy. Truly per-visitor content belongs in a deferred hole; A/B'd pages auto-disqualify on any flag read.
|
|
322
|
+
- Eligible responses are stamped `cache-control: public, s-maxage=<ttl>` plus each edge's tags (Akamai/Cloudflare prefix tags), so CDNs follow proven per-page headers instead of blanket rules. csr=true pages store the same — Kit hydrates the stored copy.
|
|
323
|
+
- **Self-freshening header on a stored page** (SSR'd, no deferred fallback): mark the component `with { render: 'live', wake: 'load' }` INSIDE a small island host — its HTML bakes into the stored bytes, and on served-from-store documents (the handle stamps a doc marker) it revalidates on first mount: a fresh server render with the visitor's cookies, swapped in. Anonymous traffic mints the canonical (default-valued reads store). A live region at page top level OUTSIDE any island bakes once and never revalidates — always give it an island host (dev warns).
|
|
324
|
+
- **Validators are page-level**: entries carry a store-time `etag` + `last-modified`, and the handle answers `if-none-match`/`if-modified-since` with a bodiless 304 — CDN revalidations (Akamai prefresh) and browser reloads of stored pages cost zero body bytes. Refused pages with no app cache-control are stamped `private, no-store` (the verdict speaks both directions).
|
|
325
|
+
- **Dev never serves or fills the store** — edits always re-render. The verdict still teaches: eligible pages answer `x-ogygia-postrender: would-store`; refusals print one named console note per path.
|
|
326
|
+
- **Serve-time stitching** (the zero-flash upgrade over live): `with { render: 'deferred', stitch: 'serve' }` (presets: `stitch: true`). The shell stores once; EVERY serve re-renders the hole server-side with the visitor's cookies and splices it into the stored bytes — personalized in view-source, first response, no JS needed. Stitched pages are per-visitor: `private, no-store`, no validators, edge-bypassed. Fail-open per hole: a failed render ships the fallback and the client fetches as a normal deferred hole. Nested islands inside stitched content wake themselves.
|
|
327
|
+
- **Edge stitching (ESI)**: `with { render: 'deferred', stitch: 'edge' }` keeps the EDGE copy: the page stays a normal cacheable stored entry (`public, s-maxage`, validators), the capture rewrites each edge hole into `<esi:remove>hole</esi:remove><esi:include src="<signed capability>" onerror="continue"/>` and stamps `Surrogate-Control: content="ESI/1.0"`; an ESI CDN (Akamai/Fastly/Varnish — enable ESI on the property) serves the shell from cache and fetches only the include from origin per request (personal first paint, origin renders ONE region). Degrades on its own: where ESI is not processed (direct origin, dev, CloudFront/Cloudflare) the include is inert, the fallback ships and the client fetches as a normal deferred hole. One `serve` hole on a page taints the whole page to origin-serve (its edge holes get server-stitched too). Ladder: browser (`live`) → edge (`stitch: 'edge'`) → origin (`stitch: 'serve'`). GOTCHA: the stitch MODE is part of the wrapper dedupe key (`identity.ts` strategyKey) — without it a `serve` and an `edge` hole of the same component share one wrapper and the whole page flips mode. Local proof: `pnpm demo:esi` boots the fixture behind an ESI-processing akamai emulator (+ a pass-through cloudfront one) and prints the walkthrough; deck S18 (origin) + S19 (through the edge).
|
|
328
|
+
|
|
329
|
+
## How it works underneath (why the rules are what they are)
|
|
330
|
+
|
|
331
|
+
- The transform (Vite plugin, enforce:pre, before sveltekit) rewrites a **marked import binding itself** into a virtual wrapper component — that's why the binding is portable (lists, `{@const Active = A}`, dynamic use) and why attributes must be static: SSR must emit a shell per usage site.
|
|
332
|
+
- SSR emits `<ogygia-region wake|render="defer" when|entry|endpoint …>` + a devalue `<script data-ogygia-props="<fp>">` sidecar (on a Kit page, all island sidecars sit at the END of the body keyed by the region's `data-og-fp`, one per distinct island; elsewhere the sidecar is the next sibling — `runtime/sidecar.ts` finds either). The page seed (`application/ogygia-page`) follows them and ships only when an island on the page reads `$page`; when it ships, island props that are `page.data` subtrees (same object OR a clone) serialize as `["OgygiaSeedRef", path]` into it (seed-refs.ts) — the client copies the node out of the seed. The runtime custom element schedules per `wake`, dynamic-imports the entry URL written **on the element** (Astro-style — the shared runtime never embeds an app-wide island map), parses props, `hydrate()`s in place. `disconnectedCallback` unmounts — SPA swaps clean up for free.
|
|
333
|
+
- Free-variable analysis captures host values referenced in island markup → serialized per instance; that's the snapshot law, enforced at build (mutated captures error; `$store` reads hoisted to value snapshots with a warn) and in dev (proxy warns).
|
|
334
|
+
- `deferred`/`live` holes fetch `GET <endpoint>?id&props&exp&sig` — a time-limited HMAC **capability URL** minted at SSR. The handle verifies the MAC before spending render CPU, renders the component server-side (cookies/remotes/await work), streams HTML back. Fetched HTML is inert (`createContextualFragment` — scripts don't run); trust root is the server MAC. Don't "sanitize" hole HTML or gate the endpoint differently — read internal/notes/INVARIANTS.md before touching any of this.
|
|
335
|
+
- Inside island graphs, `$app/state|stores|navigation` resolve to shims (Kit's client is uninitialized under csr=false); a page seed script provides `page.data`. Don't "fix" the shims away — that pulls Kit's router into island chunks. On csr=true documents the kit-world page thread hands the shims Kit's real `page`, so shared modules read correct data there too.
|
|
336
|
+
- The runtime entry is **feature-selected at build**: router, lakes, live, keep, interaction each add code only when the app's marks use them.
|
|
337
|
+
|
|
338
|
+
## Traps (each has burned an agent before)
|
|
339
|
+
|
|
340
|
+
- `import('./X.svelte', { with: { wake|render|preset } })` — **fails the build on purpose** (Vite strips attributes; there'd be no SSR shell). Host island + plain `await import()` = regular component, not an island.
|
|
341
|
+
- Hand-rolling `import()` + `mount()` for laziness — that's `wake: 'visible'`/`'idle'` with SSR HTML and a signed boundary already.
|
|
342
|
+
- A block-level island (`<div>`-rooted) placed inline in a `<p>` **hydrates twice** (parser hoists it out). Inline placement needs a `<span>`-rooted component; the framework warns.
|
|
343
|
+
- Making a `deferred` region interactive "somehow" — it never ships JS. Nest a `wake` island inside its component.
|
|
344
|
+
- `csr = true` for one interactive widget — mark one import instead. Conversely, one island wrapping the whole page = `csr = true` with extra steps.
|
|
345
|
+
- Reading context in an event handler — read during setup, keep the reference.
|
|
346
|
+
- `$store` read in a snippet crossing into an island = frozen VALUE snapshot (auto-hoisted, warned with a trace). Wanting it live means a wired class / `og_derived`, not the store.
|
|
347
|
+
- Two wire codecs with the same `id` clobber each other in the session Keep (dev warns).
|
|
348
|
+
- `maxAge: 60` = 60 **seconds** on a deferred hole (HTTP), 60 **ms** on live/remount (JS staleness). Use duration strings (`'1h'`, `'30s'`) and the trap disappears.
|
|
349
|
+
- `app.$infer` is type-only — READING it at runtime throws. `export type App = typeof app.$infer`.
|
|
350
|
+
- Yielding a component from a server-only module in a streamed page/mount fallback without `with { region: 'raw' }` — no client leg, bake errors loudly into the slot's error card.
|
|
351
|
+
- Streamed pages: status/title/headers flush with the FIRST yield; later `setHeaders` are lost.
|
|
352
|
+
- Federation deferred holes are shell-signed capabilities (the browser picks nothing), so there is no open-proxy surface; `federate({ expose | widgets })` with no peer keys serves unsigned (loud warn — `open: true` only on purpose).
|
|
353
|
+
- Federation failover: an origin ARRAY per peer is a replica pool — reads walk it; **POSTs never fail over** (pinned to primary).
|
|
354
|
+
- Dev ≠ prod: SSR query seeding degrades to refetch in dev; the mutation proxy is dev-only; `import.meta.glob` behavior can differ between dev and build. Verify data flows against `pnpm build` + preview.
|
|
355
|
+
- Docs-site routes: forgetting `export const load = docs.load` (404s/redirects/checks) or `entries` (prerender misses leaves).
|
|
356
|
+
|
|
357
|
+
## In this monorepo
|
|
358
|
+
|
|
359
|
+
- Library src: core in `packages/core/src` (`@ogygia/core`; `packages/ogygia` is the thin `ogygia` package over it), each plugin in `packages/<name>/src` (flags, postrender, federation, content, devtools, router, testing, cli); build first: `pnpm build:lib` (apps consume dist). Full check: `pnpm check`; e2e: `pnpm test:e2e` (every package's suite side by side in lanes, the big ones split by spec file; `:fast` skips the fixture builds; `:serial` runs them through turbo one at a time, unchanged ones replayed from the cache).
|
|
360
|
+
- Tests, three layers: unit = Vitest `node` project (`pnpm --filter @ogygia/core test:node`, imports dist); island tests in a REAL browser = Vitest browser mode (`test:browser`, Playwright chromium, `test/browser/*.test.ts` — SSR HTML from `setup.ts`, boots the real runtime); e2e = Playwright, SCOPED BY PACKAGE: each package's specs live with it (`packages/<name>/e2e/*.spec.ts` + its `playwright.config.ts`, against its own small fixture app in `e2e/app` that lists only that plugin; core's specs in `apps/palaestra/e2e` against the palaestra, which lists no plugin), each package has a turbo `e2e` task, so turbo's package graph + cache decide what reruns (a devtools change: devtools' suite (+ testing's, which drives the devtools lab); a core change: all). One package: `pnpm turbo run e2e --filter=@ogygia/devtools`; one spec: `pnpm --filter palaestra e2e split-brain`. Shared harness (`check()`, `spawn_server`, regexes, `suite()` config) = `@ogygia/tooling/e2e`.
|
|
361
|
+
- Internal naming: non-public exports are `snake_case`; only public API + classes/types are camel/Pascal.
|
|
362
|
+
- Design/trust rationale: `internal/notes/DESIGN.md`, `INVARIANTS.md`, `regions.md`, `config.md`. Docs source of truth: `apps/docs/src/content/docs`.
|
|
363
|
+
- This skill has TWO copies that must stay identical: the bundled source `packages/cli/ai/SKILL.md` (what `npx ogygia ai` installs into consumer apps) and this repo's own `.claude/skills/ogygia/SKILL.md`.
|