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,487 @@
|
|
|
1
|
+
# The module registry
|
|
2
|
+
|
|
3
|
+
[← Getting started](./01-getting-started.md) · [Docs index](./README.md) · [Sharing libraries →](./03-sharing-your-app-libraries.md)
|
|
4
|
+
|
|
5
|
+
This is the core concept. Everything else follows from it.
|
|
6
|
+
|
|
7
|
+
If you only remember one thing: **live snippets cannot import anything you did
|
|
8
|
+
not register.** You choose what they can reach, and you give each thing a name.
|
|
9
|
+
|
|
10
|
+
## The mental model
|
|
11
|
+
|
|
12
|
+
**There is no npm in the browser.** No bundler, no package resolution, no
|
|
13
|
+
network fetch for `lodash`. Your application's JavaScript contains only what
|
|
14
|
+
*you* imported when it was built.
|
|
15
|
+
|
|
16
|
+
In normal app code:
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
import { Button } from '@/components/ui/button';
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Your bundler resolves that path at build time and ships the file. A live snippet
|
|
23
|
+
is a **string** that was not part of the build. When it says:
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
import _ from 'lodash';
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
that does not install or fetch lodash. It means "look up `lodash` in the
|
|
30
|
+
registry I was given."
|
|
31
|
+
|
|
32
|
+
You hand real code over through the `modules` prop:
|
|
33
|
+
|
|
34
|
+
```tsx
|
|
35
|
+
<LiveProvider
|
|
36
|
+
code={source}
|
|
37
|
+
modules={{
|
|
38
|
+
'@app/store': defineLoader(() => import('@/lib/store')),
|
|
39
|
+
}}
|
|
40
|
+
/>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`next-live` compiles `import x from 'y'` into a lookup against that map. If the
|
|
44
|
+
specifier is not registered, the snippet fails with a clear error naming it.
|
|
45
|
+
|
|
46
|
+
Think of `modules` as a **phone book**:
|
|
47
|
+
|
|
48
|
+
| Snippet writes | Registry key | What loads |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| `import { Button } from '@app/ui'` | `'@app/ui'` | Your UI module |
|
|
51
|
+
| `import { useCart } from '@app/store'` | `'@app/store'` | Your store |
|
|
52
|
+
| `import _ from 'lodash'` | *(missing)* | Error |
|
|
53
|
+
|
|
54
|
+
The snippet only knows the label. You decide which real code it points to.
|
|
55
|
+
|
|
56
|
+
## Walkthrough: from string to component
|
|
57
|
+
|
|
58
|
+
**1. Host setup**
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
<LiveProvider
|
|
62
|
+
code={source}
|
|
63
|
+
modules={{
|
|
64
|
+
'@app/format': defineLoader(() => import('@/lib/format')),
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
<LivePreview />
|
|
68
|
+
</LiveProvider>
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**2. Snippet source**
|
|
72
|
+
|
|
73
|
+
```tsx
|
|
74
|
+
import { formatMoney } from '@app/format';
|
|
75
|
+
|
|
76
|
+
export default function Price() {
|
|
77
|
+
return <p>{formatMoney(99)}</p>;
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**3. Compile** - the import becomes a registry lookup for `'@app/format'`.
|
|
82
|
+
|
|
83
|
+
**4. Load** - the loader runs `import('@/lib/format')` (lazy, code-split).
|
|
84
|
+
|
|
85
|
+
**5. Render** - `formatMoney` from your real file is passed to the snippet.
|
|
86
|
+
|
|
87
|
+
## `scope` vs `modules`
|
|
88
|
+
|
|
89
|
+
The `scope` prop injects globals; snippets cannot use `import`:
|
|
90
|
+
|
|
91
|
+
```tsx
|
|
92
|
+
<LiveProvider scope={{ useState, Button, formatMoney }} />
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```tsx
|
|
96
|
+
export default () => <Button>{formatMoney(42)}</Button>;
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
**next-live** uses real imports against a registry:
|
|
100
|
+
|
|
101
|
+
```tsx
|
|
102
|
+
modules={{
|
|
103
|
+
'@app/ui': defineLoader(() => import('@/components/ui')),
|
|
104
|
+
'@app/format': defineLoader(() => import('@/lib/format')),
|
|
105
|
+
}}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
```tsx
|
|
109
|
+
import { Button } from '@app/ui';
|
|
110
|
+
import { formatMoney } from '@app/format';
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`react` is built in. Everything else you register. Prefer `modules` over `scope`
|
|
114
|
+
for new code.
|
|
115
|
+
|
|
116
|
+
## The key is just a string
|
|
117
|
+
|
|
118
|
+
A registry key does **not** have to be a real path or a real package name. It is
|
|
119
|
+
whatever you want snippet authors to type. These could all point at the same
|
|
120
|
+
file:
|
|
121
|
+
|
|
122
|
+
```tsx
|
|
123
|
+
modules={{
|
|
124
|
+
'@app/store': defineLoader(() => import('@/lib/store')),
|
|
125
|
+
'@/store': defineLoader(() => import('@/lib/store')),
|
|
126
|
+
'my-store': defineLoader(() => import('@/lib/store')),
|
|
127
|
+
}}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Pick names that read like a deliberate SDK, not a mirror of your folder tree.
|
|
131
|
+
See [Scaling](./04-scaling.md#design-an-sdk-surface-not-a-mirror-of-your-codebase).
|
|
132
|
+
|
|
133
|
+
## Common mistakes
|
|
134
|
+
|
|
135
|
+
**Typo in a named export** - the module loads but the export is missing. Fix
|
|
136
|
+
the import or re-export from your SDK module.
|
|
137
|
+
|
|
138
|
+
**Two paths to the same library** - registering a different import path than
|
|
139
|
+
your app uses creates two instances (two stores, two React copies). Keep one
|
|
140
|
+
canonical path. See [Sharing libraries](./03-sharing-your-app-libraries.md).
|
|
141
|
+
|
|
142
|
+
**Expecting npm packages to work automatically** - register them explicitly:
|
|
143
|
+
|
|
144
|
+
```tsx
|
|
145
|
+
'date-fns': defineLoader(() => import('date-fns')),
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
**Unused typo imports** - stripped at compile time like TypeScript. Only imports
|
|
149
|
+
that survive compilation are resolved.
|
|
150
|
+
|
|
151
|
+
## Two ways to register
|
|
152
|
+
|
|
153
|
+
### As a loader: the default choice
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
'@app/store': defineLoader(() => import('@/lib/store'))
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
A dynamic import, so the bundler code-splits it and the browser fetches it only
|
|
160
|
+
if a snippet actually imports that specifier.
|
|
161
|
+
|
|
162
|
+
### As a value
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
import * as store from '@/lib/store';
|
|
166
|
+
modules={{ '@app/store': store }}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Simpler to read, but the module is now in your page bundle for every visitor,
|
|
170
|
+
used or not. Fine for something tiny; wrong for anything large.
|
|
171
|
+
|
|
172
|
+
**Use loaders.** A registry of 300 loaders costs nothing at runtime - only
|
|
173
|
+
specifiers that appear in the compiled snippet are ever resolved. See
|
|
174
|
+
[Scaling](./04-scaling.md) for the measured difference.
|
|
175
|
+
|
|
176
|
+
## Organizing your registry in separate files
|
|
177
|
+
|
|
178
|
+
You do not need a giant `modules={{ … }}` on `<LiveProvider>`. Put the full list
|
|
179
|
+
in a dedicated SDK folder and import one object:
|
|
180
|
+
|
|
181
|
+
```tsx
|
|
182
|
+
import { liveModules } from '@/lib/live-sdk';
|
|
183
|
+
|
|
184
|
+
<LiveProvider code={source} modules={liveModules}>
|
|
185
|
+
<LivePreview />
|
|
186
|
+
</LiveProvider>
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
**100 registered loaders does not mean 100 network requests.** Each entry is a
|
|
190
|
+
small loader function. next-live scans the compiled snippet and only runs
|
|
191
|
+
loaders for specifiers the snippet actually imports.
|
|
192
|
+
|
|
193
|
+
### Recommended layout
|
|
194
|
+
|
|
195
|
+
```
|
|
196
|
+
lib/live-sdk/
|
|
197
|
+
index.ts # export liveModules
|
|
198
|
+
ui-modules.ts # manual group
|
|
199
|
+
format-modules.ts
|
|
200
|
+
store-modules.ts
|
|
201
|
+
vendor.ts # prefix / npm loaders
|
|
202
|
+
app-modules-glob.ts # optional auto-register
|
|
203
|
+
modules/
|
|
204
|
+
ui.ts # re-exports for snippets
|
|
205
|
+
format.ts
|
|
206
|
+
store.ts
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### Option A: manual groups + `createRegistry`
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
// lib/live-sdk/format-modules.ts
|
|
213
|
+
export const formatModules = {
|
|
214
|
+
'@app/format': defineLoader(() => import('./modules/format')),
|
|
215
|
+
'@app/x': defineLoader(() => import('@/lib/x')),
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// lib/live-sdk/index.ts
|
|
219
|
+
export const liveModules = createRegistry(
|
|
220
|
+
vendorModules,
|
|
221
|
+
uiModules,
|
|
222
|
+
formatModules,
|
|
223
|
+
storeModules,
|
|
224
|
+
);
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### Option B: `registryFromGlob`
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
export const appModulesFromGlob = registryFromGlob(
|
|
231
|
+
import.meta.glob('./modules/*.ts'),
|
|
232
|
+
(path) => `@app/${path.split('/').pop()!.replace(/\.tsx?$/, '')}`,
|
|
233
|
+
);
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Add a file under `modules/` and it is registered automatically.
|
|
237
|
+
|
|
238
|
+
### Option C: hybrid
|
|
239
|
+
|
|
240
|
+
Manual groups for special cases (prefix loaders, npm packages) plus glob for
|
|
241
|
+
the `./modules/` surface:
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
export const liveModules = createRegistry(vendorModules, appModulesFromGlob);
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
The playground uses Option A for `@app/ui`, `@app/format`, and `@app/store`,
|
|
248
|
+
with `app-modules-glob.ts` kept as a ready-made Option B example.
|
|
249
|
+
|
|
250
|
+
## Every import form works
|
|
251
|
+
|
|
252
|
+
Given `modules={{ '@app/ui': uiModule }}`:
|
|
253
|
+
|
|
254
|
+
| Snippet writes | Gets |
|
|
255
|
+
|---|---|
|
|
256
|
+
| `import ui from '@app/ui'` | the module's `default`, or the module itself if it has none |
|
|
257
|
+
| `import { Button } from '@app/ui'` | the named export |
|
|
258
|
+
| `import * as ui from '@app/ui'` | the namespace |
|
|
259
|
+
| `import '@app/ui'` | nothing; runs for side effects |
|
|
260
|
+
|
|
261
|
+
Registered values are treated as **CommonJS exports objects**: a value with its
|
|
262
|
+
own `default` key is unwrapped, and anything else *is* the default. That makes
|
|
263
|
+
the common shapes work without configuration:
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
'@app/config': { apiUrl: 'https://…' }
|
|
267
|
+
// import config from '@app/config' → the object
|
|
268
|
+
// import { apiUrl } from '@app/config' → the string
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### The one ambiguous case
|
|
272
|
+
|
|
273
|
+
Because a value with its own `default` key is unwrapped, an object that
|
|
274
|
+
genuinely contains the word `default` gets unwrapped too:
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
'@app/theme': { default: 'dark', light: '#fff' }
|
|
278
|
+
// import theme from '@app/theme' → 'dark', not the object
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
When that is not what you meant, say so explicitly with `defineModule`:
|
|
282
|
+
|
|
283
|
+
```ts
|
|
284
|
+
import { defineModule } from 'next-live';
|
|
285
|
+
|
|
286
|
+
modules={{
|
|
287
|
+
'@app/theme': defineModule({ default: { default: 'dark', light: '#fff' } }),
|
|
288
|
+
}}
|
|
289
|
+
// import theme from '@app/theme' → the whole object
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
`default` and `exports.default` address the same slot - ESM makes no distinction
|
|
293
|
+
between a default export and a named export called `default`.
|
|
294
|
+
|
|
295
|
+
## Always available
|
|
296
|
+
|
|
297
|
+
Registered for you, no configuration needed:
|
|
298
|
+
|
|
299
|
+
- `react`
|
|
300
|
+
- `react/jsx-runtime`
|
|
301
|
+
- `react/jsx-dev-runtime`
|
|
302
|
+
|
|
303
|
+
The two JSX runtimes are not optional, every JSX tag compiles to a call into
|
|
304
|
+
one of them, so without them no snippet would render at all. They come from
|
|
305
|
+
**your** React, which is what lets hooks and context work across the boundary.
|
|
306
|
+
|
|
307
|
+
Your entries merge over these, so you can substitute a React shim if you need to.
|
|
308
|
+
|
|
309
|
+
`react-dom` is deliberately not included; register it explicitly if snippets
|
|
310
|
+
need `createPortal`.
|
|
311
|
+
|
|
312
|
+
## Prefix entries: one key for a whole subtree
|
|
313
|
+
|
|
314
|
+
A key ending in `/` claims everything beneath it, and its loader receives the
|
|
315
|
+
**full specifier**:
|
|
316
|
+
|
|
317
|
+
```ts
|
|
318
|
+
'big-lib/': defineLoader((specifier) => {
|
|
319
|
+
const subpath = specifier.slice('big-lib/'.length);
|
|
320
|
+
return import(`big-lib/${subpath}`);
|
|
321
|
+
}),
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
Now `big-lib/charts/BarChart` and `big-lib/format/currency` both resolve without
|
|
325
|
+
enumerating them. There is a build-time cost - see
|
|
326
|
+
[Scaling](./04-scaling.md#deep-subpaths).
|
|
327
|
+
|
|
328
|
+
## Subpath fallback
|
|
329
|
+
|
|
330
|
+
`resolveSubpaths` lets `pkg/Sub` resolve against a registered `pkg` by reading
|
|
331
|
+
`Sub` as a property:
|
|
332
|
+
|
|
333
|
+
```tsx
|
|
334
|
+
<LiveProvider resolveSubpaths modules={{ 'big-lib': bigLib }} />
|
|
335
|
+
// 'big-lib/Chart' → bigLib.Chart
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
**Off by default, deliberately.** It is right for barrel-shaped packages and
|
|
339
|
+
wrong for packages whose subpaths are not re-exported from the barrel - and a
|
|
340
|
+
silently wrong value is worse than a clear error.
|
|
341
|
+
|
|
342
|
+
## Generating entries from the filesystem
|
|
343
|
+
|
|
344
|
+
### Turbopack: `import.meta.glob`
|
|
345
|
+
|
|
346
|
+
```ts
|
|
347
|
+
import { registryFromGlob } from 'next-live';
|
|
348
|
+
|
|
349
|
+
export const storeModules = registryFromGlob(
|
|
350
|
+
import.meta.glob('./modules/*.ts'),
|
|
351
|
+
(path) => {
|
|
352
|
+
const name = path.split('/').pop()?.replace(/\.tsx?$/, '');
|
|
353
|
+
return name ? `@app/${name}` : null;
|
|
354
|
+
},
|
|
355
|
+
);
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
### Webpack: `require.context`
|
|
359
|
+
|
|
360
|
+
`registryFromGlob` accepts any `Record<string, () => Promise<unknown>>`. Under
|
|
361
|
+
webpack, build that object from `require.context`:
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
import { registryFromGlob } from 'next-live';
|
|
365
|
+
|
|
366
|
+
const context = require.context('./modules', false, /\.tsx?$/);
|
|
367
|
+
|
|
368
|
+
export const storeModules = registryFromGlob(
|
|
369
|
+
Object.fromEntries(
|
|
370
|
+
context.keys().map((key) => [
|
|
371
|
+
key,
|
|
372
|
+
() => Promise.resolve(context(key)),
|
|
373
|
+
]),
|
|
374
|
+
),
|
|
375
|
+
(path) => {
|
|
376
|
+
const name = path.replace(/^\.\//, '').replace(/\.tsx?$/, '');
|
|
377
|
+
return `@app/${name}`;
|
|
378
|
+
},
|
|
379
|
+
);
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
See [Scaling](./04-scaling.md#generate-entries-from-the-filesystem) for the
|
|
383
|
+
directory rule that silently breaks globs.
|
|
384
|
+
|
|
385
|
+
## Styling and Tailwind
|
|
386
|
+
|
|
387
|
+
Snippets can use `className`, but Tailwind only generates CSS for classes it
|
|
388
|
+
finds in your **host** source at build time. Arbitrary utility strings inside
|
|
389
|
+
stored snippet source will not produce styles unless you safelist them or add
|
|
390
|
+
a `@source` scan target.
|
|
391
|
+
|
|
392
|
+
The reliable pattern is to register UI components whose classes are already
|
|
393
|
+
compiled:
|
|
394
|
+
|
|
395
|
+
```ts
|
|
396
|
+
// lib/live-sdk/modules/ui.ts
|
|
397
|
+
export { Button, Card, Badge } from '@/components/ui';
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
```tsx
|
|
401
|
+
import { Card, Button } from '@app/ui';
|
|
402
|
+
|
|
403
|
+
export default function App() {
|
|
404
|
+
return (
|
|
405
|
+
<Card>
|
|
406
|
+
<Button>Styled by the host bundle</Button>
|
|
407
|
+
</Card>
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
See [Troubleshooting: Tailwind in snippets](./07-troubleshooting.md#tailwind-classes-in-my-snippet-do-nothing).
|
|
413
|
+
|
|
414
|
+
## Asset imports are ignored
|
|
415
|
+
|
|
416
|
+
`import './styles.css'` resolves to an empty module rather than failing, so a
|
|
417
|
+
snippet pasted out of a real file still runs. Applies to `.css`, `.scss`,
|
|
418
|
+
`.svg`, images, and fonts.
|
|
419
|
+
|
|
420
|
+
## Free variables: the `scope` prop
|
|
421
|
+
|
|
422
|
+
For legacy snippets, `scope` injects values as bare identifiers with
|
|
423
|
+
no import at all:
|
|
424
|
+
|
|
425
|
+
```tsx
|
|
426
|
+
<LiveProvider scope={{ formatMoney, t }} />
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
```tsx
|
|
430
|
+
export default () => <b>{formatMoney(42)}</b>; // no import needed
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
Both mechanisms work together. Prefer `modules` - an explicit import says where
|
|
434
|
+
something came from, and snippets stay closer to real files. Keys that are not
|
|
435
|
+
valid identifiers, or that collide with the injected names (`module`, `exports`,
|
|
436
|
+
`require`, `React`, `render`), are skipped with a console warning.
|
|
437
|
+
|
|
438
|
+
## Runtime props
|
|
439
|
+
|
|
440
|
+
`props` are handed to the component **by reference**, not serialized:
|
|
441
|
+
|
|
442
|
+
```tsx
|
|
443
|
+
<LivePreview props={{ panel, user, store }} />
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
```tsx
|
|
447
|
+
export default function App({ panel, user }) {
|
|
448
|
+
return <button onClick={() => panel.setSize(17)}>{user.name}</button>;
|
|
449
|
+
}
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
Because nothing is cloned, a snippet calling `panel.setSize(17)` mutates the same
|
|
453
|
+
object your app holds and your UI updates. Class instances, functions, and live
|
|
454
|
+
handles all survive. This is only possible because snippets run in your page -
|
|
455
|
+
an iframe sandbox could not do it.
|
|
456
|
+
|
|
457
|
+
`props` on `<LivePreview>` merge over `props` on `<LiveProvider>`.
|
|
458
|
+
|
|
459
|
+
## When a module is missing
|
|
460
|
+
|
|
461
|
+
```
|
|
462
|
+
Module '@ui/coree' is not registered in the next-live scope.
|
|
463
|
+
|
|
464
|
+
Did you mean '@ui/core'?
|
|
465
|
+
|
|
466
|
+
Registered modules (7): react, react/jsx-runtime, react/jsx-dev-runtime,
|
|
467
|
+
'@app/store', '@app/ui', 'big-lib/', 'date-fns'
|
|
468
|
+
|
|
469
|
+
next-live does not bundle npm packages - pass them in explicitly:
|
|
470
|
+
<LiveProvider modules={{ '@ui/coree': theModule }} />
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
One thing to know: an import a snippet never *uses* is removed by the TypeScript
|
|
474
|
+
transform before resolution runs, exactly as `tsc` would. So an unused typo does
|
|
475
|
+
not error - it simply disappears.
|
|
476
|
+
|
|
477
|
+
## What the registry is not
|
|
478
|
+
|
|
479
|
+
It bounds what snippets can **conveniently** reach, not what they **can** reach.
|
|
480
|
+
Evaluated code still has `window`, `fetch`, `document`, and your cookies.
|
|
481
|
+
|
|
482
|
+
Treat the registry as module resolution and ergonomics. It is not a security
|
|
483
|
+
boundary, see [Security](./05-security.md).
|
|
484
|
+
|
|
485
|
+
---
|
|
486
|
+
|
|
487
|
+
[← Getting started](./01-getting-started.md) · [Docs index](./README.md) · [Sharing libraries →](./03-sharing-your-app-libraries.md)
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# Sharing libraries between your app and your snippets
|
|
2
|
+
|
|
3
|
+
[← Module registry](./02-module-registry.md) · [Docs index](./README.md) · [Scaling →](./04-scaling.md)
|
|
4
|
+
|
|
5
|
+
## The question
|
|
6
|
+
|
|
7
|
+
Your app already uses a charting library and a store in its own components:
|
|
8
|
+
|
|
9
|
+
```tsx
|
|
10
|
+
// app/dashboard/page.tsx, your normal application code
|
|
11
|
+
import { BarChart } from 'my-charts';
|
|
12
|
+
import { useAppStore } from '@/store';
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
And you register the same things so snippets can use them:
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
modules={{
|
|
19
|
+
'my-charts': defineLoader(() => import('my-charts')),
|
|
20
|
+
'@app/store': defineLoader(() => import('@/store')),
|
|
21
|
+
}}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
That looks like importing the same library twice. Is it?
|
|
25
|
+
|
|
26
|
+
## The answer: one instance, not two
|
|
27
|
+
|
|
28
|
+
**No, there is exactly one copy.** JavaScript modules are instantiated once per
|
|
29
|
+
resolved specifier. Your static `import` and the registry's dynamic `import()`
|
|
30
|
+
resolve to the same module, so the bundler hands both the same object. This is
|
|
31
|
+
the same reason `import React from 'react'` in fifty files gives you one React.
|
|
32
|
+
|
|
33
|
+
This is verified in the playground, not assumed. The host page imports a module
|
|
34
|
+
and stamps a marker on it:
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
import HostWidget from '@demo/vendor/Widget';
|
|
38
|
+
(HostWidget as Record<string, unknown>).__owner = 'host-app';
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
A snippet imports the *same specifier* through the registry and reads it back:
|
|
42
|
+
|
|
43
|
+
```tsx
|
|
44
|
+
import Widget from '@demo/vendor/Widget';
|
|
45
|
+
export default () => <b>Widget.__owner = {String(Widget.__owner)}</b>;
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
It renders `Widget.__owner = host-app`. One object. Run the **Shared instance** demo in `/playground` to see it. The **`/apps`**
|
|
49
|
+
shell demo shows the same pattern with `@app/store` and `@app/format` - the
|
|
50
|
+
header cart count updates when a snippet calls `addItem` through the registry.
|
|
51
|
+
|
|
52
|
+
## Why this matters much more than bundle size
|
|
53
|
+
|
|
54
|
+
The obvious worry is downloading a library twice. That is real but minor. The
|
|
55
|
+
serious consequence is **state**.
|
|
56
|
+
|
|
57
|
+
If a snippet got its own copy of your store module, it would get its own
|
|
58
|
+
*store*. Your app's cart and the snippet's cart would be two unrelated objects. The snippet
|
|
59
|
+
would appear to work - no error, no warning, while silently sharing nothing.
|
|
60
|
+
Every bug report would be "my changes don't show up".
|
|
61
|
+
|
|
62
|
+
Because there is one instance, the store a snippet imports **is** your store:
|
|
63
|
+
|
|
64
|
+
```tsx
|
|
65
|
+
// snippet
|
|
66
|
+
import { useAppStore } from '@app/store';
|
|
67
|
+
|
|
68
|
+
export default function App() {
|
|
69
|
+
const cart = useAppStore((s) => s.cart); // your app's cart
|
|
70
|
+
const add = useAppStore((s) => s.addItem); // updates your app's UI too
|
|
71
|
+
return <button onClick={() => add('x')}>{cart.length} items</button>;
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The same applies to React itself, React context, and any live object you pass
|
|
76
|
+
through `props`.
|
|
77
|
+
|
|
78
|
+
## No second download either
|
|
79
|
+
|
|
80
|
+
When your app already imports a module statically, it is in a chunk the page has
|
|
81
|
+
already loaded. A snippet importing the same specifier gets the loaded module
|
|
82
|
+
back - the browser fetches nothing.
|
|
83
|
+
|
|
84
|
+
Measured in the playground: selecting the snippet that imports a
|
|
85
|
+
host-already-imported module fetched **zero** additional chunks.
|
|
86
|
+
|
|
87
|
+
The practical consequence is a nice one:
|
|
88
|
+
|
|
89
|
+
- If your app **already uses** the library, registering it as a loader costs
|
|
90
|
+
nothing extra - the loader just hands over what is already there.
|
|
91
|
+
- If your app **does not** use it, the loader keeps it out of your bundle until
|
|
92
|
+
a snippet asks for it.
|
|
93
|
+
|
|
94
|
+
Either way, registering as a loader is the right call. There is no case where
|
|
95
|
+
registering by value is better. See [Scaling](./04-scaling.md).
|
|
96
|
+
|
|
97
|
+
## When you really do get two copies
|
|
98
|
+
|
|
99
|
+
The reassuring answer has edges. These are the cases that bite:
|
|
100
|
+
|
|
101
|
+
### 1. Two versions installed
|
|
102
|
+
|
|
103
|
+
The most common cause. If your app depends on `zustand@4` and some other
|
|
104
|
+
dependency pulls `zustand@5`, npm may install both, and they are genuinely two
|
|
105
|
+
different modules with two different stores.
|
|
106
|
+
|
|
107
|
+
Check before you debug anything else:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
npm ls zustand
|
|
111
|
+
npm ls react
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
If you see more than one version, deduplicate:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
npm dedupe
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
or pin a single version with an `overrides` entry in your root `package.json`:
|
|
121
|
+
|
|
122
|
+
```json
|
|
123
|
+
{
|
|
124
|
+
"overrides": {
|
|
125
|
+
"zustand": "5.0.2"
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
**Symptom:** state that will not sync, or - for React, `Invalid hook call` and
|
|
131
|
+
"more than one copy of React".
|
|
132
|
+
|
|
133
|
+
### 2. Different specifiers are different modules
|
|
134
|
+
|
|
135
|
+
`zustand` and `zustand/vanilla` are two modules, even though both "are Zustand".
|
|
136
|
+
If your app imports one and your registry maps the other, snippets get the other
|
|
137
|
+
one's exports.
|
|
138
|
+
|
|
139
|
+
Register the specifier your app actually uses.
|
|
140
|
+
|
|
141
|
+
### 3. You registered a copy, not the module
|
|
142
|
+
|
|
143
|
+
Spreading a module into a new object breaks live bindings and, for a store,
|
|
144
|
+
hands over a snapshot rather than the store:
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
'@app/store': { ...storeModule } // ❌ a copy
|
|
148
|
+
'@app/store': defineLoader(() => import('@/store')) // ✅ the module
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### 4. Server and client are separate instances
|
|
152
|
+
|
|
153
|
+
Node and the browser instantiate modules separately. This never affects
|
|
154
|
+
snippets, because `next-live` only evaluates on the client - but it is worth
|
|
155
|
+
knowing if you keep module-level state and expect it to survive SSR.
|
|
156
|
+
|
|
157
|
+
## The recommended pattern
|
|
158
|
+
|
|
159
|
+
Do not register third-party packages directly. Register a **thin re-export
|
|
160
|
+
module that you own**:
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
// lib/live-sdk/modules/store.ts
|
|
164
|
+
export { useAppStore, addItem, clearCart } from '@/store';
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
// lib/live-sdk/modules/charts.ts
|
|
169
|
+
export { BarChart, LineChart } from 'my-charts';
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Four reasons this is better than pointing at the package:
|
|
173
|
+
|
|
174
|
+
1. **One path to the thing.** There is no way to accidentally register a
|
|
175
|
+
different specifier than your app uses.
|
|
176
|
+
2. **You control the surface.** Snippet authors get the handful of exports you
|
|
177
|
+
support, not every module in the package.
|
|
178
|
+
3. **You can refactor.** Move or rename the real implementation and update one
|
|
179
|
+
re-export; every stored snippet keeps working.
|
|
180
|
+
4. **It reads as an API.** `@app/charts` is a contract. A deep path into a
|
|
181
|
+
third-party package is an implementation detail leaking into your users' code.
|
|
182
|
+
|
|
183
|
+
## How to check this in your own app
|
|
184
|
+
|
|
185
|
+
Drop this snippet into your control panel once, as a smoke test:
|
|
186
|
+
|
|
187
|
+
```tsx
|
|
188
|
+
import { useAppStore } from '@app/store';
|
|
189
|
+
|
|
190
|
+
export default function InstanceCheck() {
|
|
191
|
+
const state = useAppStore((s) => s);
|
|
192
|
+
return (
|
|
193
|
+
<pre>
|
|
194
|
+
store keys: {Object.keys(state).join(', ')}
|
|
195
|
+
{'\n'}same instance as host: change something in the app UI and watch this update
|
|
196
|
+
</pre>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
If mutating state from your app's own UI updates this snippet live, you have one
|
|
202
|
+
instance and everything above holds.
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
[← Module registry](./02-module-registry.md) · [Docs index](./README.md) · [Scaling →](./04-scaling.md)
|