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,234 @@
1
+ # Scaling to many apps
2
+
3
+ [← Sharing libraries](./03-sharing-your-app-libraries.md) · [Docs index](./README.md) · [Security →](./05-security.md)
4
+
5
+ For an app hosting hundreds of snippets with a large SDK surface, this is where
6
+ things go wrong, and it is mostly about *how* you register, not *how much*.
7
+
8
+ ## Register loaders, not values
9
+
10
+ Registering a module **by value** means your page imports it statically, so it
11
+ ships to every visitor whether or not any snippet uses it:
12
+
13
+ ```ts
14
+ import * as charts from 'my-charts'; // ❌ in the page bundle, always
15
+ modules={{ 'my-charts': charts }}
16
+ ```
17
+
18
+ Register a **loader** and it becomes a dynamic import, which the bundler
19
+ code-splits and fetches only when a snippet imports that specifier:
20
+
21
+ ```ts
22
+ modules={{ 'my-charts': defineLoader(() => import('my-charts')) }} // ✅
23
+ ```
24
+
25
+ Measured on the playground with a 192 KB vendor module:
26
+
27
+ | | initial JS for the page |
28
+ |---|---|
29
+ | registered by value | 827 KB |
30
+ | registered as a loader | **666 KB** |
31
+
32
+ With the loader, the module is absent from every initially-loaded chunk, and the
33
+ browser fetches its chunk at the moment a snippet imports it - verified by
34
+ watching network activity, not inferred.
35
+
36
+ **A registry of 300 loaders costs nothing.** `next-live` only resolves
37
+ specifiers that appear in the compiled snippet, so unused entries are never
38
+ touched. Your provider can list everything without penalty.
39
+
40
+ > If your app already imports the library for its own use, the loader costs
41
+ > nothing extra either, it hands over the already-loaded module. See
42
+ > [Sharing libraries](./03-sharing-your-app-libraries.md#no-second-download-either).
43
+
44
+ ## The library's own weight
45
+
46
+ Before your registry, what does `next-live` itself cost a page? Measured with
47
+ esbuild, React externalised, code-splitting on:
48
+
49
+ | Page imports | Entry chunk |
50
+ |---|---|
51
+ | `LiveProvider` + `LivePreview` + `LiveError` | **16.1 KB** |
52
+ | the above plus `LiveEditor` from `next-live/editor` | 102.1 KB |
53
+
54
+ The difference is `prism-react-renderer`. It is only needed to syntax-highlight
55
+ an editor, so it lives on its own entry and is an optional peer dependency -
56
+ a page that merely *runs* stored snippets never downloads or installs it.
57
+
58
+ Sucrase is not in either number: it is fetched as a separate chunk on first
59
+ compile, or skipped entirely if you
60
+ [precompile on the server](#compile-cost-and-skipping-the-transpiler).
61
+
62
+ ## Design an SDK surface, not a mirror of your codebase
63
+
64
+ The bigger question is *what* to register.
65
+
66
+ Everything in the registry is a public contract with your snippet authors. Expose
67
+ 500 internal functions and you can never rename or move any of them again -
68
+ every stored snippet becomes a reason not to refactor.
69
+
70
+ Prefer a small, deliberate set of stable namespaces:
71
+
72
+ ```
73
+ @app/store @app/ui @app/data @app/charts
74
+ ```
75
+
76
+ each a thin re-export of the real implementation:
77
+
78
+ ```ts
79
+ // lib/live-sdk/modules/charts.ts
80
+ export { BarChart, LineChart } from 'my-charts';
81
+ ```
82
+
83
+ This, more than any technique below, is what stops the provider growing without
84
+ bound. It is a design decision, not a technical one.
85
+
86
+ ## Compose the registry from small files
87
+
88
+ `createRegistry` merges groups, so the surface lives in one file per domain
89
+ rather than one object that grows forever:
90
+
91
+ ```ts
92
+ // lib/live-sdk/index.ts
93
+ import { createRegistry } from 'next-live';
94
+ import { vendorModules } from './vendor';
95
+ import { storeModules } from './stores';
96
+ import { uiModules } from './ui';
97
+
98
+ export const liveModules = createRegistry(vendorModules, storeModules, uiModules);
99
+ ```
100
+
101
+ ```tsx
102
+ <LiveProvider code={source} modules={liveModules} />
103
+ ```
104
+
105
+ Adding a capability means adding a group, not editing the component that renders
106
+ the provider. Later groups win, and a key defined by two groups logs a warning in
107
+ development - a silent override is painful to debug.
108
+
109
+ ## Generate entries from the filesystem
110
+
111
+ For a directory that is genuinely one-file-per-thing, `registryFromGlob` removes
112
+ the hand-maintenance entirely. Turbopack's `import.meta.glob` already returns
113
+ `{ path: () => import(path) }` - lazy thunks, exactly the shape a registry needs:
114
+
115
+ ```ts
116
+ // lib/live-sdk/modules.ts
117
+ import { registryFromGlob } from 'next-live';
118
+
119
+ export const storeModules = registryFromGlob(
120
+ import.meta.glob('./modules/*.ts'),
121
+ (path) => {
122
+ const name = path.split('/').pop()!.replace(/\.tsx?$/, '');
123
+ return name ? `@app/${name}` : null; // return null to omit a file
124
+ },
125
+ );
126
+ ```
127
+
128
+ Add a file to `./modules` and snippets can import it immediately, still lazily.
129
+ This file never has to change again.
130
+
131
+ ### The directory rule that will cost you an hour
132
+
133
+ **The pattern must be at or below the calling file's own directory.**
134
+
135
+ Turbopack resolves the pattern relative to the file calling it, and a `../`
136
+ pattern **silently matches nothing**. It returns an empty object rather than
137
+ erroring, so your registry quietly has zero entries and every snippet fails with
138
+ "module is not registered".
139
+
140
+ Verified against Turbopack 16.3.5:
141
+
142
+ | Pattern | Result |
143
+ |---|---|
144
+ | `'./*.ts'` | ✅ matches |
145
+ | `'./modules/*.ts'` | ✅ matches |
146
+ | `'../*.ts'` | ❌ empty |
147
+ | `'../store.ts'` (explicit file) | ❌ empty |
148
+
149
+ That constraint pushes you toward the better shape anyway: keep a dedicated
150
+ directory of modules exposed to snippets, each a thin re-export. Your SDK
151
+ surface becomes visible in the filesystem instead of buried in a filter list.
152
+
153
+ `import.meta.glob` requires **Turbopack** - it does not exist under webpack.
154
+ Under webpack, build the equivalent `{ path: () => import(path) }` object
155
+ yourself and pass that to `registryFromGlob`.
156
+
157
+ ## Deep subpaths
158
+
159
+ Some packages are used through hundreds of deep modules rather than a barrel -
160
+ `big-lib/charts/BarChart`, `big-lib/format/currency`, and so on. A **prefix
161
+ entry** - a key ending in `/`, serves the whole subtree from one line,
162
+ receiving the full specifier:
163
+
164
+ ```ts
165
+ 'big-lib/': defineLoader((specifier) => {
166
+ const subpath = specifier.slice('big-lib/'.length);
167
+ return import(`big-lib/${subpath}`);
168
+ }),
169
+ ```
170
+
171
+ Verified with Turbopack: deep subpaths resolve, and each arrives as its own
172
+ chunk fetched on demand.
173
+
174
+ ### Know the trade-off
175
+
176
+ A template-literal import compiles to a **context** - the bundler emits a chunk
177
+ for every module matching the pattern, including ones no snippet ever imports.
178
+ Confirmed in the playground: a module imported by no app still had chunks
179
+ generated for it.
180
+
181
+ Those chunks are not in your initial bundle, so page load is unaffected. But
182
+ build time and output file count grow with the size of the package. This was
183
+ verified on a small package, measure it yourself before pointing a prefix entry
184
+ at something with hundreds of modules.
185
+
186
+ If build times suffer, narrow the scope:
187
+
188
+ ```ts
189
+ // Only the subtrees you actually use
190
+ 'big-lib/charts/': defineLoader(/* … */),
191
+ 'big-lib/format/': defineLoader(/* … */),
192
+ ```
193
+
194
+ or list the specific modules explicitly. An explicit list is more verbose but is
195
+ also an allowlist, which some teams prefer for exactly that reason - and it is
196
+ the approach the [recommended SDK pattern](#design-an-sdk-surface-not-a-mirror-of-your-codebase)
197
+ gives you for free.
198
+
199
+ ## Compile cost, and skipping the transpiler
200
+
201
+ Compilation is a few milliseconds, and results are not shared between page
202
+ loads. If you serve many stored snippets, transpile once on the server and cache
203
+ by content hash, the browser then never downloads Sucrase at all:
204
+
205
+ ```ts
206
+ // app/api/apps/[id]/route.ts
207
+ import { precompile } from 'next-live/server';
208
+
209
+ const result = precompile(source, { filePath: `${id}.tsx` });
210
+ // result.hash is a stable cache key / ETag
211
+ ```
212
+
213
+ ```tsx
214
+ import { precompiledTransform } from 'next-live';
215
+
216
+ <LiveProvider code={source} transform={precompiledTransform(compiled)} />
217
+ ```
218
+
219
+ `precompiledTransform` returns a constant closure over the server result. Only
220
+ apply it while `code` still matches the source that was precompiled - as soon as
221
+ an author edits the snippet, drop back to client transpile or re-precompile.
222
+ See [Troubleshooting: precompile ignores edits](./07-troubleshooting.md#precompile-ignores-my-edits).
223
+
224
+ You can also warm the transpiler chunk during idle time so the first compile is
225
+ not gated on a network round trip:
226
+
227
+ ```ts
228
+ import { preloadTranspiler } from 'next-live';
229
+ useEffect(() => preloadTranspiler(), []);
230
+ ```
231
+
232
+ ---
233
+
234
+ [← Sharing libraries](./03-sharing-your-app-libraries.md) · [Docs index](./README.md) · [Security →](./05-security.md)
@@ -0,0 +1,204 @@
1
+ # Security
2
+
3
+ [← Scaling](./04-scaling.md) · [Docs index](./README.md) · [API reference →](./06-api-reference.md)
4
+
5
+ Read this before you deploy. It is short, and the first rule is the one that
6
+ matters.
7
+
8
+ ## The model in one paragraph
9
+
10
+ `next-live` runs code with `new Function` on your page. That code has the page's
11
+ full authority - cookies, storage, DOM, and your APIs as the signed-in user.
12
+ This is safe when snippet authors are people you trust, and unsafe when they are
13
+ not. **Everything below assumes the first.**
14
+
15
+ ## 1. Feed the evaluator only from your own API
16
+
17
+ This is the rule that protects you.
18
+
19
+ Snippet source must come from your own authenticated API. Never from a query
20
+ parameter, hash fragment, `localStorage`, `postMessage`, or any other channel a
21
+ visitor can influence.
22
+
23
+ ```tsx
24
+ // ✅ from your API
25
+ const { source } = await fetch(`/api/apps/${id}`).then((r) => r.json());
26
+
27
+ // ❌ never
28
+ const source = new URLSearchParams(location.search).get('code');
29
+ ```
30
+
31
+ If an attacker can control what reaches `code`, they run arbitrary JavaScript in
32
+ your origin - and no CSP setting prevents it, because the execution is by
33
+ design. Every other measure here assumes this one holds.
34
+
35
+ ## 2. Treat stored snippets as code
36
+
37
+ A row in your snippets table is now equivalent to a deploy. Give it the controls
38
+ a deploy gets:
39
+
40
+ - **Authorization on the write path.** Whoever can save a snippet can run
41
+ JavaScript on your site. That endpoint deserves your strictest check.
42
+ - **An audit trail**, who changed what, when.
43
+ - **Version history**, so you can roll back a bad snippet the way you roll back
44
+ a bad deploy.
45
+
46
+ ## 3. Scope `'unsafe-eval'` to the routes that run snippets
47
+
48
+ `new Function` requires `'unsafe-eval'` in `script-src`. Next's CSP guide gates
49
+ that directive behind a dev-only check, because "Neither React nor Next.js use
50
+ `eval` in production by default" - `next-live` does.
51
+
52
+ It does **not** have to apply to your whole application. In `proxy.ts`:
53
+
54
+ ```ts
55
+ import { NextResponse, type NextRequest } from 'next/server';
56
+
57
+ /** Only these routes evaluate snippets. */
58
+ const RUNNER_ROUTES = ['/apps', '/playground'];
59
+
60
+ export function proxy(request: NextRequest) {
61
+ const isDev = process.env.NODE_ENV !== 'production';
62
+ // React uses eval in development for server error stacks, so dev needs the
63
+ // directive everywhere regardless of route.
64
+ const needsEval =
65
+ isDev || RUNNER_ROUTES.some((r) => request.nextUrl.pathname.startsWith(r));
66
+
67
+ const csp = [
68
+ "default-src 'self'",
69
+ `script-src 'self' 'unsafe-inline'${needsEval ? " 'unsafe-eval'" : ''}`,
70
+ "style-src 'self' 'unsafe-inline'",
71
+ "img-src 'self' blob: data:",
72
+ "font-src 'self' data:",
73
+ "connect-src 'self'",
74
+ "object-src 'none'",
75
+ "base-uri 'self'",
76
+ "form-action 'self'",
77
+ "frame-ancestors 'none'",
78
+ ...(isDev ? [] : ['upgrade-insecure-requests']),
79
+ ].join('; ');
80
+
81
+ const response = NextResponse.next();
82
+ response.headers.set('Content-Security-Policy', csp);
83
+ return response;
84
+ }
85
+
86
+ export const config = {
87
+ // Without a matcher this runs on every request, including static assets.
88
+ matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
89
+ };
90
+ ```
91
+
92
+ A working copy is in `apps/playground/proxy.ts`. The `/rsc-check` route there is
93
+ a deliberate control: it is *not* a runner route, so evaluation is blocked and
94
+ you can see what the failure looks like.
95
+
96
+ > CSP moved to `proxy.ts` in Next 16 - `middleware.ts` was renamed.
97
+
98
+ ### The catch: a CSP does not follow a client-side navigation
99
+
100
+ Route-scoped CSP and client-side routing do not compose, and the failure is
101
+ quiet.
102
+
103
+ A policy is attached to a **document**. A Next `<Link>` navigation fetches no
104
+ new document, so the policy from wherever the visitor first landed stays in
105
+ force for the rest of the session. Land on `/` (no `'unsafe-eval'`), click a
106
+ `<Link>` to `/apps`, and every snippet fails to compile, even though `/apps`
107
+ would have been served the right policy had it been loaded directly.
108
+
109
+ It works in development, because React needs `'unsafe-eval'` there anyway and
110
+ the dev branch grants it everywhere. It works if you reload directly onto a
111
+ runner route. It breaks on the path most visitors actually take.
112
+
113
+ Cross that boundary with a real page load:
114
+
115
+ ```tsx
116
+ // A link into a runner route must not be a client-side navigation.
117
+ <a href="/apps">Open the app</a> // ✅ new document, correct policy
118
+ <Link href="/apps">Open the app</Link> // ❌ keeps the previous page's policy
119
+ ```
120
+
121
+ The playground wraps this in a `RunnerLink` component that reads the same
122
+ `RUNNER_ROUTES` list the proxy uses, so the two cannot drift. See
123
+ `apps/playground/components/RunnerLink.tsx` and `lib/runner-routes.ts`.
124
+
125
+ The cost is one full page load when entering the runner section. The
126
+ alternative is granting `'unsafe-eval'` application-wide, which is the thing
127
+ this whole section exists to avoid.
128
+
129
+ One consequence worth planning for: every page a visitor can reach *from* a
130
+ runner route by soft navigation also needs the runner policy, or it needs its
131
+ own hard link back. Keeping the runner section contiguous (one prefix, as the
132
+ playground does with `/docs`) is simpler than scattering eval-enabled pages
133
+ through the app.
134
+
135
+ ## 4. Keep the rest of the policy strict
136
+
137
+ `'unsafe-eval'` sounds worse than it is. It gates **only** string-to-code APIs
138
+ (`eval`, `Function`, `setTimeout("…")`). It does **not** permit loading external
139
+ scripts, so `script-src 'self' 'unsafe-eval'` still blocks attacker-hosted code.
140
+
141
+ **`'unsafe-inline'` in `script-src` is the one weakening worth naming.** The
142
+ policy above carries it because Next injects inline bootstrap and streaming
143
+ scripts; without it a stock App Router page does not run. On a runner route it
144
+ costs you little extra, since `'unsafe-eval'` is already present and is the
145
+ stronger capability of the two. Removing it means adopting a nonce, which is
146
+ worth doing on your non-runner routes if you can accept the cost described
147
+ below, and which buys you nothing on the runner routes themselves.
148
+
149
+ Two further directives are worth particular attention:
150
+
151
+ - **`connect-src`** bounds a misbehaving snippet: it can read whatever the page
152
+ can, but it cannot send it anywhere you did not allow. Widen it per-host,
153
+ deliberately.
154
+ - **`object-src 'none'`** and **`base-uri 'self'`** close well-known bypasses and
155
+ cost nothing.
156
+
157
+ ### Two things that will not help
158
+
159
+ - **A nonce is not a substitute.** `'nonce-…'` and `'strict-dynamic'` authorize
160
+ script *elements*; `new Function` is governed solely by `'unsafe-eval'`.
161
+ - **A `blob:` URL is not safer.** It runs in the same origin with the same
162
+ powers, and `'strict-dynamic'` causes scheme allowlists like `blob:` to be
163
+ ignored anyway.
164
+
165
+ Also note that adopting a nonce-based CSP forces fully dynamic rendering and is
166
+ incompatible with PPR / `cacheComponents` - a real cost worth weighing.
167
+
168
+ If CSP does block evaluation, `next-live` detects it and reports what to change
169
+ instead of surfacing the browser's raw `EvalError`.
170
+
171
+ ## What is contained, and what is not
172
+
173
+ **Contained.** Runtime errors and render loops. `<LiveErrorBoundary>` keeps a
174
+ broken snippet from taking down the host app, and a render-rate breaker stops
175
+ runaway `setState` loops - the common real-world hang. React's own "Maximum
176
+ update depth" guard catches the synchronous case, but not an effect that updates
177
+ state on every commit; the breaker catches that one.
178
+
179
+ **Not contained.** A snippet runs with the page's full authority. A synchronous
180
+ `while (true)` - in module scope, in render, or in a handler - will hang the tab,
181
+ and no timer, `AbortController`, or `Promise.race` can interrupt it, because
182
+ JavaScript cannot interrupt synchronous code in its own realm.
183
+
184
+ The [module registry](./02-module-registry.md) does not change this. It bounds
185
+ what snippets can *conveniently* reach, not what they *can* reach, `window`,
186
+ `fetch`, and the DOM are always there.
187
+
188
+ ## If your authors stop being trusted
189
+
190
+ Same-realm evaluation is a stability aid for cooperative authors, not a security
191
+ boundary. If snippets ever come from a public marketplace, user-to-user sharing,
192
+ or tenants writing code that runs for other tenants' users, none of the above is
193
+ sufficient.
194
+
195
+ What you would need is the preview running in an iframe on a **separate origin**,
196
+ which cannot read your cookies or DOM. Know the cost before choosing it: props
197
+ would have to cross by structured clone, so you could no longer pass a store, a
198
+ library, a function, or any live object by reference - see
199
+ [Sharing libraries](./03-sharing-your-app-libraries.md). That is a different
200
+ product, and this library does not pretend to be it.
201
+
202
+ ---
203
+
204
+ [← Scaling](./04-scaling.md) · [Docs index](./README.md) · [API reference →](./06-api-reference.md)