@weftui/router 0.22.0 → 0.23.1

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.
@@ -1 +1,225 @@
1
- import{i as e,n as t,r as n,t as r}from"../outlet-BS31W71_.js";import{r as i,t as a}from"../href-BgbDo3hx.js";import{FetchHttpClient as o,HttpApiClient as s}from"@effect/platform";import{Context as c,Effect as l,Layer as u,Option as d,Runtime as f,Schema as p,Stream as m,Subscribable as h,SubscriptionRef as g}from"effect";import{AppRpcClientTag as _}from"@weftui/core";function v(e,t){return l.gen(function*(){let n=yield*l.runtime(),r=r=>{if(r.defaultPrevented||r.button!==0||r.metaKey||r.ctrlKey||r.shiftKey||r.altKey)return;let a=r.target,o=a instanceof Element?a.closest(`a`):null;if(o===null)return;let s=o.getAttribute(`target`);if(o.hasAttribute(`download`)||s!==null&&s!==`_self`||o.getAttribute(`rel`)===`external`)return;let c=o.getAttribute(`href`);if(c===null||c.length===0)return;let l;try{l=new URL(c,window.location.href)}catch{return}if(l.origin!==window.location.origin)return;let u=`${l.pathname}${l.search}`;u!==`${window.location.pathname}${window.location.search}`&&i(e,u)._tag===`Matched`&&(r.preventDefault(),f.runFork(n)(t(u)))};yield*l.acquireRelease(l.sync(()=>document.addEventListener(`click`,r)),()=>l.sync(()=>document.removeEventListener(`click`,r)))})}function y(){return`${window.location.pathname}${window.location.search}`}function b(e){let t=new URL(e,window.location.href);return`${t.pathname}${t.search}`}function x(t,r={}){let a=u.scopedContext(l.gen(function*(){let a=yield*g.make(y()),p=yield*g.make({_tag:`Idle`}),x=yield*l.runtime(),S=yield*s.make(t.httpApi,{baseUrl:r.baseUrl??window.location.origin}).pipe(l.provide(o.layer)),C=0,w=t=>t._tag===`Matched`?[t.leaf.component,...t.leaf.layoutChain.map(e=>e.component)].map(e).filter(e=>e!==void 0):[],T=(e,t)=>l.sync(()=>{t?window.history.replaceState(null,``,e):window.history.pushState(null,``,e)}),E=(e,n,r)=>l.gen(function*(){let o=w(i(t,e));if(o.length===0){n&&(yield*T(e,r)),yield*g.set(a,e);return}let s=++C;yield*g.set(p,{_tag:`Navigating`,to:e}),yield*l.promise(()=>Promise.all(o.map(e=>e()))).pipe(l.onError(()=>s===C?g.set(p,{_tag:`Idle`}):l.void)),s===C&&(n&&(yield*T(e,r)),yield*g.set(a,e),yield*g.set(p,{_tag:`Idle`}))}),D=(e,t)=>E(b(e),!0,t?.replace===!0),O=()=>{f.runFork(x)(E(y(),!1,!1))};yield*l.acquireRelease(l.sync(()=>window.addEventListener(`popstate`,O)),()=>l.sync(()=>window.removeEventListener(`popstate`,O))),yield*v(t,D);let k=h.make({get:l.map(g.get(a),e=>i(t,e)),changes:m.map(a.changes,e=>i(t,e))}),A=r.rpc,j;if(A===void 0)j=_.of({call:e=>l.fail(Error(`Boundary.rpc "${e}" cannot resolve: no \`rpc\` option was passed to RouterLive`))});else{let{RpcClient:e,RpcSerialization:t}=yield*l.promise(()=>import(`@effect/rpc`)),n=String(r.baseUrl??window.location.origin).replace(/\/$/,``),i=yield*e.make(A.group,{flatten:!0}).pipe(l.provide(e.layerProtocolHttp({url:`${n}/_eui/rpc`}).pipe(u.provide(u.mergeAll(o.layer,t.layerJson)))));j=_.of({call:(e,t)=>i(e,t)})}let M=n.of({currentMatch:k,navigate:D,httpApiClient:d.some(S),navigating:p});return c.make(n,M).pipe(c.add(_,j))})),p=r.context;return p===void 0?a:u.merge(a,p)}function S(e,...t){let[r,i]=t,o=a(e,r);return l.flatMap(n,e=>e.navigate(o,i))}const C=e=>l.flatMap(n,t=>t.navigate(e)),w=e=>l.flatMap(n,t=>t.navigate(e,{replace:!0})),T=()=>l.sync(()=>window.history.go(-1)),E=()=>l.sync(()=>window.history.go(1));function D(e){let t=new URLSearchParams;for(let n of Object.keys(e).sort()){let r=e[n];r!=null&&t.append(n,String(r))}return t.toString()}function O(e){let t=e.indexOf(`?`);return t===-1?e:e.slice(0,t)}function k(e,t){return l.gen(function*(){let r=yield*n,i=yield*r.currentMatch.get;if(i._tag!==`Matched`)return;let a=e(i.query),o=D(p.encodeUnknownSync(i.leaf.querySchema)(a)),s=O(i.url);yield*r.navigate(o.length>0?`${s}?${o}`:s,t)})}const A=(e,t)=>k(()=>e,t),j=(e,t)=>k(t=>({...t,...e}),t);export{n as Router,r as RouterApp,x as RouterLive,t as RouterOutlet,t as outletNode,T as back,E as forward,v as installLinkInterceptor,S as navigate,j as patchQuery,C as push,w as replace,A as setQuery};
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 e,c as t,d as n,l as r,n as i,o as a,r as o,s,t as c,u as l}from"./outlet-BS31W71_.js";import{n as u,r as d,t as f}from"./href-BgbDo3hx.js";export{o as Router,c as RouterApp,t as RouterNotFound,r as RouterParamsError,e as buildHttpApi,a as compile,u as compileMatchers,f as href,l as isRouterNotFound,s as leafRegistry,d as match,n as notFound,i as outletNode};
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 };
@@ -1 +1,212 @@
1
- import{c as e,n as t,r as n,u as r}from"../outlet-BS31W71_.js";import{HttpApiBuilder as i,HttpApiEndpoint as a,HttpApiGroup as o,HttpServer as s,HttpServerResponse as c}from"@effect/platform";import{Cause as l,Effect as u,Exit as d,Layer as f,Option as p,Schema as m,Scope as h,Stream as g,Subscribable as _}from"effect";import{AppRpcClientTag as v}from"@weftui/core";import{SuspenseFailureHandlerTag as y,renderToHydratableShell as b,renderToStringHydratable as x}from"@weftui/dom/server";import{RpcSerialization as S,RpcServer as C,RpcTest as w}from"@effect/rpc";let T;(function(T){function E(e,t){return c.text(`<!DOCTYPE html>\n${e}`,{status:t,contentType:`text/html; charset=utf-8`})}function D(e){let t={_tag:`Idle`};return n.of({currentMatch:_.make({get:u.succeed(e),changes:g.make(e)}),navigate:()=>u.void,httpApiClient:p.none(),navigating:_.make({get:u.succeed(t),changes:g.make(t)})})}function O(e){return e===void 0?f.succeed(v,v.of({call:e=>u.fail(Error(`Boundary.rpc "${e}" cannot resolve: no \`rpc\` option was passed to RouterServer`))})):f.scoped(v,u.map(w.makeClient(e.group,{flatten:!0}),e=>v.of({call:(t,n)=>e(t,n)}))).pipe(f.provide(e.handlers))}function k(e,t,r){return x(u.provideService(e.document({}),n.Outlet,t)).pipe(u.provideService(n,r),u.provide(O(e.rpc)),u.provide(e.context??f.empty))}function A(e,t,n,r){let i=D({_tag:`NotFound`,url:n});return k(t,e.compiled.notFound(),i).pipe(u.map(e=>E(e,r)))}function j(e,n,r){let i=D({_tag:`NotFound`,url:r});return k(n,t(e),i).pipe(u.map(e=>E(e,404)))}function M(e,n,i){let a=D(i);return k(n,t(e),a).pipe(u.map(e=>E(e,200)),u.catchIf(r,()=>A(e,n,i.url,404)))}function N(t){return{handle:n=>{let i=l.failureOption(n);return p.isSome(i)&&r(i.value)?p.some({content:t.compiled.notFound(),markNoindex:!0,failureReplay:m.encodeSync(e)(i.value)}):p.none()}}}function P(e,i,a){let o=D(a),s=t(e);return u.gen(function*(){let t=yield*h.make(),{shell:r,patches:a}=yield*b(u.provideService(i.document({}),n.Outlet,s)).pipe(u.provideService(n,o),u.provideService(y,N(e)),u.provide(O(i.rpc)),u.provide(i.context??f.empty),h.extend(t),u.onError(e=>h.close(t,d.failCause(e)))),l=g.make(`<!DOCTYPE html>\n${r}`).pipe(g.concat(a),g.ensuring(h.close(t,d.void)),g.encodeText);return c.stream(l,{status:200,contentType:`text/html; charset=utf-8`})}).pipe(u.catchIf(r,()=>A(e,i,a.url,404)))}let F=new WeakMap,I=new WeakMap;function L(e,t,n,r){let c=n.get(e)??new WeakMap;n.set(e,c);let l=c.get(t.document);if(l!==void 0)return l;let u=e.compiled.leaves,d=i,p=o.make(`fallback`).add(a.get(`catchAll`,`*`).addSuccess(m.String)),h=e.httpApi.add(p),g=d.group(h,`pages`,n=>u.reduce((n,i)=>n.handle(i.id,n=>r(e,t,{_tag:`Matched`,leaf:i,path:n.path,query:n.urlParams,url:n.request.url})),n)),_=d.group(h,`fallback`,n=>n.handle(`catchAll`,n=>j(e,t,n.request.url))),v=d.api(h).pipe(f.provide(f.mergeAll(g,_))),{handler:y}=i.toWebHandler(f.mergeAll(v,s.layerContext)),b=t.rpc,x=y;if(b!==void 0){let{handler:e}=C.toWebHandler(b.group,{layer:f.mergeAll(b.handlers,S.layerJson)});x=t=>new URL(t.url).pathname===`/_eui/rpc`?e(t):y(t)}return c.set(t.document,x),x}function R(e,t){return L(e,t,F,M)}function z(e){return e.startsWith(`http://`)||e.startsWith(`https://`)?e:`http://localhost${e.startsWith(`/`)?e:`/${e}`}`}function B(e,t){let n=t;return u.tryPromise({try:async()=>{let t=await R(e,n)(new Request(z(n.url)));return{html:await t.text(),status:t.status}},catch:e=>e instanceof Error?e:Error(String(e))})}T.render=B;function V(e,t){return R(e,t)}T.toWebHandler=V;function H(e,t){return L(e,t,I,P)}T.toStreamingWebHandler=H})(T||={});export{T as RouterServer};
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.22.0",
3
+ "version": "0.23.1",
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.22.0",
36
- "@weftui/dom": "0.22.0"
35
+ "@weftui/dom": "0.23.1",
36
+ "@weftui/core": "0.23.1"
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-plus": "0.2.1"
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"
@@ -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};
Binary file