@ultimat3/render 26.1.1 → 27.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/CLAUDE.md CHANGED
@@ -4,7 +4,7 @@ Owns: the `route` primitive, the four render modes, the route table, the surface
4
4
  islands + budgets, hydration directives, `<head>` merge, **the server JSX runtime and the two Bun
5
5
  loaders that make an app's `.tsx` and `.scss` runnable**.
6
6
 
7
- **Two entry points, disjoint.** `"."` (`index.ts`) is the CLIENT half and bundles for the browser;
7
+ **Three entry points**; `"."` and `"./server"` disjoint. `"."` (`index.ts`) is the CLIENT half and bundles for the browser;
8
8
  `"./server"` (`server.ts`) is the build-time half — `css-modules`, `module-loader`, `render-html`,
9
9
  `render-isr`, `render-ssr`, `render-static`, `render-stream` — and does not (`css-modules.ts` needs
10
10
  `node:url`, which a browser build cannot link; no `sideEffects` value repairs that). Never re-export
@@ -91,7 +91,7 @@ axiom 6). Never `cli` (upward).
91
91
  | An undecodable path segment | not a match, never a throw — `decodeSegment` in `registry.ts`. |
92
92
  | ISR registration | reconciled against `store.paths()` after every generation (`forgetEvictedPaths`). |
93
93
  | Stream hole deadline | `DEFAULT_HOLE_TIMEOUT_MS` (15 s), `holeTimeoutMs: null` to opt out, otherwise a whole number ≥ 1. A hole reveals exactly once — promise, rejection or deadline. |
94
- | Errors | `errors.ts` subclasses only. Never a bare `Error`, never a bare `TODO`. |
94
+ | Errors | `errors.ts` subclasses only, but for `navigation-errors.ts` (re-exported; keeps `./client` off the table). Never a bare `Error`, never a bare `TODO`. |
95
95
  | Policy | render checks *presence* only. Evaluation belongs to `@ultimat3/policy`. |
96
96
  | A gated route is never a cached one | `modes.ts` refuses `policy` on both `static` and `isr` (`X_ROUTE_MODE_INVALID`, `modes.test.ts`). `ssr` is the one gated mode. |
97
97
  | Responses | return `RenderResult`. `@ultimat3/http` builds the `Response`. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/render",
3
- "version": "26.1.1",
3
+ "version": "27.1.0",
4
4
  "description": "The route primitive and the five render modes: static, isr, ssr, stream, spa.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -40,11 +40,11 @@
40
40
  "test": "bun test"
41
41
  },
42
42
  "dependencies": {
43
- "@ultimat3/cache": "26.1.1",
44
- "@ultimat3/core": "26.1.1",
45
- "@ultimat3/http": "26.1.1",
46
- "@ultimat3/i18n": "26.1.1",
47
- "@ultimat3/seo": "26.1.1",
43
+ "@ultimat3/cache": "27.1.0",
44
+ "@ultimat3/core": "27.1.0",
45
+ "@ultimat3/http": "27.1.0",
46
+ "@ultimat3/i18n": "27.1.0",
47
+ "@ultimat3/seo": "27.1.0",
48
48
  "sass": "1.105.1"
49
49
  }
50
50
  }
@@ -14,6 +14,12 @@ export interface RouteMount {
14
14
  readonly by: string;
15
15
  /** Every permission the route's own screen decides on, coarse gate first. */
16
16
  readonly permissions: readonly string[];
17
+ /**
18
+ * `true` when an app FILE serves the path in the mount's place — a config the mounter issued
19
+ * for it (`RouteMountInput.claimable`). The descriptor's `file` is then that file. Absent
20
+ * otherwise, never `false`.
21
+ */
22
+ readonly claimed?: true;
17
23
  }
18
24
 
19
25
  /** The mount a set of routes belongs to. `key` is what a re-declaration replaces. */
@@ -23,6 +29,13 @@ export interface RouteMountInput {
23
29
  /** What `RouteDescriptor.file` reads for these routes: the package, since no app file is. */
24
30
  readonly file: string;
25
31
  readonly surface: Exclude<Surface, 'shared'>;
32
+ /**
33
+ * May the file route declaring `config` serve `path` in the mount's place? The MOUNTER's
34
+ * answer, never render's: only the package that mounted a path knows which configs it issued
35
+ * (and with what guard inside). Asked with the config the module EXPORTED, by identity. Absent:
36
+ * no file may, and a file on a mounted path is `X_ROUTE_DUPLICATE`.
37
+ */
38
+ readonly claimable?: (path: string, config: RouteConfig) => boolean;
26
39
  }
27
40
 
28
41
  export interface MountedRouteInput {
@@ -62,6 +75,10 @@ export function clearMountedRoutes(): void {
62
75
  mounted.clear();
63
76
  }
64
77
 
78
+ /** Did the mount that owns `route` issue `config` for its path? */
79
+ export const claimedBy = (route: MountedRoute, config: RouteConfig): boolean =>
80
+ route.mount.claimable?.(route.path, config) === true;
81
+
65
82
  /** One URL, one claimant — a file and a mount on the same path is two servers for it. */
66
83
  export const mountCollision = (
67
84
  path: string,
package/src/registry.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  import { assertModeInvariants, defaultIslandBudget } from './modes';
21
21
  import type { MountedRouteInput, RouteMount, RouteMountInput } from './mounted-routes';
22
22
  import {
23
+ claimedBy,
23
24
  clearMountedRoutes,
24
25
  mountCollision,
25
26
  mountedAt,
@@ -87,8 +88,9 @@ export interface RouteDescriptor {
87
88
  readonly islandSources: readonly string[];
88
89
  readonly budgetJs: string | null;
89
90
  /**
90
- * Present on a route NO surface file declares: who mounted it and every permission that gates
91
- * it. Absent on a file route — so a reader never has to ask whether `file` is a path.
91
+ * Present on a route a package mounted: who mounted it and every permission that gates it.
92
+ * `file` is the package — unless `mount.claimed`, when an app file serves the path in the
93
+ * mount's place and `file` is that file. Absent on every other file route.
92
94
  */
93
95
  readonly mount?: RouteMount;
94
96
  }
@@ -212,6 +214,11 @@ function toUrlSegment(segment: string): string {
212
214
  }
213
215
 
214
216
  const routes = new Map<string, RouteEntry>();
217
+ /**
218
+ * The config each path's module EXPORTED, before `withIslandBudget` may have copied it: a mount's
219
+ * `claimable` answers by identity, and the entry's own config is not always that object.
220
+ */
221
+ const declaredConfigs = new Map<string, RouteConfig>();
215
222
  /** The table `describePages()` last built, dropped whenever a route registers or the registry clears. */
216
223
  let described: readonly RouteDescriptor[] | undefined;
217
224
 
@@ -297,7 +304,9 @@ export function registerRoute<TData = RouteData>(
297
304
  );
298
305
  }
299
306
  const claimed = mountedAt(path);
300
- if (claimed !== undefined) throw mountCollision(path, input.file, claimed.mount);
307
+ if (claimed !== undefined && !claimedBy(claimed, input.config as RouteConfig)) {
308
+ throw mountCollision(path, input.file, claimed.mount);
309
+ }
301
310
 
302
311
  const entry: RouteEntry<TData> = {
303
312
  file: input.file,
@@ -318,6 +327,7 @@ export function registerRoute<TData = RouteData>(
318
327
  ...(input.component === undefined ? {} : { component: input.component }),
319
328
  };
320
329
  routes.set(path, entry as RouteEntry);
330
+ declaredConfigs.set(path, input.config as RouteConfig);
321
331
  described = undefined;
322
332
  return entry;
323
333
  }
@@ -355,7 +365,9 @@ export function registerMountedRoutes(
355
365
  ): void {
356
366
  for (const route of declared) {
357
367
  const file = routes.get(route.path)?.file;
358
- if (file !== undefined) throw mountCollision(route.path, file, mount);
368
+ const config = declaredConfigs.get(route.path);
369
+ const claimed = config !== undefined && claimedBy({ ...route, mount }, config);
370
+ if (file !== undefined && !claimed) throw mountCollision(route.path, file, mount);
359
371
  assertPurgeableTags(mount.file, route.config.revalidate?.tags);
360
372
  }
361
373
  setMountedRoutes(mount, declared);
@@ -364,6 +376,7 @@ export function registerMountedRoutes(
364
376
 
365
377
  export function clearRoutes(): void {
366
378
  routes.clear();
379
+ declaredConfigs.clear();
367
380
  clearMountedRoutes();
368
381
  described = undefined;
369
382
  }
@@ -395,21 +408,39 @@ export function describePages(): readonly RouteDescriptor[] {
395
408
  }
396
409
 
397
410
  function buildDescriptors(): RouteDescriptor[] {
398
- const files = routeEntries().map((entry) =>
399
- descriptorOf(entry.path, entry.file, entry.surface, entry.config, entry.pattern.keys.length),
400
- );
401
- const mounts = mountedRoutes().map(
402
- (route): RouteDescriptor => ({
403
- ...descriptorOf(
404
- route.path,
405
- route.mount.file,
406
- route.mount.surface,
407
- route.config,
408
- compilePattern(route.path).keys.length,
409
- ),
410
- mount: { by: route.mount.by, permissions: [...route.permissions] },
411
- }),
412
- );
411
+ const mounted = mountedRoutes();
412
+ // A path an app file claimed is ONE row: the file's, still naming the mount that gates it.
413
+ const claimOf = (path: string): RouteMount | undefined => {
414
+ const route = mounted.find((candidate) => candidate.path === path);
415
+ return route === undefined
416
+ ? undefined
417
+ : { by: route.mount.by, permissions: [...route.permissions], claimed: true };
418
+ };
419
+ const files = routeEntries().map((entry): RouteDescriptor => {
420
+ const row = descriptorOf(
421
+ entry.path,
422
+ entry.file,
423
+ entry.surface,
424
+ entry.config,
425
+ entry.pattern.keys.length,
426
+ );
427
+ const claim = claimOf(entry.path);
428
+ return claim === undefined ? row : { ...row, mount: claim };
429
+ });
430
+ const mounts = mounted
431
+ .filter((route) => !routes.has(route.path))
432
+ .map(
433
+ (route): RouteDescriptor => ({
434
+ ...descriptorOf(
435
+ route.path,
436
+ route.mount.file,
437
+ route.mount.surface,
438
+ route.config,
439
+ compilePattern(route.path).keys.length,
440
+ ),
441
+ mount: { by: route.mount.by, permissions: [...route.permissions] },
442
+ }),
443
+ );
413
444
  return mounts.length === 0
414
445
  ? files
415
446
  : [...files, ...mounts].sort((a, b) => byCodeUnit(a.path, b.path));