@ultimat3/render 20.2.1 → 22.0.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/CLAUDE.md +85 -88
- package/README.md +31 -9
- package/package.json +6 -6
- package/src/client-scope-tag.ts +49 -0
- package/src/client-sync-tags.ts +44 -0
- package/src/css-modules.ts +16 -3
- package/src/duration.ts +9 -8
- package/src/hydrate.ts +43 -12
- package/src/index.ts +21 -9
- package/src/modes.ts +11 -10
- package/src/module-loader.ts +3 -0
- package/src/registry.ts +12 -0
- package/src/render-isr-store.ts +93 -0
- package/src/render-isr.ts +24 -93
- package/src/render-static.ts +19 -22
- package/src/render-stream.ts +14 -14
- package/src/server.ts +2 -6
- package/src/static-path.ts +77 -0
- package/src/stream-scripts.ts +18 -0
- package/src/surfaces.ts +38 -24
package/src/server.ts
CHANGED
|
@@ -40,22 +40,18 @@ export { ROOT_ELEMENT_ID, renderComponent, renderToHtml } from './render-html';
|
|
|
40
40
|
export type {
|
|
41
41
|
IsrController,
|
|
42
42
|
IsrControllerOptions,
|
|
43
|
-
IsrEntry,
|
|
44
43
|
IsrRendered,
|
|
45
44
|
IsrRenderFn,
|
|
46
45
|
IsrServeResult,
|
|
47
|
-
IsrState,
|
|
48
|
-
IsrStore,
|
|
49
|
-
MemoryIsrStoreOptions,
|
|
50
46
|
} from './render-isr';
|
|
51
47
|
export {
|
|
52
48
|
createIsrController,
|
|
53
|
-
DEFAULT_ISR_MAX_ENTRIES,
|
|
54
49
|
ISR_LOCALE_PARAM,
|
|
55
50
|
invalidateAndRevalidate,
|
|
56
51
|
isrKey,
|
|
57
|
-
memoryIsrStore,
|
|
58
52
|
} from './render-isr';
|
|
53
|
+
export type { IsrEntry, IsrState, IsrStore, MemoryIsrStoreOptions } from './render-isr-store';
|
|
54
|
+
export { DEFAULT_ISR_MAX_ENTRIES, memoryIsrStore } from './render-isr-store';
|
|
59
55
|
export type { SsrOptions, SsrRenderFn, SsrRenderInput } from './render-ssr';
|
|
60
56
|
export { renderSsr, ssrHeaders } from './render-ssr';
|
|
61
57
|
export type { StaticArtifact, StaticBuildOptions, StaticRenderFn } from './render-static';
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Single responsibility: a route pattern plus `prerender()` params, as the segments a static build
|
|
2
|
+
// writes. `prerender()` returns APP data, and each value becomes a directory on disk: a raw `..`
|
|
3
|
+
// wrote outside the build output, and a missing param wrote a directory literally named `:slug`.
|
|
4
|
+
|
|
5
|
+
import { renderCauseValue } from '@ultimat3/core';
|
|
6
|
+
import { PrerenderFailedError } from './errors';
|
|
7
|
+
import type { RouteParams } from './route';
|
|
8
|
+
|
|
9
|
+
/** Anything a single path segment may not carry: separators, `?` and `#` (controls: `hasControl`). */
|
|
10
|
+
const UNSAFE = /[/\\?#]/;
|
|
11
|
+
|
|
12
|
+
/** NUL and every C0 control, plus DEL — by code point, so no control character sits in a regex. */
|
|
13
|
+
const hasControl = (text: string): boolean => {
|
|
14
|
+
for (const char of text) {
|
|
15
|
+
const code = char.codePointAt(0) ?? 0;
|
|
16
|
+
if (code < 0x20 || code === 0x7f) return true;
|
|
17
|
+
}
|
|
18
|
+
return false;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const refuse = (pattern: string, param: string, value: unknown, why: string): never => {
|
|
22
|
+
throw new PrerenderFailedError(
|
|
23
|
+
`prerender() gave ${pattern} the ${param} ${renderCauseValue(value)}, which ${why}`,
|
|
24
|
+
`return a value for every param of ${pattern} from prerender(), each one plain path text — slugify it (lowercase, [a-z0-9-]) where it comes from a title or a user`,
|
|
25
|
+
);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const checked = (pattern: string, param: string, segment: string): string => {
|
|
29
|
+
if (segment === '.' || segment === '..') {
|
|
30
|
+
return refuse(pattern, param, segment, 'is a dot segment — it would write outside its route');
|
|
31
|
+
}
|
|
32
|
+
if (UNSAFE.test(segment) || hasControl(segment)) {
|
|
33
|
+
return refuse(pattern, param, segment, 'carries a separator, a control character, ? or #');
|
|
34
|
+
}
|
|
35
|
+
return segment;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The raw (decoded) segments, validated. `:name` must be present and one segment; `*name` may span
|
|
40
|
+
* several (`a/b`) and may be absent, but none of its parts may be a dot segment or unsafe.
|
|
41
|
+
*/
|
|
42
|
+
export function filledSegments(pattern: string, params: RouteParams): readonly string[] {
|
|
43
|
+
const out: string[] = [];
|
|
44
|
+
for (const segment of pattern.split('/')) {
|
|
45
|
+
if (segment === '') continue;
|
|
46
|
+
if (segment.startsWith(':')) {
|
|
47
|
+
const name = segment.slice(1);
|
|
48
|
+
const value = Object.hasOwn(params, name) ? params[name] : undefined;
|
|
49
|
+
if (value === undefined || value === '') {
|
|
50
|
+
return refuse(
|
|
51
|
+
pattern,
|
|
52
|
+
name,
|
|
53
|
+
value,
|
|
54
|
+
'is missing — the file would be named after the pattern',
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
out.push(checked(pattern, name, value));
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (segment.startsWith('*')) {
|
|
61
|
+
const name = segment.slice(1);
|
|
62
|
+
const value = Object.hasOwn(params, name) ? (params[name] ?? '') : '';
|
|
63
|
+
for (const part of value.split('/')) if (part !== '') out.push(checked(pattern, name, part));
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
out.push(segment);
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The URL form: each segment percent-encoded, as a browser's `pathname` spells it. */
|
|
72
|
+
export const urlPathOf = (segments: readonly string[]): string =>
|
|
73
|
+
segments.length === 0 ? '/' : `/${segments.map(encodeURIComponent).join('/')}`;
|
|
74
|
+
|
|
75
|
+
/** The file form: the DECODED segments, which is what a static server maps a URL back onto. */
|
|
76
|
+
export const filePathOf = (segments: readonly string[], indexFile: string): string =>
|
|
77
|
+
[...segments, indexFile].join('/');
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Single responsibility: the inline script bodies out-of-order streaming emits, as constants. Its own
|
|
2
|
+
// module so a CSP builder can import the list without importing the stream renderer and its logger.
|
|
3
|
+
|
|
4
|
+
export const REVEAL_BODY =
|
|
5
|
+
"window.$X=function(){document.querySelectorAll('template[data-x-hole]').forEach(function(t){" +
|
|
6
|
+
"var s=document.getElementById(t.getAttribute('data-x-hole'));if(s){s.replaceWith(t.content);t.remove()}})}";
|
|
7
|
+
|
|
8
|
+
/** The one call every reveal makes. Constant, so a hash-based CSP can admit it. */
|
|
9
|
+
export const REVEAL_CALL = '$X()';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Every inline script body a streamed document can carry — two, whatever the holes. A production
|
|
13
|
+
* policy admits inline script by HASH (a `render: 'stream'` response gets no nonce), and the reveal
|
|
14
|
+
* used to be one `$X("<id>")` per hole: a body per id that no policy could list, so every reveal
|
|
15
|
+
* was blocked. The id now rides only in the escaped `data-x-hole` attribute, and `$X()` reveals
|
|
16
|
+
* every template that has arrived.
|
|
17
|
+
*/
|
|
18
|
+
export const STREAM_REVEAL_BODIES: readonly string[] = Object.freeze([REVEAL_BODY, REVEAL_CALL]);
|
package/src/surfaces.ts
CHANGED
|
@@ -47,7 +47,10 @@ export const SURFACE_SPECS = Object.freeze<Record<Surface, SurfaceSpec>>({
|
|
|
47
47
|
defaultMode: null,
|
|
48
48
|
allowedModes: [],
|
|
49
49
|
jsBaselineBytes: 0,
|
|
50
|
-
|
|
50
|
+
// `app` too, measured the day this table became the rule: both tracked apps' `api/index.ts`
|
|
51
|
+
// and `api/tasks.ts` import the app slices' actions and jobs to register them. Both surfaces
|
|
52
|
+
// are server-only, so no browser bundle pays for the edge; `site` stays out.
|
|
53
|
+
mayImport: ['shared', 'app'],
|
|
51
54
|
mayImportTypes: ['shared'],
|
|
52
55
|
},
|
|
53
56
|
shared: {
|
|
@@ -118,7 +121,11 @@ export function importGraph(
|
|
|
118
121
|
return graph;
|
|
119
122
|
}
|
|
120
123
|
|
|
121
|
-
export type BoundaryRule =
|
|
124
|
+
export type BoundaryRule =
|
|
125
|
+
| 'site-imports-app'
|
|
126
|
+
| 'shared-is-a-leaf'
|
|
127
|
+
| 'app-imports-api-at-runtime'
|
|
128
|
+
| 'surface-imports-surface';
|
|
122
129
|
|
|
123
130
|
export interface BoundaryViolation {
|
|
124
131
|
readonly rule: BoundaryRule;
|
|
@@ -210,50 +217,57 @@ interface ClassifyInput {
|
|
|
210
217
|
readonly chain: readonly string[];
|
|
211
218
|
}
|
|
212
219
|
|
|
220
|
+
/**
|
|
221
|
+
* One edge against `SURFACE_SPECS` — the table IS the rule. `mayImport` and `mayImportTypes` were
|
|
222
|
+
* read by nothing, so an `api → site`, `site → api` or `app → site` value import classified as no
|
|
223
|
+
* violation at all. The three pairs that had rules of their own keep them (and their codes); every
|
|
224
|
+
* other crossing the table does not allow is `surface-imports-surface`.
|
|
225
|
+
*/
|
|
213
226
|
function classify(i: ClassifyInput): BoundaryViolation | null {
|
|
214
227
|
const chainText = i.chain.join(' → ');
|
|
228
|
+
const base = { entry: i.entry, importer: i.importer, imported: i.imported, chain: i.chain };
|
|
215
229
|
|
|
230
|
+
// Transitive, from the ENTRY: a site page reaching app/ through any number of hops.
|
|
216
231
|
if (i.entrySurface === 'site' && i.importedSurface === 'app' && !i.typeOnly) {
|
|
217
232
|
return {
|
|
233
|
+
...base,
|
|
218
234
|
rule: 'site-imports-app',
|
|
219
|
-
entry: i.entry,
|
|
220
|
-
importer: i.importer,
|
|
221
|
-
imported: i.imported,
|
|
222
|
-
chain: i.chain,
|
|
223
235
|
cause: chainText,
|
|
224
236
|
fix: `x fix boundary ${i.entry} (or move ${i.imported} out of the shared graph)`,
|
|
225
237
|
};
|
|
226
238
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
)
|
|
239
|
+
const from = i.importerSurface;
|
|
240
|
+
const to = i.importedSurface;
|
|
241
|
+
if (from === null || to === null || from === to) return null;
|
|
242
|
+
const spec = SURFACE_SPECS[from];
|
|
243
|
+
if (spec.mayImport.includes(to)) return null;
|
|
244
|
+
if (i.typeOnly && spec.mayImportTypes.includes(to)) return null;
|
|
245
|
+
// Reported above, from the site entry that reaches it — one crossing, one finding.
|
|
246
|
+
if (from === 'site' && to === 'app' && !i.typeOnly) return null;
|
|
247
|
+
|
|
248
|
+
if (from === 'shared' && !i.typeOnly) {
|
|
233
249
|
return {
|
|
250
|
+
...base,
|
|
234
251
|
rule: 'shared-is-a-leaf',
|
|
235
|
-
entry: i.entry,
|
|
236
|
-
importer: i.importer,
|
|
237
|
-
imported: i.imported,
|
|
238
|
-
chain: i.chain,
|
|
239
252
|
cause: `${chainText} (shared/ is a leaf — it may not import a surface)`,
|
|
240
253
|
fix: `move the shared part of ${i.imported} into shared/ and import it from ${i.importer}`,
|
|
241
254
|
};
|
|
242
255
|
}
|
|
243
|
-
|
|
244
|
-
if (i.importerSurface === 'app' && i.importedSurface === 'api' && !i.typeOnly) {
|
|
256
|
+
if (from === 'app' && to === 'api' && !i.typeOnly) {
|
|
245
257
|
return {
|
|
258
|
+
...base,
|
|
246
259
|
rule: 'app-imports-api-at-runtime',
|
|
247
|
-
entry: i.entry,
|
|
248
|
-
importer: i.importer,
|
|
249
|
-
imported: i.imported,
|
|
250
|
-
chain: i.chain,
|
|
251
260
|
cause: `${chainText} (app/ → api/ is types-only)`,
|
|
252
261
|
fix: `change to \`import type\` in ${i.importer} and call the typed client instead`,
|
|
253
262
|
};
|
|
254
263
|
}
|
|
255
|
-
|
|
256
|
-
return
|
|
264
|
+
const field = i.typeOnly ? 'mayImportTypes' : 'mayImport';
|
|
265
|
+
return {
|
|
266
|
+
...base,
|
|
267
|
+
rule: 'surface-imports-surface',
|
|
268
|
+
cause: `${chainText} (${from}/ may not import ${to}/${i.typeOnly ? ', even as a type' : ''}: SURFACE_SPECS.${from}.${field} is [${spec[field].join(', ')}])`,
|
|
269
|
+
fix: `move what ${i.importer} needs from ${i.imported} into shared/ and import it from there`,
|
|
270
|
+
};
|
|
257
271
|
}
|
|
258
272
|
|
|
259
273
|
/** Build-time gate. `x verify` and the dev server both call this. */
|