@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/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
- mayImport: ['shared'],
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 = 'site-imports-app' | 'shared-is-a-leaf' | 'app-imports-api-at-runtime';
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
- if (
229
- i.importerSurface === 'shared' &&
230
- (i.importedSurface === 'app' || i.importedSurface === 'site') &&
231
- !i.typeOnly
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 null;
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. */