@weftui/router 0.21.0 → 0.23.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,467 @@
1
+ import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "@effect/platform";
2
+ import { Context, Effect, Schema, Stream, Subscribable, pipe } from "effect";
3
+ import { Boundary, h } from "@weftui/core";
4
+ //#region src/errors.ts
5
+ /**
6
+ * Tagged error raised by {@link notFound} and caught by the router's internal
7
+ * not-found boundary. Exported so a user can place their own
8
+ * `Boundary.catchTag("RouterNotFound", …)` to override the fallback for a subtree
9
+ * (the router's internal boundary is outermost, so a nearer user boundary wins).
10
+ *
11
+ * Modeled as a `Schema.TaggedError` so it can be encoded/decoded across the wire
12
+ * the same way `Boundary.rpc` replays typed failures.
13
+ */
14
+ var RouterNotFound = class extends Schema.TaggedError()("RouterNotFound", {
15
+ /** The path that could not be resolved, when known. */
16
+ path: Schema.optional(Schema.String) }) {};
17
+ /**
18
+ * Short-circuits the current page render with a {@link RouterNotFound} failure,
19
+ * Next.js-style. Callable from any page or layout `component`; the nearest
20
+ * enclosing not-found boundary (the router's internal one by default) renders the
21
+ * configured `notFound` page in its place. The server responds with HTTP 404.
22
+ *
23
+ * @param path - Optional path to attach for diagnostics.
24
+ */
25
+ const notFound = (path) => Effect.fail(new RouterNotFound({ path }));
26
+ /** Type guard recognising a {@link RouterNotFound} value regardless of its prototype. */
27
+ const isRouterNotFound = (u) => typeof u === "object" && u !== null && "_tag" in u && u._tag === "RouterNotFound";
28
+ /**
29
+ * Tagged error raised by `Router.params` / `Router.query` when the live match does
30
+ * not satisfy the requested fields — either no route is matched, or a requested
31
+ * key is missing / fails its schema's `Type`-side validation. `source` records
32
+ * whether the failure was on the path params or the query, and `keys` lists the
33
+ * requested field names for diagnostics.
34
+ *
35
+ * It bubbles up through the route tree's aggregate error channel, so a user may
36
+ * place a `Boundary.catchTag("RouterParamsError", …)` to recover within a subtree.
37
+ *
38
+ * Modeled as a `Schema.TaggedError` so it can be encoded/decoded across the wire
39
+ * the same way `RouterNotFound` and `Boundary.rpc` replay typed failures.
40
+ */
41
+ var RouterParamsError = class extends Schema.TaggedError()("RouterParamsError", {
42
+ /** Which side of the match failed validation. */
43
+ source: Schema.Literal("path", "query"),
44
+ /** The requested field names, for diagnostics. */
45
+ keys: Schema.Array(Schema.String)
46
+ }) {};
47
+ //#endregion
48
+ //#region src/compile.ts
49
+ /**
50
+ * Maps each authored {@link RouteNode} to its {@link CompiledLeaf}. Populated by
51
+ * {@link compile} (via {@link router}) and read by `href` so a leaf reference can
52
+ * resolve its full pattern and schemas.
53
+ */
54
+ const leafRegistry = /* @__PURE__ */ new WeakMap();
55
+ /** Splits a segment string into its non-empty path parts. */
56
+ function splitSegment(segment) {
57
+ return segment.split("/").filter((s) => s.length > 0);
58
+ }
59
+ /** Joins cumulative path parts into a normalized pattern (`/`-prefixed, no trailing `/`). */
60
+ function toPattern(parts) {
61
+ return parts.length === 0 ? "/" : `/${parts.join("/")}`;
62
+ }
63
+ /** Extracts `:name` placeholder names from cumulative path parts, in order. */
64
+ function extractParams(parts) {
65
+ return parts.filter((p) => p.startsWith(":")).map((p) => p.slice(1));
66
+ }
67
+ /** Derives a stable, identifier-safe id from a full path pattern. */
68
+ function patternToId(pattern, index) {
69
+ const base = pattern.replace(/:/g, "").replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "");
70
+ return base.length === 0 ? `root_${index}` : `${base}_${index}`;
71
+ }
72
+ /** The longest leading run of path parts shared by every entry in `partsList`. */
73
+ function longestCommonSegmentPrefix(partsList) {
74
+ const first = partsList[0];
75
+ if (first === void 0) return [];
76
+ const prefix = [];
77
+ for (let i = 0; i < first.length; i++) {
78
+ const part = first[i];
79
+ if (part === void 0) break;
80
+ let common = true;
81
+ for (let j = 1; j < partsList.length; j++) if (partsList[j]?.[i] !== part) {
82
+ common = false;
83
+ break;
84
+ }
85
+ if (!common) break;
86
+ prefix.push(part);
87
+ }
88
+ return prefix;
89
+ }
90
+ /**
91
+ * Compiles a route tree into a flat list of {@link CompiledLeaf}s (C1–C6).
92
+ *
93
+ * Pass 1 walks the tree: only **routes** contribute path parts (layouts own no
94
+ * path), so each leaf's `parts` come solely from the route segments on its branch,
95
+ * and its ancestor `LayoutNode`s are recorded in order. Pass 2 derives one shared
96
+ * {@link CompiledLayout} per distinct layout node — its `patternPrefix` is the
97
+ * longest common path prefix of that layout's subtree leaves — then assembles each
98
+ * leaf's `layoutChain` (root → parent) and merged path schema.
99
+ */
100
+ function compile(def) {
101
+ const leafWorks = [];
102
+ const walk = (node, parentParts, parentPathFields, ancestors) => {
103
+ if (node._tag === "Layout") {
104
+ for (const child of node.children) walk(child, parentParts, parentPathFields, [...ancestors, node]);
105
+ return;
106
+ }
107
+ leafWorks.push({
108
+ node,
109
+ parts: [...parentParts, ...splitSegment(node.segment)],
110
+ pathFields: {
111
+ ...parentPathFields,
112
+ ...node.path
113
+ },
114
+ query: node.query,
115
+ ancestors
116
+ });
117
+ };
118
+ walk(def.root, [], {}, []);
119
+ const layoutLeafParts = /* @__PURE__ */ new Map();
120
+ for (const work of leafWorks) for (const ancestor of work.ancestors) {
121
+ const list = layoutLeafParts.get(ancestor) ?? [];
122
+ list.push(work.parts);
123
+ layoutLeafParts.set(ancestor, list);
124
+ }
125
+ const compiledLayouts = /* @__PURE__ */ new Map();
126
+ for (const [node, partsList] of layoutLeafParts) {
127
+ const lcp = longestCommonSegmentPrefix(partsList);
128
+ compiledLayouts.set(node, {
129
+ patternPrefix: toPattern(lcp),
130
+ paramNames: extractParams(lcp),
131
+ component: node.component
132
+ });
133
+ }
134
+ const leaves = [];
135
+ for (const work of leafWorks) {
136
+ const fullPathPattern = toPattern(work.parts);
137
+ const paramNames = extractParams(work.parts);
138
+ const pathFields = {};
139
+ for (const name of paramNames) pathFields[name] = work.pathFields[name] ?? Schema.String;
140
+ const leaf = {
141
+ id: patternToId(fullPathPattern, leaves.length),
142
+ fullPathPattern,
143
+ paramNames,
144
+ pathSchema: Schema.Struct(pathFields),
145
+ querySchema: Schema.Struct(work.query),
146
+ component: work.node.component,
147
+ layoutChain: work.ancestors.map((a) => compiledLayouts.get(a))
148
+ };
149
+ leaves.push(leaf);
150
+ leafRegistry.set(work.node, leaf);
151
+ }
152
+ return {
153
+ leaves,
154
+ notFound: def.notFound
155
+ };
156
+ }
157
+ /**
158
+ * Builds the authoritative `HttpApi` for a compiled tree (S4): a single `"pages"`
159
+ * group whose endpoints are GET endpoints — one per leaf — at each leaf's full path
160
+ * pattern, carrying `setPath(pathSchema)`, `setUrlParams(querySchema)`, a
161
+ * `Schema.String` (text/HTML) success, and a `RouterNotFound → 404` error. The tree
162
+ * (not `HttpApi`) is the authoring surface; this is the single source of truth the
163
+ * server dispatch (`HttpApiBuilder`) and the client matcher / derived `HttpApiClient`
164
+ * read from, so both sides agree on paths and schemas.
165
+ *
166
+ * Each leaf's `pathSchema`/`querySchema` are typed string-encodeable (see
167
+ * {@link CompiledLeaf}), so `setPath`/`setUrlParams` need no `as any` casts.
168
+ *
169
+ * `Boundary.rpc` data no longer rides this spine: it resolves through the app's
170
+ * merged `RpcGroup` over the ambient `AppRpcClient` (`POST /_eui/rpc`), wired
171
+ * explicitly into `RouterServer`/`RouterLive`. The matcher reads only `"pages"`.
172
+ */
173
+ function buildHttpApi(leaves) {
174
+ const group = leaves.reduce((g, leaf) => g.add(HttpApiEndpoint.get(leaf.id, leaf.fullPathPattern).setPath(leaf.pathSchema).setUrlParams(leaf.querySchema).addSuccess(Schema.String).addError(RouterNotFound, { status: 404 })), HttpApiGroup.make("pages"));
175
+ return HttpApi.make("router").add(group);
176
+ }
177
+ /**
178
+ * Seals a route tree into a {@link RouterDef}, compiling it eagerly (so leaf
179
+ * references are stamped for `href`), building its authoritative {@link buildHttpApi}
180
+ * spine, and capturing the app-level not-found page. The tree's aggregate channels
181
+ * (plus the not-found page's) are carried on the returned `RouterDef`'s phantom
182
+ * `E`/`R` params.
183
+ */
184
+ function makeRouter(root, options) {
185
+ const compiled = compile({
186
+ root,
187
+ notFound: options.notFound
188
+ });
189
+ return {
190
+ root,
191
+ notFound: options.notFound,
192
+ compiled,
193
+ httpApi: buildHttpApi(compiled.leaves)
194
+ };
195
+ }
196
+ //#endregion
197
+ //#region src/route-tree.ts
198
+ function makeRoute(segment, config) {
199
+ return {
200
+ _tag: "Route",
201
+ segment,
202
+ path: config.path ?? {},
203
+ query: config.query ?? {},
204
+ component: config.component
205
+ };
206
+ }
207
+ /**
208
+ * Declares a layout. `component` is a {@link ComponentSlot} that splices the next
209
+ * level down via `yield* Router.Outlet` (place it in the returned tree). The
210
+ * router invokes it per render and provides that outlet, so `Router.Outlet` is
211
+ * **excluded** from the layout's aggregate requirement channel; the subtree's
212
+ * real channels are unioned in.
213
+ *
214
+ * @example
215
+ * ```ts
216
+ * Router.layout(
217
+ * {
218
+ * component: Component.gen(function* () {
219
+ * const outlet = yield* Router.Outlet;
220
+ * return yield* h.div({ class: "shell" }, [Header(), outlet]);
221
+ * }),
222
+ * },
223
+ * [Router.route("", { component: Home })],
224
+ * );
225
+ * ```
226
+ */
227
+ function makeLayout(config, children) {
228
+ return {
229
+ _tag: "Layout",
230
+ component: config.component,
231
+ children
232
+ };
233
+ }
234
+ /**
235
+ * Brand key marking a {@link ComponentSlot} as **preloadable** — a lazy slot
236
+ * ({@link lazyComponent}) carries a `preload()` under this key. Internal to
237
+ * `@weftui/router` (read by the client `navigate` to resolve a matched branch's
238
+ * chunks before commit; see `pending-navigation.specs.md`); not public API.
239
+ *
240
+ * Declared `unique symbol` so it is usable as a computed interface key.
241
+ */
242
+ const PreloadSlot = Symbol.for("@weftui/router/preload");
243
+ /**
244
+ * Reads the {@link PreloadSlot | preload} capability off a slot, or `undefined` for an
245
+ * eager slot. Lets `navigate` await a matched branch's lazy chunks without knowing
246
+ * which slots are lazy.
247
+ */
248
+ function getPreload(slot) {
249
+ return slot[PreloadSlot];
250
+ }
251
+ /**
252
+ * Wraps a dynamic-import `load` as a lazy {@link ComponentSlot}: the route's descriptor
253
+ * (`segment`, `path`/`query`) stays eager and matchable, while the component — the render
254
+ * body and its module's deps — is split into the chunk `load` resolves. The router invokes
255
+ * the returned slot at render time; it awaits `load` then renders the resolved component,
256
+ * adopting the server DOM in place on hydration (flash-free) and fetching the chunk on
257
+ * client navigation. Exposed as {@link Router.lazy}. See `lazy-component.specs.md`.
258
+ *
259
+ * The resolved value is a component slot (`Component.gen` / `Component.make`, or a
260
+ * `() => Node` thunk) — the shape `component:` already accepts — so its `E`/`R` channels
261
+ * are recovered via {@link SlotNode} and propagate up the tree exactly as an eager
262
+ * component's do.
263
+ *
264
+ * @example
265
+ * ```ts
266
+ * Router.route("docs/:category/:slug", {
267
+ * path: { category: Schema.String, slug: Schema.String },
268
+ * component: Router.lazy(() => import("./doc-page").then((m) => m.DocPage)),
269
+ * });
270
+ * ```
271
+ */
272
+ function lazyComponent(load) {
273
+ let cached;
274
+ let resolved;
275
+ const preload = () => (cached ??= load()).then((component) => {
276
+ resolved = component;
277
+ return component;
278
+ });
279
+ const slot = () => resolved !== void 0 ? resolved({}) : Effect.gen(function* () {
280
+ return yield* (yield* Effect.promise(() => cached ??= load()))({});
281
+ });
282
+ return Object.assign(slot, { [PreloadSlot]: preload });
283
+ }
284
+ //#endregion
285
+ //#region src/router-service.ts
286
+ var Router = class extends Context.Tag("@weftui/router/Router")() {};
287
+ /**
288
+ * The injected outlet: the node a layout (or the server document shell) splices
289
+ * to place the next level down. Provided per render by the router
290
+ * (`Effect.provideService(layout.component({}), OutletTag, innerNode)`); a layout
291
+ * reads it with `yield* Router.Outlet`.
292
+ *
293
+ * Typed **opaque** as `Node<never, never>` so splicing `[outlet]` adds nothing to
294
+ * a layout's local channels — the subtree's real channels are aggregated
295
+ * structurally by {@link makeLayout} / {@link makeRouter}, never inferred across
296
+ * this DI boundary. Re-exported on the namespace as `Router.Outlet`.
297
+ */
298
+ var OutletTag = class extends Context.Tag("@weftui/router/Outlet")() {};
299
+ /** Picks the requested `fields` keys out of a decoded match record. */
300
+ function pick(fields, record) {
301
+ const subset = {};
302
+ for (const key of Object.keys(fields)) subset[key] = record[key];
303
+ return subset;
304
+ }
305
+ /**
306
+ * Reads the live match's **path params** for the requested `fields`. Snapshot
307
+ * semantics — reads `yield* Router` then `currentMatch.get` — and returns the
308
+ * already-decoded values **directly**: the matcher decoded them against the leaf's
309
+ * full path schema, so no re-validation is needed (the cast is sound — the picked
310
+ * subset is the `Type` side of `fields`). Fails with a {@link RouterParamsError}
311
+ * (`source: "path"`) only when no route is matched. Re-exported as `Router.params`.
312
+ */
313
+ function readParams(fields) {
314
+ return Effect.gen(function* () {
315
+ const match = yield* (yield* Router).currentMatch.get;
316
+ if (match._tag === "NotFound") return yield* Effect.fail(new RouterParamsError({
317
+ source: "path",
318
+ keys: Object.keys(fields)
319
+ }));
320
+ return pick(fields, match.path);
321
+ });
322
+ }
323
+ /**
324
+ * Reads the live match's **query** for the requested `fields`. Same snapshot +
325
+ * direct-read semantics as {@link readParams}, failing with a
326
+ * {@link RouterParamsError} (`source: "query"`) only on a no-match. Re-exported as
327
+ * `Router.query`.
328
+ */
329
+ function readQuery(fields) {
330
+ return Effect.gen(function* () {
331
+ const match = yield* (yield* Router).currentMatch.get;
332
+ if (match._tag === "NotFound") return yield* Effect.fail(new RouterParamsError({
333
+ source: "query",
334
+ keys: Object.keys(fields)
335
+ }));
336
+ return pick(fields, match.query);
337
+ });
338
+ }
339
+ /**
340
+ * Builds a reactive {@link Subscribable} of the picked `fields` from a `currentMatch`
341
+ * Subscribable, reading the `path` or `query` side. Unlike {@link readParams} /
342
+ * {@link readQuery} (snapshot accessors that fail on `NotFound`), the reactive form
343
+ * is **resilient**: a `NotFound` match yields the empty subset (each field
344
+ * `undefined`) rather than failing, so the stream stays live across navigations.
345
+ */
346
+ function selectStream(currentMatch, fields, source) {
347
+ const select = (m) => pick(fields, m._tag === "Matched" ? m[source] : {});
348
+ return Subscribable.make({
349
+ get: Effect.map(currentMatch.get, select),
350
+ changes: Stream.map(currentMatch.changes, select)
351
+ });
352
+ }
353
+ /**
354
+ * Reactive counterpart to {@link readParams}: a {@link Subscribable} of the live
355
+ * match's **path params** for `fields`, derived from `currentMatch.changes`. It
356
+ * re-emits on every navigation and stays live across `NotFound` (yielding the empty
357
+ * subset), so a component can render `[(yield* Router.paramsStream(fields)).changes]`
358
+ * and update in place even when the outlet keeps the same leaf mounted. Re-exported
359
+ * as `Router.paramsStream`.
360
+ */
361
+ function subscribeParams(fields) {
362
+ return Effect.map(Router, (router) => selectStream(router.currentMatch, fields, "path"));
363
+ }
364
+ /**
365
+ * Reactive counterpart to {@link readQuery}: a {@link Subscribable} of the live
366
+ * match's **query** for `fields`. Especially useful for query-only changes
367
+ * (`setQuery` / `patchQuery`), which keep the same leaf mounted: a snapshot
368
+ * `Router.query` would not update, but this stream does. Re-exported as
369
+ * `Router.queryStream`.
370
+ */
371
+ function subscribeQuery(fields) {
372
+ return Effect.map(Router, (router) => selectStream(router.currentMatch, fields, "query"));
373
+ }
374
+ /**
375
+ * Reactive {@link Subscribable} of the client {@link NavState}. A component reads
376
+ * `[(yield* Router.navigatingStream).changes]` to render pending UI during a
377
+ * deferred-commit navigation (`pending-navigation.specs.md`). Re-exported as
378
+ * `Router.navigatingStream`.
379
+ */
380
+ const subscribeNavigating = Effect.map(Router, (router) => router.navigating);
381
+ (function(_Router) {
382
+ _Router.route = makeRoute;
383
+ _Router.layout = makeLayout;
384
+ _Router.lazy = lazyComponent;
385
+ _Router.router = makeRouter;
386
+ _Router.Outlet = OutletTag;
387
+ _Router.params = readParams;
388
+ _Router.query = readQuery;
389
+ _Router.paramsStream = subscribeParams;
390
+ _Router.queryStream = subscribeQuery;
391
+ _Router.navigatingStream = subscribeNavigating;
392
+ })(Router || (Router = {}));
393
+ //#endregion
394
+ //#region src/outlet.ts
395
+ /** Substitutes `:name` placeholders in a pattern with encoded param values. */
396
+ function substitute(pattern, params) {
397
+ return pattern.replace(/:([A-Za-z0-9_]+)/g, (_m, name) => encodeURIComponent(String(params[name])));
398
+ }
399
+ /** A dedupe key reused when this level is unchanged across a navigation (O2). */
400
+ function keyOf(index, match) {
401
+ if (match._tag === "NotFound") return "\0notfound";
402
+ const chain = match.leaf.layoutChain;
403
+ if (index >= chain.length) return `leaf:${match.url}`;
404
+ const layout = chain[index];
405
+ return layout === void 0 ? `layout:${index}` : substitute(layout.patternPrefix, match.path);
406
+ }
407
+ /** Builds the `Renderable` for one nesting level of a match. */
408
+ function renderLevel(def, router, index, match) {
409
+ if (match._tag === "NotFound") return def.compiled.notFound();
410
+ const chain = match.leaf.layoutChain;
411
+ const leafProps = {
412
+ path: match.path,
413
+ query: match.query
414
+ };
415
+ if (index >= chain.length) return match.leaf.component(leafProps);
416
+ const layout = chain[index];
417
+ if (layout === void 0) return match.leaf.component(leafProps);
418
+ const outlet = h.fragment([levelStream(def, router, index + 1)]);
419
+ return Effect.provideService(layout.component({}), Router.Outlet, outlet);
420
+ }
421
+ /**
422
+ * The reactive stream for one nesting level: re-emits only when this level's
423
+ * dedupe key changes, so unchanged ancestor layouts stay mounted while a deeper
424
+ * level swaps (O2/O3). The DOM renderer treats the returned `Stream` as a
425
+ * reactive child region.
426
+ */
427
+ function levelStream(def, router, index) {
428
+ return pipe(router.currentMatch.changes, Stream.map((match) => [keyOf(index, match), match]), Stream.changesWith((a, b) => a[0] === b[0]), Stream.map(([, match]) => renderLevel(def, router, index, match)));
429
+ }
430
+ /**
431
+ * The bare nested-outlet node for a router definition: a fragment whose single
432
+ * reactive child is the level-0 stream. Used directly by the server renderer so
433
+ * a `RouterNotFound` raised by a page escapes to the server's 404 handler.
434
+ */
435
+ function outletNode(def) {
436
+ const stream = Stream.unwrap(Effect.map(Router, (router) => levelStream(def, router, 0)));
437
+ return h.fragment([stream]);
438
+ }
439
+ /**
440
+ * The universal router root node. Wraps {@link outletNode} in the router's
441
+ * internal not-found boundary so a `RouterNotFound` raised by a page renders the
442
+ * configured `notFound` page in place (also covering client-side navigation). A
443
+ * user `Boundary.catchTag("RouterNotFound", …)` placed inside a page is nearer and
444
+ * therefore wins for that subtree.
445
+ *
446
+ * Server and client render the **same** `RouterApp` tree so hydration aligns. The
447
+ * server no longer needs a status side-channel: it dispatches through
448
+ * `HttpApiBuilder`, so a page-raised `RouterNotFound` (and a no-match) surface their
449
+ * 404 through the platform request pipeline rather than a render-time callback.
450
+ *
451
+ * `RouterLive` is a scoped layer (it owns the popstate listener + link click
452
+ * interceptor) and must outlive the mount, so provide it via a long-lived
453
+ * `ManagedRuntime` rather than `Effect.provide` at the node level:
454
+ *
455
+ * ```ts
456
+ * const runtime = ManagedRuntime.make(RouterLive(def));
457
+ * runtime.runPromise(hydrate(RouterApp(def), root));
458
+ * ```
459
+ */
460
+ function RouterApp(def) {
461
+ return Boundary.catchTag({
462
+ tag: "RouterNotFound",
463
+ fallback: () => def.compiled.notFound()
464
+ }, [outletNode(def)]);
465
+ }
466
+ //#endregion
467
+ export { buildHttpApi as a, RouterNotFound as c, notFound as d, getPreload as i, RouterParamsError as l, outletNode as n, compile as o, Router as r, leafRegistry as s, RouterApp as t, isRouterNotFound as u };
@@ -1,4 +1,4 @@
1
- import { E as RouterNotFound, d as FieldsType, i as RouterDef, m as RouteNode, u as Fields, x as Router } from "./compile-C0JShTTR.js";
1
+ import { D as RouterNotFound, S as Router, d as FieldsType, i as RouterDef, m as RouteNode, u as Fields } from "./compile-HOyeyWRy.js";
2
2
  import { Node } from "@weftui/core";
3
3
 
4
4
  //#region src/href.d.ts
@@ -1,5 +1,6 @@
1
- import { i as RouterDef, l as ComponentSlot } from "../compile-C0JShTTR.js";
1
+ import { S as Router, i as RouterDef, l as ComponentSlot } from "../compile-HOyeyWRy.js";
2
2
  import { Effect, Layer } from "effect";
3
+ import { AppRpcClientTag } from "@weftui/core";
3
4
  import { RpcGroup } from "@effect/rpc";
4
5
 
5
6
  //#region src/server/router-server.d.ts
@@ -19,7 +20,7 @@ declare namespace RouterServer {
19
20
  * registry) — `toWebHandler` serves it at `POST /_eui/rpc`, and an in-process
20
21
  * client over the same handlers resolves SSR boundaries in-process.
21
22
  */
22
- interface RpcOptions {
23
+ export interface RpcOptions {
23
24
  /** The app's merged `RpcGroup` (pure Schema contract; shared with the client). */
24
25
  readonly group: RpcGroup.RpcGroup<any>;
25
26
  /** The server-only handler Layer (`group.toLayer(...)` ⊕ its dependencies). */
@@ -33,8 +34,16 @@ declare namespace RouterServer {
33
34
  * params), so the document may use either. Mirrors the route/layout `component` slot,
34
35
  * so it accepts both a plain thunk and a `Component.make` / `Component.gen` component.
35
36
  * `<!DOCTYPE html>` is prepended at serialize time.
37
+ *
38
+ * `context` is the render-time provide seam (spec:
39
+ * `ambient-context-propagation.specs.md`): an app-wide `Layer` provided to the
40
+ * document shell **and** every route/layout leaf. It is required exactly when the
41
+ * def carries residual app services ({@link AppServices} — its aggregate `R` minus
42
+ * the `Router` / `Router.Outlet` / `AppRpcClientTag` the router already threads) and
43
+ * disallowed otherwise, so a missing provide is a compile error and `rpc`-only /
44
+ * no-service apps stay unchanged (surfaced via {@link ContextOption} at each entry).
36
45
  */
37
- interface Options {
46
+ export interface Options {
38
47
  /** The document shell slot; reads the app to splice via `yield* Router.Outlet`. */
39
48
  readonly document: ComponentSlot;
40
49
  /**
@@ -45,8 +54,33 @@ declare namespace RouterServer {
45
54
  */
46
55
  readonly rpc?: RpcOptions;
47
56
  }
57
+ /**
58
+ * The residual app services a caller must still provide through the {@link Options.context}
59
+ * seam: a def's aggregate requirement `R` **minus** the services the router already
60
+ * threads in per render — `Router` and `Router.Outlet` (provided by the outlet /
61
+ * document plumbing) and `AppRpcClientTag` (provided from the `rpc` option). When this
62
+ * resolves to `never` the app has no app-wide service to inject and `context` is disallowed.
63
+ */
64
+ export type AppServices<R> = Exclude<R, Router | Router.Outlet | AppRpcClientTag>;
65
+ /** True only for the exact `any` type — a loosely-typed `RouterDef<any, any>`. */
66
+ type IsAny<T> = 0 extends 1 & T ? true : false;
67
+ /**
68
+ * Conditionally shapes the `context` field at each entry point: **required** (a
69
+ * `Layer` supplying every residual {@link AppServices}) when the def has statically
70
+ * known app-wide services, **absent** when it has none, and **optional** for a
71
+ * loosely-typed `RouterDef<any, any>` (residual services can't be tracked, so the
72
+ * seam is not forced). This makes a missing provide a compile error for a precisely
73
+ * typed def (AC2) while keeping no-service / loosely-typed apps unchanged (AC3).
74
+ */
75
+ export type ContextOption<R> = [AppServices<R>] extends [never] ? {
76
+ readonly context?: undefined;
77
+ } : IsAny<AppServices<R>> extends true ? {
78
+ readonly context?: Layer.Layer<any, never, never>;
79
+ } : {
80
+ readonly context: Layer.Layer<AppServices<R>, never, never>;
81
+ };
48
82
  /** The result of {@link render}. */
49
- interface Rendered {
83
+ export interface Rendered {
50
84
  readonly html: string;
51
85
  readonly status: number;
52
86
  }
@@ -56,16 +90,16 @@ declare namespace RouterServer {
56
90
  * with `<!DOCTYPE html>` prepended and the status sourced from the platform
57
91
  * pipeline (200, or 404 for a no-match / page-raised `RouterNotFound`).
58
92
  */
59
- function render(def: RouterDef, options: Options & {
93
+ export function render<R>(def: RouterDef<any, R>, options: Options & {
60
94
  readonly url: string;
61
- }): Effect.Effect<Rendered, Error>;
95
+ } & ContextOption<R>): Effect.Effect<Rendered, Error>;
62
96
  /**
63
97
  * The platform web `fetch`-style handler `(Request) => Promise<Response>` that
64
98
  * dispatches through `HttpApiBuilder` and renders the matched route to
65
99
  * `text/html`. Suitable for bridging into a dev server (e.g. Vite) or any
66
100
  * Web-platform server.
67
101
  */
68
- function toWebHandler(def: RouterDef, options: Options): (request: Request) => Promise<Response>;
102
+ export function toWebHandler<R>(def: RouterDef<any, R>, options: Options & ContextOption<R>): (request: Request) => Promise<Response>;
69
103
  /**
70
104
  * Streaming variant of {@link toWebHandler} (spec: "Streaming SSR" in
71
105
  * `router.specs.md`, SW1 … SW7). Same `Options`, memoized separately. Per
@@ -78,7 +112,8 @@ declare namespace RouterServer {
78
112
  * `RouterNotFound` stay real 404s; `POST /_eui/rpc` delegation is unchanged.
79
113
  * `render` and `toWebHandler` remain fully buffered.
80
114
  */
81
- function toStreamingWebHandler(def: RouterDef, options: Options): (request: Request) => Promise<Response>;
115
+ export function toStreamingWebHandler<R>(def: RouterDef<any, R>, options: Options & ContextOption<R>): (request: Request) => Promise<Response>;
116
+ export {};
82
117
  }
83
118
  //#endregion
84
119
  export { RouterServer };