@weftui/router 0.22.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.
- package/dist/client/index.js +225 -1
- package/dist/href-CSRbOQov.js +165 -0
- package/dist/index.js +3 -1
- package/dist/outlet-CXgDkheJ.js +467 -0
- package/dist/server/index.js +212 -1
- package/package.json +5 -4
- package/dist/href-BgbDo3hx.js +0 -1
- package/dist/outlet-BS31W71_.js +0 -0
package/dist/client/index.js
CHANGED
|
@@ -1 +1,225 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { i as getPreload, n as outletNode, r as Router, t as RouterApp } from "../outlet-CXgDkheJ.js";
|
|
2
|
+
import { r as match, t as href } from "../href-CSRbOQov.js";
|
|
3
|
+
import { FetchHttpClient, HttpApiClient } from "@effect/platform";
|
|
4
|
+
import { Context, Effect, Layer, Option, Runtime, Schema, Stream, Subscribable, SubscriptionRef } from "effect";
|
|
5
|
+
import { AppRpcClientTag } from "@weftui/core";
|
|
6
|
+
//#region src/client/link.ts
|
|
7
|
+
/**
|
|
8
|
+
* Installs a global, delegated click interceptor (in the `Router` layer scope)
|
|
9
|
+
* that turns plain same-origin `h.a({ href })` clicks into SPA navigation when
|
|
10
|
+
* the href resolves to a route (L1). Modified clicks, non-left buttons,
|
|
11
|
+
* `target=_blank`, `download`, external origins, same-document (hash-only or
|
|
12
|
+
* identical-URL) navigations, and non-matching hrefs fall through to the
|
|
13
|
+
* browser's native handling — the interceptor leaves `preventDefault` untouched
|
|
14
|
+
* in those cases (L2). The listener is removed on scope teardown (L3).
|
|
15
|
+
*
|
|
16
|
+
* @param def - The router definition, used to decide whether an href matches a route.
|
|
17
|
+
* @param navigate - The router's `navigate`, run via the captured runtime on a match.
|
|
18
|
+
*/
|
|
19
|
+
function installLinkInterceptor(def, navigate) {
|
|
20
|
+
return Effect.gen(function* () {
|
|
21
|
+
const runtime = yield* Effect.runtime();
|
|
22
|
+
const onClick = (event) => {
|
|
23
|
+
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
24
|
+
const target = event.target;
|
|
25
|
+
const anchor = target instanceof Element ? target.closest("a") : null;
|
|
26
|
+
if (anchor === null) return;
|
|
27
|
+
const targetAttr = anchor.getAttribute("target");
|
|
28
|
+
if (anchor.hasAttribute("download") || targetAttr !== null && targetAttr !== "_self" || anchor.getAttribute("rel") === "external") return;
|
|
29
|
+
const href = anchor.getAttribute("href");
|
|
30
|
+
if (href === null || href.length === 0) return;
|
|
31
|
+
let url;
|
|
32
|
+
try {
|
|
33
|
+
url = new URL(href, window.location.href);
|
|
34
|
+
} catch {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (url.origin !== window.location.origin) return;
|
|
38
|
+
const to = `${url.pathname}${url.search}`;
|
|
39
|
+
if (to === `${window.location.pathname}${window.location.search}`) return;
|
|
40
|
+
if (match(def, to)._tag !== "Matched") return;
|
|
41
|
+
event.preventDefault();
|
|
42
|
+
Runtime.runFork(runtime)(navigate(to));
|
|
43
|
+
};
|
|
44
|
+
yield* Effect.acquireRelease(Effect.sync(() => document.addEventListener("click", onClick)), () => Effect.sync(() => document.removeEventListener("click", onClick)));
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region src/client/router-live.ts
|
|
49
|
+
/** Path the client rpc protocol posts to; mirrors `RouterServer`'s server route. */
|
|
50
|
+
const RPC_PATH = "/_eui/rpc";
|
|
51
|
+
/** Reads the current location as a normalized `path + search` string. */
|
|
52
|
+
function locationUrl() {
|
|
53
|
+
return `${window.location.pathname}${window.location.search}`;
|
|
54
|
+
}
|
|
55
|
+
/** Normalizes a navigation target (absolute or relative) to `path + search`. */
|
|
56
|
+
function normalizeTo(to) {
|
|
57
|
+
const url = new URL(to, window.location.href);
|
|
58
|
+
return `${url.pathname}${url.search}`;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The client `Router` layer, backed by the History API. Seeds a
|
|
62
|
+
* `SubscriptionRef` from `window.location`, listens for `popstate`, and exposes
|
|
63
|
+
* `currentMatch` as the ref mapped through the shared matcher. `navigate` pushes
|
|
64
|
+
* History state and updates the ref. Also installs the same-origin link click
|
|
65
|
+
* interceptor for the layer's lifetime.
|
|
66
|
+
*
|
|
67
|
+
* It additionally derives a real {@link RouterHttpApiClient} from `def.httpApi`
|
|
68
|
+
* (over `FetchHttpClient`, `baseUrl` default same-origin) and exposes it on the
|
|
69
|
+
* `Router` service for network work. SPA URL→leaf resolution stays local via the
|
|
70
|
+
* shared {@link match}er — both sides read the one `def.httpApi` definition.
|
|
71
|
+
*
|
|
72
|
+
* Alongside `Router` it provides the core {@link AppRpcClientTag} seam — a
|
|
73
|
+
* **network** flat rpc client (`RpcClient.make` over `layerProtocolHttp` →
|
|
74
|
+
* `POST /_eui/rpc`) — so `@weftui/dom` can resolve a `Boundary.rpc` (hydrated
|
|
75
|
+
* refetch and client-first mount) without depending on this package or
|
|
76
|
+
* `@effect/rpc`.
|
|
77
|
+
*/
|
|
78
|
+
function RouterLive(def, options = {}) {
|
|
79
|
+
const core = Layer.scopedContext(Effect.gen(function* () {
|
|
80
|
+
const urlRef = yield* SubscriptionRef.make(locationUrl());
|
|
81
|
+
const navRef = yield* SubscriptionRef.make({ _tag: "Idle" });
|
|
82
|
+
const runtime = yield* Effect.runtime();
|
|
83
|
+
const httpApiClient = yield* HttpApiClient.make(def.httpApi, { baseUrl: options.baseUrl ?? window.location.origin }).pipe(Effect.provide(FetchHttpClient.layer));
|
|
84
|
+
let latest = 0;
|
|
85
|
+
const collectPreloads = (m) => m._tag !== "Matched" ? [] : [m.leaf.component, ...m.leaf.layoutChain.map((l) => l.component)].map(getPreload).filter((p) => p !== void 0);
|
|
86
|
+
const commitUrl = (normalized, replace) => Effect.sync(() => {
|
|
87
|
+
if (replace) window.history.replaceState(null, "", normalized);
|
|
88
|
+
else window.history.pushState(null, "", normalized);
|
|
89
|
+
});
|
|
90
|
+
const commitTo = (normalized, pushUrl, replace) => Effect.gen(function* () {
|
|
91
|
+
const preloads = collectPreloads(match(def, normalized));
|
|
92
|
+
if (preloads.length === 0) {
|
|
93
|
+
if (pushUrl) yield* commitUrl(normalized, replace);
|
|
94
|
+
yield* SubscriptionRef.set(urlRef, normalized);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const token = ++latest;
|
|
98
|
+
yield* SubscriptionRef.set(navRef, {
|
|
99
|
+
_tag: "Navigating",
|
|
100
|
+
to: normalized
|
|
101
|
+
});
|
|
102
|
+
yield* Effect.promise(() => Promise.all(preloads.map((p) => p()))).pipe(Effect.onError(() => token === latest ? SubscriptionRef.set(navRef, { _tag: "Idle" }) : Effect.void));
|
|
103
|
+
if (token !== latest) return;
|
|
104
|
+
if (pushUrl) yield* commitUrl(normalized, replace);
|
|
105
|
+
yield* SubscriptionRef.set(urlRef, normalized);
|
|
106
|
+
yield* SubscriptionRef.set(navRef, { _tag: "Idle" });
|
|
107
|
+
});
|
|
108
|
+
const navigate = (to, options) => commitTo(normalizeTo(to), true, options?.replace === true);
|
|
109
|
+
const onPopState = () => {
|
|
110
|
+
Runtime.runFork(runtime)(commitTo(locationUrl(), false, false));
|
|
111
|
+
};
|
|
112
|
+
yield* Effect.acquireRelease(Effect.sync(() => window.addEventListener("popstate", onPopState)), () => Effect.sync(() => window.removeEventListener("popstate", onPopState)));
|
|
113
|
+
yield* installLinkInterceptor(def, navigate);
|
|
114
|
+
const currentMatch = Subscribable.make({
|
|
115
|
+
get: Effect.map(SubscriptionRef.get(urlRef), (url) => match(def, url)),
|
|
116
|
+
changes: Stream.map(urlRef.changes, (url) => match(def, url))
|
|
117
|
+
});
|
|
118
|
+
const rpc = options.rpc;
|
|
119
|
+
let appRpcClient;
|
|
120
|
+
if (rpc === void 0) appRpcClient = AppRpcClientTag.of({ call: (tag) => Effect.fail(/* @__PURE__ */ new Error(`Boundary.rpc "${tag}" cannot resolve: no \`rpc\` option was passed to RouterLive`)) });
|
|
121
|
+
else {
|
|
122
|
+
const { RpcClient, RpcSerialization } = yield* Effect.promise(() => import("@effect/rpc"));
|
|
123
|
+
const baseUrl = String(options.baseUrl ?? window.location.origin).replace(/\/$/, "");
|
|
124
|
+
const flatClient = yield* RpcClient.make(rpc.group, { flatten: true }).pipe(Effect.provide(RpcClient.layerProtocolHttp({ url: `${baseUrl}${RPC_PATH}` }).pipe(Layer.provide(Layer.mergeAll(FetchHttpClient.layer, RpcSerialization.layerJson)))));
|
|
125
|
+
appRpcClient = AppRpcClientTag.of({ call: (tag, payload) => flatClient(tag, payload) });
|
|
126
|
+
}
|
|
127
|
+
const router = Router.of({
|
|
128
|
+
currentMatch,
|
|
129
|
+
navigate,
|
|
130
|
+
httpApiClient: Option.some(httpApiClient),
|
|
131
|
+
navigating: navRef
|
|
132
|
+
});
|
|
133
|
+
return Context.make(Router, router).pipe(Context.add(AppRpcClientTag, appRpcClient));
|
|
134
|
+
}));
|
|
135
|
+
const context = options.context;
|
|
136
|
+
return context === void 0 ? core : Layer.merge(core, context);
|
|
137
|
+
}
|
|
138
|
+
//#endregion
|
|
139
|
+
//#region src/client/navigation.ts
|
|
140
|
+
/**
|
|
141
|
+
* Programmatic, type-safe navigation helpers built on the `Router` service and the
|
|
142
|
+
* type-safe {@link href} builder. They mirror the History API the client `Router`
|
|
143
|
+
* layer (`RouterLive`) is backed by:
|
|
144
|
+
*
|
|
145
|
+
* - {@link navigate} — go to a leaf route reference with typed `{ path, query }`.
|
|
146
|
+
* - {@link push} / {@link replace} — go to a raw `path + search` string.
|
|
147
|
+
* - {@link back} / {@link forward} — step through History (`history.go`).
|
|
148
|
+
* - {@link setQuery} / {@link patchQuery} — change the current route's query in
|
|
149
|
+
* place, re-encoding through the matched leaf's `querySchema`.
|
|
150
|
+
*
|
|
151
|
+
* All but `back`/`forward` require the `Router` service (run them within the layer
|
|
152
|
+
* provided by `RouterLive`); `back`/`forward` only touch `window.history`.
|
|
153
|
+
*/
|
|
154
|
+
/**
|
|
155
|
+
* Navigates to a leaf route `ref` with typed `path`/`query` args, building the URL
|
|
156
|
+
* via {@link href} (so it round-trips with `match`) and pushing — or, with
|
|
157
|
+
* `options.replace`, replacing — the History entry. `path` is required when the
|
|
158
|
+
* route has path params; `query` is optional when every query field is optional
|
|
159
|
+
* (same requiredness rules as `href`).
|
|
160
|
+
*
|
|
161
|
+
* @example
|
|
162
|
+
* ```ts
|
|
163
|
+
* yield* navigate(userRoute, { path: { id: 42 } });
|
|
164
|
+
* yield* navigate(userRoute, { path: { id: 42 }, query: { tab: "posts" } }, { replace: true });
|
|
165
|
+
* ```
|
|
166
|
+
*/
|
|
167
|
+
function navigate(ref, ...args) {
|
|
168
|
+
const [hrefArgs, options] = args;
|
|
169
|
+
const to = href(ref, hrefArgs);
|
|
170
|
+
return Effect.flatMap(Router, (router) => router.navigate(to, options));
|
|
171
|
+
}
|
|
172
|
+
/** Navigates to a raw `path + search` string, pushing a new History entry. */
|
|
173
|
+
const push = (to) => Effect.flatMap(Router, (router) => router.navigate(to));
|
|
174
|
+
/** Navigates to a raw `path + search` string, replacing the current History entry. */
|
|
175
|
+
const replace = (to) => Effect.flatMap(Router, (router) => router.navigate(to, { replace: true }));
|
|
176
|
+
/** Steps one entry back in History (`history.go(-1)`); the `popstate` handler resyncs. */
|
|
177
|
+
const back = () => Effect.sync(() => window.history.go(-1));
|
|
178
|
+
/** Steps one entry forward in History (`history.go(1)`); the `popstate` handler resyncs. */
|
|
179
|
+
const forward = () => Effect.sync(() => window.history.go(1));
|
|
180
|
+
/** Encodes an already-encoded query record into a key-sorted search string (mirrors `href`). */
|
|
181
|
+
function encodeSearch(encoded) {
|
|
182
|
+
const params = new URLSearchParams();
|
|
183
|
+
for (const key of Object.keys(encoded).sort()) {
|
|
184
|
+
const value = encoded[key];
|
|
185
|
+
if (value !== void 0 && value !== null) params.append(key, String(value));
|
|
186
|
+
}
|
|
187
|
+
return params.toString();
|
|
188
|
+
}
|
|
189
|
+
/** The path portion (without the search) of a normalized match URL. */
|
|
190
|
+
function pathOf(url) {
|
|
191
|
+
const qIndex = url.indexOf("?");
|
|
192
|
+
return qIndex === -1 ? url : url.slice(0, qIndex);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Navigates within the current route, transforming its decoded query and
|
|
196
|
+
* re-encoding through the matched leaf's `querySchema`. The path is preserved (so
|
|
197
|
+
* the leaf stays mounted; reactive `Router.queryStream` readers update in place).
|
|
198
|
+
* A no-op when no route is currently matched.
|
|
199
|
+
*/
|
|
200
|
+
function applyQuery(transform, options) {
|
|
201
|
+
return Effect.gen(function* () {
|
|
202
|
+
const router = yield* Router;
|
|
203
|
+
const match = yield* router.currentMatch.get;
|
|
204
|
+
if (match._tag !== "Matched") return;
|
|
205
|
+
const next = transform(match.query);
|
|
206
|
+
const search = encodeSearch(Schema.encodeUnknownSync(match.leaf.querySchema)(next));
|
|
207
|
+
const path = pathOf(match.url);
|
|
208
|
+
yield* router.navigate(search.length > 0 ? `${path}?${search}` : path, options);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Replaces the current route's query entirely with `query` (re-encoded through the
|
|
213
|
+
* matched leaf's `querySchema`), keeping the path. Pass `{}` to clear the query.
|
|
214
|
+
*/
|
|
215
|
+
const setQuery = (query, options) => applyQuery(() => query, options);
|
|
216
|
+
/**
|
|
217
|
+
* Merges `partial` into the current route's decoded query (re-encoded through the
|
|
218
|
+
* matched leaf's `querySchema`), keeping the path and any unspecified query fields.
|
|
219
|
+
*/
|
|
220
|
+
const patchQuery = (partial, options) => applyQuery((current) => ({
|
|
221
|
+
...current,
|
|
222
|
+
...partial
|
|
223
|
+
}), options);
|
|
224
|
+
//#endregion
|
|
225
|
+
export { Router, RouterApp, RouterLive, outletNode as RouterOutlet, outletNode, back, forward, installLinkInterceptor, navigate, patchQuery, push, replace, setQuery };
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { s as leafRegistry } from "./outlet-CXgDkheJ.js";
|
|
2
|
+
import { Either, Option, Schema } from "effect";
|
|
3
|
+
//#region src/matcher.ts
|
|
4
|
+
/** Escapes a literal path segment for inclusion in a `RegExp`. */
|
|
5
|
+
function escapeRegex(literal) {
|
|
6
|
+
return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
7
|
+
}
|
|
8
|
+
/** The `:name` placeholder names in a path template, in order. */
|
|
9
|
+
function paramNamesOf(pattern) {
|
|
10
|
+
return pattern.split("/").filter((s) => s.startsWith(":")).map((s) => s.slice(1));
|
|
11
|
+
}
|
|
12
|
+
/** Number of `:param` segments in a pattern; fewer params = more specific (M6). */
|
|
13
|
+
function paramCount(pattern) {
|
|
14
|
+
return pattern.split("/").filter((s) => s.startsWith(":")).length;
|
|
15
|
+
}
|
|
16
|
+
/** Builds a regex matching a full pattern, tolerating an optional trailing slash (M3). */
|
|
17
|
+
function patternToRegex(pattern) {
|
|
18
|
+
const body = pattern.split("/").filter((s) => s.length > 0).map((part) => part.startsWith(":") ? "([^/]+)" : escapeRegex(part)).join("/");
|
|
19
|
+
return new RegExp(body.length === 0 ? "^/?$" : `^/${body}/?$`);
|
|
20
|
+
}
|
|
21
|
+
/** Empty-struct fallback for an endpoint with no declared path/query schema. */
|
|
22
|
+
const emptySchema = Schema.Struct({});
|
|
23
|
+
/**
|
|
24
|
+
* Memoizes the compiled matcher entries per {@link RouterDef} so the regexes are
|
|
25
|
+
* built and sorted once, not on every `match()` call (which is on the hot path:
|
|
26
|
+
* every navigation, every link-interceptor click, and once per server request).
|
|
27
|
+
*/
|
|
28
|
+
const matchersCache = /* @__PURE__ */ new WeakMap();
|
|
29
|
+
/**
|
|
30
|
+
* Precompiles a {@link RouterDef} into ordered matcher entries (memoized per
|
|
31
|
+
* `RouterDef`). The patterns and path/query schemas are read from the authoritative
|
|
32
|
+
* `def.httpApi` `"pages"` endpoints — the single source of truth the server dispatch
|
|
33
|
+
* also reads — and each entry's render metadata leaf is resolved from `def.compiled`
|
|
34
|
+
* by endpoint id. Matching stays local (SPA URL→leaf); see the refactor plan's
|
|
35
|
+
* _Feasibility constraint_.
|
|
36
|
+
*
|
|
37
|
+
* Entries are sorted most-specific first (fewer params, then longer pattern) so a
|
|
38
|
+
* static segment wins over a param segment at the same position (M6).
|
|
39
|
+
*
|
|
40
|
+
* Note: the specificity order is a global heuristic (param count, then length).
|
|
41
|
+
* It resolves the common "static beats param at the same position" case, but two
|
|
42
|
+
* patterns with the same param count and length (e.g. `/a/:b/c` vs `/a/x/:d`)
|
|
43
|
+
* fall back to endpoint order.
|
|
44
|
+
*/
|
|
45
|
+
function compileMatchers(def) {
|
|
46
|
+
const cached = matchersCache.get(def);
|
|
47
|
+
if (cached !== void 0) return cached;
|
|
48
|
+
const leafById = /* @__PURE__ */ new Map();
|
|
49
|
+
for (const leaf of def.compiled.leaves) leafById.set(leaf.id, leaf);
|
|
50
|
+
const endpoints = def.httpApi.groups["pages"]?.endpoints ?? {};
|
|
51
|
+
const entries = [];
|
|
52
|
+
for (const endpoint of Object.values(endpoints)) {
|
|
53
|
+
const leaf = leafById.get(endpoint.name);
|
|
54
|
+
if (leaf === void 0) continue;
|
|
55
|
+
entries.push({
|
|
56
|
+
leaf,
|
|
57
|
+
regex: patternToRegex(endpoint.path),
|
|
58
|
+
paramNames: paramNamesOf(endpoint.path),
|
|
59
|
+
pathSchema: Option.getOrElse(endpoint.pathSchema, () => emptySchema),
|
|
60
|
+
querySchema: Option.getOrElse(endpoint.urlParamsSchema, () => emptySchema)
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
entries.sort((a, b) => {
|
|
64
|
+
const pc = paramCount(a.leaf.fullPathPattern) - paramCount(b.leaf.fullPathPattern);
|
|
65
|
+
if (pc !== 0) return pc;
|
|
66
|
+
return b.leaf.fullPathPattern.length - a.leaf.fullPathPattern.length;
|
|
67
|
+
});
|
|
68
|
+
matchersCache.set(def, entries);
|
|
69
|
+
return entries;
|
|
70
|
+
}
|
|
71
|
+
/** Splits a request URL into its normalized path and raw query string. */
|
|
72
|
+
function splitUrl(url) {
|
|
73
|
+
const hashIndex = url.indexOf("#");
|
|
74
|
+
const withoutHash = hashIndex === -1 ? url : url.slice(0, hashIndex);
|
|
75
|
+
const qIndex = withoutHash.indexOf("?");
|
|
76
|
+
const rawPath = qIndex === -1 ? withoutHash : withoutHash.slice(0, qIndex);
|
|
77
|
+
const search = qIndex === -1 ? "" : withoutHash.slice(qIndex + 1);
|
|
78
|
+
let path = rawPath.length === 0 ? "/" : rawPath;
|
|
79
|
+
if (!path.startsWith("/")) path = `/${path}`;
|
|
80
|
+
if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
|
|
81
|
+
return {
|
|
82
|
+
path,
|
|
83
|
+
search
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/** Parses a raw query string into a flat record (last value wins on repeat). */
|
|
87
|
+
function parseQuery(search) {
|
|
88
|
+
const record = {};
|
|
89
|
+
if (search.length === 0) return record;
|
|
90
|
+
for (const [key, value] of new URLSearchParams(search)) record[key] = value;
|
|
91
|
+
return record;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Matches a request URL against a {@link RouterDef} (M1–M7). Returns the decoded
|
|
95
|
+
* `Matched` leaf, or `NotFound` when nothing matches or a path/query decode fails
|
|
96
|
+
* (decode failure is treated as no-match, not an error). Patterns and schemas come
|
|
97
|
+
* from `def.httpApi` via {@link compileMatchers}.
|
|
98
|
+
*/
|
|
99
|
+
function match(def, url) {
|
|
100
|
+
const entries = compileMatchers(def);
|
|
101
|
+
const { path, search } = splitUrl(url);
|
|
102
|
+
const normalizedUrl = search.length === 0 ? path : `${path}?${search}`;
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
const m = entry.regex.exec(path);
|
|
105
|
+
if (m === null) continue;
|
|
106
|
+
const rawParams = {};
|
|
107
|
+
entry.paramNames.forEach((name, i) => {
|
|
108
|
+
const raw = m[i + 1];
|
|
109
|
+
if (raw !== void 0) rawParams[name] = decodeURIComponent(raw);
|
|
110
|
+
});
|
|
111
|
+
const decodedPath = Schema.decodeUnknownEither(entry.pathSchema)(rawParams);
|
|
112
|
+
if (Either.isLeft(decodedPath)) continue;
|
|
113
|
+
const decodedQuery = Schema.decodeUnknownEither(entry.querySchema)(parseQuery(search));
|
|
114
|
+
if (Either.isLeft(decodedQuery)) continue;
|
|
115
|
+
return {
|
|
116
|
+
_tag: "Matched",
|
|
117
|
+
leaf: entry.leaf,
|
|
118
|
+
path: decodedPath.right,
|
|
119
|
+
query: decodedQuery.right,
|
|
120
|
+
url: normalizedUrl
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
_tag: "NotFound",
|
|
125
|
+
url: normalizedUrl
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
//#endregion
|
|
129
|
+
//#region src/href.ts
|
|
130
|
+
/**
|
|
131
|
+
* Builds a type-safe URL for a leaf route reference (the value returned by
|
|
132
|
+
* {@link route}). Path params are encoded into the pattern and query values are
|
|
133
|
+
* encoded into a key-sorted search string (H1–H4). Round-trips with `match`.
|
|
134
|
+
*
|
|
135
|
+
* The leaf must belong to a tree that has been sealed with `Router.router()`
|
|
136
|
+
* (which stamps the leaf registry); otherwise an error is thrown.
|
|
137
|
+
*
|
|
138
|
+
* @example
|
|
139
|
+
* ```ts
|
|
140
|
+
* const userRoute = Router.route("users/:id", {
|
|
141
|
+
* path: { id: Schema.NumberFromString },
|
|
142
|
+
* component: …,
|
|
143
|
+
* });
|
|
144
|
+
* Router.router(Router.layout({ component: … }, [userRoute]), { notFound });
|
|
145
|
+
* href(userRoute, { path: { id: 42 } }); // "/users/42"
|
|
146
|
+
* ```
|
|
147
|
+
*/
|
|
148
|
+
function href(ref, ...args) {
|
|
149
|
+
const leaf = leafRegistry.get(ref);
|
|
150
|
+
if (leaf === void 0) throw new Error("href: route has not been compiled. Seal the tree with Router.router() before calling href().");
|
|
151
|
+
const { path = {}, query = {} } = args[0] ?? {};
|
|
152
|
+
const encodedPath = Schema.encodeUnknownSync(leaf.pathSchema)(path);
|
|
153
|
+
let url = leaf.fullPathPattern.replace(/:([A-Za-z0-9_]+)/g, (_match, name) => encodeURIComponent(String(encodedPath[name])));
|
|
154
|
+
const encodedQuery = Schema.encodeUnknownSync(leaf.querySchema)(query);
|
|
155
|
+
const params = new URLSearchParams();
|
|
156
|
+
for (const key of Object.keys(encodedQuery).sort()) {
|
|
157
|
+
const value = encodedQuery[key];
|
|
158
|
+
if (value !== void 0 && value !== null) params.append(key, String(value));
|
|
159
|
+
}
|
|
160
|
+
const search = params.toString();
|
|
161
|
+
if (search.length > 0) url = `${url}?${search}`;
|
|
162
|
+
return url;
|
|
163
|
+
}
|
|
164
|
+
//#endregion
|
|
165
|
+
export { compileMatchers as n, match as r, href as t };
|
package/dist/index.js
CHANGED
|
@@ -1 +1,3 @@
|
|
|
1
|
-
import{a as
|
|
1
|
+
import { a as buildHttpApi, c as RouterNotFound, d as notFound, l as RouterParamsError, n as outletNode, o as compile, r as Router, s as leafRegistry, t as RouterApp, u as isRouterNotFound } from "./outlet-CXgDkheJ.js";
|
|
2
|
+
import { n as compileMatchers, r as match, t as href } from "./href-CSRbOQov.js";
|
|
3
|
+
export { Router, RouterApp, RouterNotFound, RouterParamsError, buildHttpApi, compile, compileMatchers, href, isRouterNotFound, leafRegistry, match, notFound, outletNode };
|
|
@@ -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 };
|
package/dist/server/index.js
CHANGED
|
@@ -1 +1,212 @@
|
|
|
1
|
-
import{c as
|
|
1
|
+
import { c as RouterNotFound, n as outletNode, r as Router, u as isRouterNotFound } from "../outlet-CXgDkheJ.js";
|
|
2
|
+
import { HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpServer, HttpServerResponse } from "@effect/platform";
|
|
3
|
+
import { Cause, Effect, Exit, Layer, Option, Schema, Scope, Stream, Subscribable } from "effect";
|
|
4
|
+
import { AppRpcClientTag } from "@weftui/core";
|
|
5
|
+
import { SuspenseFailureHandlerTag, renderToHydratableShell, renderToStringHydratable } from "@weftui/dom/server";
|
|
6
|
+
import { RpcSerialization, RpcServer, RpcTest } from "@effect/rpc";
|
|
7
|
+
//#region src/server/router-server.ts
|
|
8
|
+
let RouterServer;
|
|
9
|
+
(function(_RouterServer) {
|
|
10
|
+
/** Path the in-process rpc web handler claims; mirrors `RouterLive`'s client URL. */
|
|
11
|
+
const RPC_PATH = "/_eui/rpc";
|
|
12
|
+
/** `text/html` response options at a given status. */
|
|
13
|
+
function htmlResponse(html, status) {
|
|
14
|
+
return HttpServerResponse.text(`<!DOCTYPE html>\n${html}`, {
|
|
15
|
+
status,
|
|
16
|
+
contentType: "text/html; charset=utf-8"
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
/** Builds the fixed per-request `Router` from an already-resolved match; `navigate` is a no-op on the server. */
|
|
20
|
+
function serverRouter(matched) {
|
|
21
|
+
const idle = { _tag: "Idle" };
|
|
22
|
+
return Router.of({
|
|
23
|
+
currentMatch: Subscribable.make({
|
|
24
|
+
get: Effect.succeed(matched),
|
|
25
|
+
changes: Stream.make(matched)
|
|
26
|
+
}),
|
|
27
|
+
navigate: () => Effect.void,
|
|
28
|
+
httpApiClient: Option.none(),
|
|
29
|
+
navigating: Subscribable.make({
|
|
30
|
+
get: Effect.succeed(idle),
|
|
31
|
+
changes: Stream.make(idle)
|
|
32
|
+
})
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* In-process {@link AppRpcClientTag} Layer over the app's handler Layer
|
|
37
|
+
* ({@link RpcTest.makeClient}, flat, no protocol/serialization). SSR
|
|
38
|
+
* `Boundary.rpc` resolution calls `call(tag, payload())` against this — the rpc
|
|
39
|
+
* runs in-process, never over the network.
|
|
40
|
+
*
|
|
41
|
+
* The render path requires the tag unconditionally, so with no `rpc` configured
|
|
42
|
+
* a stub is provided whose `call` fails descriptively — a `Boundary.rpc` in an
|
|
43
|
+
* rpc-less app surfaces the misconfiguration instead of dying opaquely.
|
|
44
|
+
*/
|
|
45
|
+
function appRpcClientLayer(rpc) {
|
|
46
|
+
if (rpc === void 0) return Layer.succeed(AppRpcClientTag, AppRpcClientTag.of({ call: (tag) => Effect.fail(/* @__PURE__ */ new Error(`Boundary.rpc "${tag}" cannot resolve: no \`rpc\` option was passed to RouterServer`)) }));
|
|
47
|
+
return Layer.scoped(AppRpcClientTag, Effect.map(RpcTest.makeClient(rpc.group, { flatten: true }), (flat) => AppRpcClientTag.of({ call: (tag, payload) => flat(tag, payload) }))).pipe(Layer.provide(rpc.handlers));
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Renders the document shell — with `app` spliced via `Router.Outlet` — to a
|
|
51
|
+
* hydratable HTML string. The whole tree (shell + every route/layout leaf) drains
|
|
52
|
+
* in this one `renderToStringHydratable` context, so the app-wide `options.context`
|
|
53
|
+
* Layer provided here reaches the leaves too (the render-time provide seam). No
|
|
54
|
+
* context ⇒ `Layer.empty`, a no-op.
|
|
55
|
+
*/
|
|
56
|
+
function renderDocument(options, app, router) {
|
|
57
|
+
return renderToStringHydratable(Effect.provideService(options.document({}), Router.Outlet, app)).pipe(Effect.provideService(Router, router), Effect.provide(appRpcClientLayer(options.rpc)), Effect.provide(options.context ?? Layer.empty));
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Renders the configured `notFound` page **directly** in the shell (no nested
|
|
61
|
+
* outlet, no reactive-region markers) at `status`. Mirrors the client's internal
|
|
62
|
+
* not-found boundary fallback — which replaces the whole outlet subtree — so the
|
|
63
|
+
* page-raised-404 HTML aligns for hydration.
|
|
64
|
+
*/
|
|
65
|
+
function renderNotFoundDirect(def, options, url, status) {
|
|
66
|
+
const router = serverRouter({
|
|
67
|
+
_tag: "NotFound",
|
|
68
|
+
url
|
|
69
|
+
});
|
|
70
|
+
return renderDocument(options, def.compiled.notFound(), router).pipe(Effect.map((html) => htmlResponse(html, status)));
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Renders the no-match case: the bare {@link outletNode} with a `NotFound` match,
|
|
74
|
+
* so the `notFound` page renders **inside** the level-0 reactive region (markers
|
|
75
|
+
* present) — matching what the client outlet produces for an unmatched URL — at
|
|
76
|
+
* HTTP 404.
|
|
77
|
+
*/
|
|
78
|
+
function renderNoMatch(def, options, url) {
|
|
79
|
+
const router = serverRouter({
|
|
80
|
+
_tag: "NotFound",
|
|
81
|
+
url
|
|
82
|
+
});
|
|
83
|
+
return renderDocument(options, outletNode(def), router).pipe(Effect.map((html) => htmlResponse(html, 404)));
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Renders one matched leaf: the bare {@link outletNode} with the platform-decoded
|
|
87
|
+
* match, replying `text/html` at 200. A page that raises `RouterNotFound` is
|
|
88
|
+
* caught here (the server omits `RouterApp`'s boundary so the failure surfaces)
|
|
89
|
+
* and re-rendered as the not-found page at 404 via {@link renderNotFoundDirect}.
|
|
90
|
+
*/
|
|
91
|
+
function renderLeaf(def, options, matched) {
|
|
92
|
+
const router = serverRouter(matched);
|
|
93
|
+
return renderDocument(options, outletNode(def), router).pipe(Effect.map((html) => htmlResponse(html, 200)), Effect.catchIf(isRouterNotFound, () => renderNotFoundDirect(def, options, matched.url, 404)));
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The streaming pass's {@link SuspenseFailureHandlerTag} (SW1 late-404 row):
|
|
97
|
+
* a `RouterNotFound` escaping `Boundary.suspend` children after the shell has
|
|
98
|
+
* flushed is substituted with the router's `notFound` page plus a
|
|
99
|
+
* client-injected `<meta name="robots" content="noindex">` (Next.js soft-404
|
|
100
|
+
* parity). Any other cause keeps the dom swallow default (AC-ST8).
|
|
101
|
+
*
|
|
102
|
+
* The substitute also carries the `Schema`-encoded `RouterNotFound` as
|
|
103
|
+
* `failureReplay` (SW8), so the patch is the failure-replay variant
|
|
104
|
+
* (`streaming-shell.specs.md` AC-FH7) and a later `hydrate` replays the
|
|
105
|
+
* failure into `RouterApp`'s boundary instead of mismatching.
|
|
106
|
+
*/
|
|
107
|
+
function notFoundSuspenseHandler(def) {
|
|
108
|
+
return { handle: (cause) => {
|
|
109
|
+
const failure = Cause.failureOption(cause);
|
|
110
|
+
return Option.isSome(failure) && isRouterNotFound(failure.value) ? Option.some({
|
|
111
|
+
content: def.compiled.notFound(),
|
|
112
|
+
markNoindex: true,
|
|
113
|
+
failureReplay: Schema.encodeSync(RouterNotFound)(failure.value)
|
|
114
|
+
}) : Option.none();
|
|
115
|
+
} };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Streaming counterpart of {@link renderLeaf} (SW1 … SW6): renders the
|
|
119
|
+
* document via the dom shell-split API, decides the status off the buffered
|
|
120
|
+
* shell, then streams `<!DOCTYPE html>\n` + shell as the first chunk and the
|
|
121
|
+
* Suspense patches after it. A `RouterNotFound` raised during the shell walk
|
|
122
|
+
* is caught (nothing flushed yet) and re-rendered buffered at 404.
|
|
123
|
+
*/
|
|
124
|
+
function renderLeafStreaming(def, options, matched) {
|
|
125
|
+
const router = serverRouter(matched);
|
|
126
|
+
const app = outletNode(def);
|
|
127
|
+
return Effect.gen(function* () {
|
|
128
|
+
const scope = yield* Scope.make();
|
|
129
|
+
const { shell, patches } = yield* renderToHydratableShell(Effect.provideService(options.document({}), Router.Outlet, app)).pipe(Effect.provideService(Router, router), Effect.provideService(SuspenseFailureHandlerTag, notFoundSuspenseHandler(def)), Effect.provide(appRpcClientLayer(options.rpc)), Effect.provide(options.context ?? Layer.empty), Scope.extend(scope), Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))));
|
|
130
|
+
const body = Stream.make(`<!DOCTYPE html>\n${shell}`).pipe(Stream.concat(patches), Stream.ensuring(Scope.close(scope, Exit.void)), Stream.encodeText);
|
|
131
|
+
return HttpServerResponse.stream(body, {
|
|
132
|
+
status: 200,
|
|
133
|
+
contentType: "text/html; charset=utf-8"
|
|
134
|
+
});
|
|
135
|
+
}).pipe(Effect.catchIf(isRouterNotFound, () => renderNotFoundDirect(def, options, matched.url, 404)));
|
|
136
|
+
}
|
|
137
|
+
/** Memoized platform web handlers, keyed by `(def, document)`. */
|
|
138
|
+
const handlerCache = /* @__PURE__ */ new WeakMap();
|
|
139
|
+
/** Streaming handlers are memoized separately from the buffered ones. */
|
|
140
|
+
const streamingHandlerCache = /* @__PURE__ */ new WeakMap();
|
|
141
|
+
/**
|
|
142
|
+
* Builds (and memoizes) the platform `(Request) => Promise<Response>` handler for
|
|
143
|
+
* `def`. Dispatch runs through a **server-local** `HttpApi`: `def.httpApi`
|
|
144
|
+
* (pristine — the client and the spec read it) extended with a second `"fallback"`
|
|
145
|
+
* group holding one catch-all `"*"` endpoint. Platform owns matching: a request
|
|
146
|
+
* routes to the specific leaf endpoint, or — when nothing matches — to the
|
|
147
|
+
* catch-all, which renders the configured not-found page at 404. (Platform's own
|
|
148
|
+
* unmatched path resolves a default empty 404 before any response hook can rewrite
|
|
149
|
+
* it, so the catch-all is the route that keeps no-match rendering ours.)
|
|
150
|
+
*/
|
|
151
|
+
function webHandlerWith(def, options, cache, leafRenderer) {
|
|
152
|
+
const perDef = cache.get(def) ?? /* @__PURE__ */ new WeakMap();
|
|
153
|
+
cache.set(def, perDef);
|
|
154
|
+
const cached = perDef.get(options.document);
|
|
155
|
+
if (cached !== void 0) return cached;
|
|
156
|
+
const leaves = def.compiled.leaves;
|
|
157
|
+
const builder = HttpApiBuilder;
|
|
158
|
+
const fallbackGroup = HttpApiGroup.make("fallback").add(HttpApiEndpoint.get("catchAll", "*").addSuccess(Schema.String));
|
|
159
|
+
const api = def.httpApi.add(fallbackGroup);
|
|
160
|
+
const pagesLayer = builder.group(api, "pages", (handlers) => leaves.reduce((h, leaf) => h.handle(leaf.id, (request) => leafRenderer(def, options, {
|
|
161
|
+
_tag: "Matched",
|
|
162
|
+
leaf,
|
|
163
|
+
path: request.path,
|
|
164
|
+
query: request.urlParams,
|
|
165
|
+
url: request.request.url
|
|
166
|
+
})), handlers));
|
|
167
|
+
const fallbackLayer = builder.group(api, "fallback", (handlers) => handlers.handle("catchAll", (request) => renderNoMatch(def, options, request.request.url)));
|
|
168
|
+
const apiLayer = builder.api(api).pipe(Layer.provide(Layer.mergeAll(pagesLayer, fallbackLayer)));
|
|
169
|
+
const { handler: pageHandler } = HttpApiBuilder.toWebHandler(Layer.mergeAll(apiLayer, HttpServer.layerContext));
|
|
170
|
+
const rpc = options.rpc;
|
|
171
|
+
let handler = pageHandler;
|
|
172
|
+
if (rpc !== void 0) {
|
|
173
|
+
const { handler: rpcHandler } = RpcServer.toWebHandler(rpc.group, { layer: Layer.mergeAll(rpc.handlers, RpcSerialization.layerJson) });
|
|
174
|
+
handler = (request) => new URL(request.url).pathname === RPC_PATH ? rpcHandler(request) : pageHandler(request);
|
|
175
|
+
}
|
|
176
|
+
perDef.set(options.document, handler);
|
|
177
|
+
return handler;
|
|
178
|
+
}
|
|
179
|
+
/** The buffered platform web handler (S2a). */
|
|
180
|
+
function webHandler(def, options) {
|
|
181
|
+
return webHandlerWith(def, options, handlerCache, renderLeaf);
|
|
182
|
+
}
|
|
183
|
+
/** Coerces a possibly-relative URL/path into an absolute URL for a synthetic `Request`. */
|
|
184
|
+
function absoluteUrl(url) {
|
|
185
|
+
if (url.startsWith("http://") || url.startsWith("https://")) return url;
|
|
186
|
+
return `http://localhost${url.startsWith("/") ? url : `/${url}`}`;
|
|
187
|
+
}
|
|
188
|
+
function render(def, options) {
|
|
189
|
+
const opts = options;
|
|
190
|
+
return Effect.tryPromise({
|
|
191
|
+
try: async () => {
|
|
192
|
+
const response = await webHandler(def, opts)(new Request(absoluteUrl(opts.url)));
|
|
193
|
+
return {
|
|
194
|
+
html: await response.text(),
|
|
195
|
+
status: response.status
|
|
196
|
+
};
|
|
197
|
+
},
|
|
198
|
+
catch: (error) => error instanceof Error ? error : new Error(String(error))
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
_RouterServer.render = render;
|
|
202
|
+
function toWebHandler(def, options) {
|
|
203
|
+
return webHandler(def, options);
|
|
204
|
+
}
|
|
205
|
+
_RouterServer.toWebHandler = toWebHandler;
|
|
206
|
+
function toStreamingWebHandler(def, options) {
|
|
207
|
+
return webHandlerWith(def, options, streamingHandlerCache, renderLeafStreaming);
|
|
208
|
+
}
|
|
209
|
+
_RouterServer.toStreamingWebHandler = toStreamingWebHandler;
|
|
210
|
+
})(RouterServer || (RouterServer = {}));
|
|
211
|
+
//#endregion
|
|
212
|
+
export { RouterServer };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weftui/router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "Universal nested router for Weft",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Stef van Wijchen",
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@effect/platform": "^0.96.1",
|
|
34
34
|
"@effect/rpc": "^0.75.1",
|
|
35
|
-
"@weftui/core": "0.
|
|
36
|
-
"@weftui/dom": "0.
|
|
35
|
+
"@weftui/core": "0.23.0",
|
|
36
|
+
"@weftui/dom": "0.23.0"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@types/jsdom": "^28.0.3",
|
|
@@ -42,7 +42,8 @@
|
|
|
42
42
|
"jsdom": "^29.1.1",
|
|
43
43
|
"tsx": "^4.22.4",
|
|
44
44
|
"typescript": "^6.0.3",
|
|
45
|
-
"vite
|
|
45
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@0.2.2",
|
|
46
|
+
"vite-plus": "0.2.2"
|
|
46
47
|
},
|
|
47
48
|
"peerDependencies": {
|
|
48
49
|
"effect": "^3.21"
|
package/dist/href-BgbDo3hx.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{s as e}from"./outlet-BS31W71_.js";import{Either as t,Option as n,Schema as r}from"effect";function i(e){return e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`)}function a(e){return e.split(`/`).filter(e=>e.startsWith(`:`)).map(e=>e.slice(1))}function o(e){return e.split(`/`).filter(e=>e.startsWith(`:`)).length}function s(e){let t=e.split(`/`).filter(e=>e.length>0).map(e=>e.startsWith(`:`)?`([^/]+)`:i(e)).join(`/`);return RegExp(t.length===0?`^/?$`:`^/${t}/?$`)}const c=r.Struct({}),l=new WeakMap;function u(e){let t=l.get(e);if(t!==void 0)return t;let r=new Map;for(let t of e.compiled.leaves)r.set(t.id,t);let i=e.httpApi.groups.pages?.endpoints??{},u=[];for(let e of Object.values(i)){let t=r.get(e.name);t!==void 0&&u.push({leaf:t,regex:s(e.path),paramNames:a(e.path),pathSchema:n.getOrElse(e.pathSchema,()=>c),querySchema:n.getOrElse(e.urlParamsSchema,()=>c)})}return u.sort((e,t)=>{let n=o(e.leaf.fullPathPattern)-o(t.leaf.fullPathPattern);return n===0?t.leaf.fullPathPattern.length-e.leaf.fullPathPattern.length:n}),l.set(e,u),u}function d(e){let t=e.indexOf(`#`),n=t===-1?e:e.slice(0,t),r=n.indexOf(`?`),i=r===-1?n:n.slice(0,r),a=r===-1?``:n.slice(r+1),o=i.length===0?`/`:i;return o.startsWith(`/`)||(o=`/${o}`),o.length>1&&o.endsWith(`/`)&&(o=o.slice(0,-1)),{path:o,search:a}}function f(e){let t={};if(e.length===0)return t;for(let[n,r]of new URLSearchParams(e))t[n]=r;return t}function p(e,n){let i=u(e),{path:a,search:o}=d(n),s=o.length===0?a:`${a}?${o}`;for(let e of i){let n=e.regex.exec(a);if(n===null)continue;let i={};e.paramNames.forEach((e,t)=>{let r=n[t+1];r!==void 0&&(i[e]=decodeURIComponent(r))});let c=r.decodeUnknownEither(e.pathSchema)(i);if(t.isLeft(c))continue;let l=r.decodeUnknownEither(e.querySchema)(f(o));if(!t.isLeft(l))return{_tag:`Matched`,leaf:e.leaf,path:c.right,query:l.right,url:s}}return{_tag:`NotFound`,url:s}}function m(t,...n){let i=e.get(t);if(i===void 0)throw Error(`href: route has not been compiled. Seal the tree with Router.router() before calling href().`);let{path:a={},query:o={}}=n[0]??{},s=r.encodeUnknownSync(i.pathSchema)(a),c=i.fullPathPattern.replace(/:([A-Za-z0-9_]+)/g,(e,t)=>encodeURIComponent(String(s[t]))),l=r.encodeUnknownSync(i.querySchema)(o),u=new URLSearchParams;for(let e of Object.keys(l).sort()){let t=l[e];t!=null&&u.append(e,String(t))}let d=u.toString();return d.length>0&&(c=`${c}?${d}`),c}export{u as n,p as r,m as t};
|
package/dist/outlet-BS31W71_.js
DELETED
|
Binary file
|