@valancex/router 0.0.0-stage → 0.12.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/README.md +28 -2
- package/dist/guards.d.ts +7 -0
- package/dist/guards.js +14 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +5 -0
- package/dist/pattern.d.ts +40 -0
- package/dist/pattern.js +125 -0
- package/dist/route.d.ts +58 -0
- package/dist/route.js +18 -0
- package/dist/router.d.ts +53 -0
- package/dist/router.js +390 -0
- package/dist/types.d.ts +56 -0
- package/dist/types.js +7 -0
- package/package.json +63 -3
package/README.md
CHANGED
|
@@ -1,3 +1,29 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @valancex/router
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Routing for VALANCE applications: route definitions, typed path and query parameters, guards, programmatic navigation and a title per route. **Provisional** (0.x).
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { Schema } from "effect";
|
|
7
|
+
import { createRouter, redirect, route } from "@valancex/router";
|
|
8
|
+
|
|
9
|
+
const router = createRouter({
|
|
10
|
+
routes: {
|
|
11
|
+
home: route("/", { title: () => "Home" }),
|
|
12
|
+
message: route("/inbox/:id", {
|
|
13
|
+
params: { id: Schema.NumberFromString },
|
|
14
|
+
title: ({ params }) => `Message ${params.id}`,
|
|
15
|
+
canActivate: ({ context }) => (context().signedIn ? true : redirect("login")),
|
|
16
|
+
}),
|
|
17
|
+
login: route("/login", { title: () => "Sign in" }),
|
|
18
|
+
},
|
|
19
|
+
notFound: "notFound",
|
|
20
|
+
context: () => ({ signedIn: false }),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
await Web.run(app, { container, primitives, present: "mount", plugins: [router.plugin] });
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
- `router.commands(state)` gives `navigate`, `go` and `replace` commands for the application's state.
|
|
27
|
+
- Guards: `canMatch` (sync), `canActivate`, `canDeactivate`, and `beforeEach`; each answers a boolean, a `redirect(...)`, or a Promise/Effect of one. A refused Back/Forward is undone.
|
|
28
|
+
- Redirects name a route, never a URL; `router.safeNext(address)` only accepts an address on the application.
|
|
29
|
+
- Not yet: nested routes, a `beforeunload` guard, typed HTTP interceptors.
|
package/dist/guards.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import type { Route } from "./route.js";
|
|
3
|
+
import type { Guard, GuardContext, GuardResult, Redirect } from "./types.js";
|
|
4
|
+
/** Sends a navigation to another route: `redirect(login, {}, { next: "/inbox" })`. The target is a route, never an address, so a redirect cannot leave the application. */
|
|
5
|
+
export declare const redirect: <P, Q>(to: Route<P, Q, any, any> | string, params?: Readonly<Record<string, unknown>>, query?: Readonly<Record<string, unknown>>) => Redirect;
|
|
6
|
+
/** Runs a guard whatever it returns, and says what it answered. A guard that throws, rejects or fails refuses. */
|
|
7
|
+
export declare const runGuard: <S>(guard: Guard<S>, context: GuardContext<S>) => Effect.Effect<GuardResult>;
|
package/dist/guards.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
/** Sends a navigation to another route: `redirect(login, {}, { next: "/inbox" })`. The target is a route, never an address, so a redirect cannot leave the application. */
|
|
3
|
+
export const redirect = (to, params = {}, query = {}) => ({
|
|
4
|
+
_tag: "Redirect",
|
|
5
|
+
name: typeof to === "string" ? to : to.name,
|
|
6
|
+
params,
|
|
7
|
+
query,
|
|
8
|
+
});
|
|
9
|
+
const isRedirect = (value) => typeof value === "object" && value !== null && value._tag === "Redirect";
|
|
10
|
+
/** Runs a guard whatever it returns, and says what it answered. A guard that throws, rejects or fails refuses. */
|
|
11
|
+
export const runGuard = (guard, context) => Effect.suspend(() => {
|
|
12
|
+
const answer = guard(context);
|
|
13
|
+
return Effect.isEffect(answer) ? answer : Effect.tryPromise(async () => answer);
|
|
14
|
+
}).pipe(Effect.map((answer) => (isRedirect(answer) ? answer : answer === true)), Effect.catchAllCause((cause) => Effect.logError("route guard failed", cause).pipe(Effect.as(false))));
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { createRouter } from "./router.js";
|
|
2
|
+
export type { Router, RouterOptions } from "./router.js";
|
|
3
|
+
export { route } from "./route.js";
|
|
4
|
+
export type { Route, RouteConfig } from "./route.js";
|
|
5
|
+
export { redirect } from "./guards.js";
|
|
6
|
+
export { RouteDefinitionError } from "./pattern.js";
|
|
7
|
+
export { RouteState } from "./types.js";
|
|
8
|
+
export type { Decision, Guard, GuardContext, GuardResult, NavigationKind, Redirect, TitleContext } from "./types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export declare class RouteDefinitionError extends Error {
|
|
2
|
+
constructor(message: string);
|
|
3
|
+
}
|
|
4
|
+
export type Segment = {
|
|
5
|
+
readonly kind: "literal";
|
|
6
|
+
readonly value: string;
|
|
7
|
+
} | {
|
|
8
|
+
readonly kind: "param";
|
|
9
|
+
readonly name: string;
|
|
10
|
+
} | {
|
|
11
|
+
readonly kind: "rest";
|
|
12
|
+
readonly name: string;
|
|
13
|
+
};
|
|
14
|
+
export interface Pattern {
|
|
15
|
+
readonly source: string;
|
|
16
|
+
readonly segments: ReadonlyArray<Segment>;
|
|
17
|
+
/** The names a match fills, in order. */
|
|
18
|
+
readonly names: ReadonlyArray<string>;
|
|
19
|
+
/** How specific it is, segment by segment (literal 3, parameter 2, rest 1), for ranking. */
|
|
20
|
+
readonly score: ReadonlyArray<number>;
|
|
21
|
+
/** The pattern with every parameter name removed: two routes with the same shape cannot be told apart. */
|
|
22
|
+
readonly shape: string;
|
|
23
|
+
}
|
|
24
|
+
/** Limits on what is matched, so a hostile address costs a bounded amount. */
|
|
25
|
+
export declare const LIMITS: {
|
|
26
|
+
readonly path: 2048;
|
|
27
|
+
readonly segments: 32;
|
|
28
|
+
readonly segment: 512;
|
|
29
|
+
readonly queryKeys: 64;
|
|
30
|
+
readonly queryValue: 2048;
|
|
31
|
+
};
|
|
32
|
+
export declare const parsePattern: (source: string) => Pattern;
|
|
33
|
+
/** Higher first: compare segment by segment, then the longer pattern. */
|
|
34
|
+
export declare const bySpecificity: (a: Pattern, b: Pattern) => number;
|
|
35
|
+
/** The path's segments decoded once, or `undefined` for a path that is not an address this router would read. */
|
|
36
|
+
export declare const segmentsOf: (pathname: string) => ReadonlyArray<string> | undefined;
|
|
37
|
+
/** The values the pattern takes from the segments (as strings, for a Schema to decode), or `undefined` when it does not match. */
|
|
38
|
+
export declare const matchSegments: (pattern: Pattern, segments: ReadonlyArray<string>) => Readonly<Record<string, string>> | undefined;
|
|
39
|
+
/** The path for `values` (strings), each piece percent-encoded. A missing or empty value is an error: a path with a hole is not an address. */
|
|
40
|
+
export declare const buildPath: (pattern: Pattern, values: Readonly<Record<string, string>>) => string;
|
package/dist/pattern.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// A route's path pattern: literal segments, `:name` parameters and a trailing `*` (or `*name`) that takes the rest. Matching is strict where a loose reading would be a hole: the whole path is
|
|
2
|
+
// bounded, every segment is percent-decoded once and may not hold a control character, a literal is compared exactly (case and all), and a trailing slash is the same address.
|
|
3
|
+
export class RouteDefinitionError extends Error {
|
|
4
|
+
constructor(message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "RouteDefinitionError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
/** Limits on what is matched, so a hostile address costs a bounded amount. */
|
|
10
|
+
export const LIMITS = { path: 2048, segments: 32, segment: 512, queryKeys: 64, queryValue: 2048 };
|
|
11
|
+
const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
12
|
+
export const parsePattern = (source) => {
|
|
13
|
+
if (!source.startsWith("/") || source.includes("//") || (source.length > 1 && source.endsWith("/"))) {
|
|
14
|
+
throw new RouteDefinitionError(`the path "${source}" must start with "/", hold no empty segment and not end with "/" (the root is "/")`);
|
|
15
|
+
}
|
|
16
|
+
const parts = source === "/" ? [] : source.slice(1).split("/");
|
|
17
|
+
const segments = [];
|
|
18
|
+
const names = new Set();
|
|
19
|
+
parts.forEach((part, at) => {
|
|
20
|
+
if (part.startsWith(":")) {
|
|
21
|
+
const name = part.slice(1);
|
|
22
|
+
if (!NAME.test(name) || names.has(name)) {
|
|
23
|
+
throw new RouteDefinitionError(`the path "${source}" has a parameter ":${name}" that is not a name or is used twice`);
|
|
24
|
+
}
|
|
25
|
+
names.add(name);
|
|
26
|
+
segments.push({ kind: "param", name });
|
|
27
|
+
}
|
|
28
|
+
else if (part.startsWith("*")) {
|
|
29
|
+
const name = part.length === 1 ? "rest" : part.slice(1);
|
|
30
|
+
if (at !== parts.length - 1 || !NAME.test(name) || names.has(name)) {
|
|
31
|
+
throw new RouteDefinitionError(`the path "${source}" may end with "*" or "*name" and nowhere else, and the name must be new`);
|
|
32
|
+
}
|
|
33
|
+
names.add(name);
|
|
34
|
+
segments.push({ kind: "rest", name });
|
|
35
|
+
}
|
|
36
|
+
else if (part === "" || /[:*?#\s]/.test(part)) {
|
|
37
|
+
throw new RouteDefinitionError(`the path "${source}" has a segment "${part}" that is empty or holds one of : * ? # or a space (write a parameter as ":name")`);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
segments.push({ kind: "literal", value: part });
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
return {
|
|
44
|
+
source,
|
|
45
|
+
segments,
|
|
46
|
+
names: [...names],
|
|
47
|
+
score: segments.map((segment) => (segment.kind === "literal" ? 3 : segment.kind === "param" ? 2 : 1)),
|
|
48
|
+
shape: `/${segments.map((segment) => (segment.kind === "literal" ? segment.value : segment.kind === "param" ? ":" : "*")).join("/")}`,
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
/** Higher first: compare segment by segment, then the longer pattern. */
|
|
52
|
+
export const bySpecificity = (a, b) => {
|
|
53
|
+
for (let at = 0; at < Math.max(a.score.length, b.score.length); at += 1) {
|
|
54
|
+
const difference = (b.score[at] ?? 0) - (a.score[at] ?? 0);
|
|
55
|
+
if (difference !== 0) {
|
|
56
|
+
return difference;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return 0;
|
|
60
|
+
};
|
|
61
|
+
// oxlint-disable-next-line no-control-regex -- refusing control characters in an address is the point
|
|
62
|
+
const CONTROL = /[\u0000-\u001f\u007f]/;
|
|
63
|
+
const decode = (segment) => {
|
|
64
|
+
try {
|
|
65
|
+
const value = decodeURIComponent(segment);
|
|
66
|
+
return CONTROL.test(value) ? undefined : value;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
/** The path's segments decoded once, or `undefined` for a path that is not an address this router would read. */
|
|
73
|
+
export const segmentsOf = (pathname) => {
|
|
74
|
+
if (!pathname.startsWith("/") || pathname.includes("//") || pathname.length > LIMITS.path) {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
const trimmed = pathname.length > 1 && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
|
|
78
|
+
const raw = trimmed === "/" ? [] : trimmed.slice(1).split("/");
|
|
79
|
+
if (raw.length > LIMITS.segments || raw.some((part) => part === "" || part.length > LIMITS.segment)) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
const decoded = raw.map(decode);
|
|
83
|
+
return decoded.every((part) => part !== undefined) ? decoded : undefined;
|
|
84
|
+
};
|
|
85
|
+
/** The values the pattern takes from the segments (as strings, for a Schema to decode), or `undefined` when it does not match. */
|
|
86
|
+
export const matchSegments = (pattern, segments) => {
|
|
87
|
+
const values = Object.create(null);
|
|
88
|
+
const last = pattern.segments[pattern.segments.length - 1];
|
|
89
|
+
const rest = last?.kind === "rest";
|
|
90
|
+
if (rest ? segments.length < pattern.segments.length : segments.length !== pattern.segments.length) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
for (const [at, segment] of pattern.segments.entries()) {
|
|
94
|
+
if (segment.kind === "literal") {
|
|
95
|
+
if (segments[at] !== segment.value) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else if (segment.kind === "param") {
|
|
100
|
+
values[segment.name] = segments[at];
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
values[segment.name] = segments.slice(at).join("/");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return values;
|
|
107
|
+
};
|
|
108
|
+
/** The path for `values` (strings), each piece percent-encoded. A missing or empty value is an error: a path with a hole is not an address. */
|
|
109
|
+
export const buildPath = (pattern, values) => {
|
|
110
|
+
if (pattern.segments.length === 0) {
|
|
111
|
+
return "/";
|
|
112
|
+
}
|
|
113
|
+
return `/${pattern.segments
|
|
114
|
+
.map((segment) => {
|
|
115
|
+
if (segment.kind === "literal") {
|
|
116
|
+
return encodeURIComponent(segment.value);
|
|
117
|
+
}
|
|
118
|
+
const value = values[segment.name];
|
|
119
|
+
if (value === undefined || value === "") {
|
|
120
|
+
throw new RouteDefinitionError(`the path "${pattern.source}" needs a value for "${segment.name}"`);
|
|
121
|
+
}
|
|
122
|
+
return segment.kind === "param" ? encodeURIComponent(value) : value.split("/").map(encodeURIComponent).join("/");
|
|
123
|
+
})
|
|
124
|
+
.join("/")}`;
|
|
125
|
+
};
|
package/dist/route.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Schema } from "effect";
|
|
2
|
+
import type { Pattern } from "./pattern.js";
|
|
3
|
+
import type { Guard, Redirect, TitleContext } from "./types.js";
|
|
4
|
+
/** What a route is configured with. `S` is the application's state, `C` the matching context. */
|
|
5
|
+
export interface RouteConfig<P, Q, S, C> {
|
|
6
|
+
/** The route's name: unique, and what state, redirects and `href` refer to. */
|
|
7
|
+
readonly name: string;
|
|
8
|
+
/** The path parameters. The encoded side is strings (what an address holds); the decoded side is what the application sees. A value that does not decode, or that decodes to something that is not JSON (a `NaN`, a Date), means the route does not match. */
|
|
9
|
+
readonly params?: Schema.Schema<P, any>;
|
|
10
|
+
/** The query parameters, the same way. A key that appears more than once arrives as an array of strings. A query that does not decode means the route does not match. */
|
|
11
|
+
readonly query?: Schema.Schema<Q, any>;
|
|
12
|
+
/** The document's title for this route: a string, or a function of its parameters. */
|
|
13
|
+
readonly title?: string | ((context: TitleContext<P, Q>) => string | undefined);
|
|
14
|
+
/** Static facts about the route for guards to read (`{ requires: "admin" }`). */
|
|
15
|
+
readonly data?: Readonly<Record<string, unknown>>;
|
|
16
|
+
/**
|
|
17
|
+
* A **matching condition**: is this route available here and now at all (a feature flag, the signed-in role)? Synchronous and a function of `context` only, so matching stays a pure function
|
|
18
|
+
* of the address. A route that is not available is skipped: the address falls to the next candidate, then to not found. Not for conditions that need the application's state or to wait:
|
|
19
|
+
* those are guards.
|
|
20
|
+
*/
|
|
21
|
+
readonly canMatch?: (context: C) => boolean;
|
|
22
|
+
/** Asked before the route is entered. */
|
|
23
|
+
readonly canActivate?: Guard<S>;
|
|
24
|
+
/** Asked before the route is left for another route or other parameters (not for a change of query alone): unsaved edits, a running upload. */
|
|
25
|
+
readonly canDeactivate?: Guard<S>;
|
|
26
|
+
/** The address is another route's: matching it gives that route's state. */
|
|
27
|
+
readonly redirectTo?: Redirect;
|
|
28
|
+
/** `static` (default): the build writes a page for it, for each of `staticParams`, or once when it has none. `client`: only the browser knows these addresses, and the host must serve the app shell for them. */
|
|
29
|
+
readonly delivery?: "static" | "client";
|
|
30
|
+
/** The parameter sets to write pages for (a `static` route with parameters). */
|
|
31
|
+
readonly staticParams?: () => ReadonlyArray<P>;
|
|
32
|
+
/** A change of the query alone, on the same route and parameters, replaces the history entry instead of adding one (a search box). */
|
|
33
|
+
readonly replaceQuery?: boolean;
|
|
34
|
+
}
|
|
35
|
+
/** A route: its pattern and its configuration. The type parameters remember what its Schemas decode to, so `href` can be checked. */
|
|
36
|
+
export interface Route<P = Record<string, never>, Q = Record<string, never>, S = any, C = any> {
|
|
37
|
+
readonly _tag: "Route";
|
|
38
|
+
readonly pattern: Pattern;
|
|
39
|
+
readonly config: RouteConfig<P, Q, S, C>;
|
|
40
|
+
readonly name: string;
|
|
41
|
+
/** Not present at run time: carries `P` and `Q` for the checker. */
|
|
42
|
+
readonly types?: {
|
|
43
|
+
readonly params: P;
|
|
44
|
+
readonly query: Q;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Defines a route.
|
|
49
|
+
*
|
|
50
|
+
* const message = route("/inbox/:id", {
|
|
51
|
+
* name: "message",
|
|
52
|
+
* params: Schema.Struct({ id: Schema.NumberFromString }),
|
|
53
|
+
* query: Schema.Struct({ tab: Schema.optional(Schema.Literal("all", "unread")) }),
|
|
54
|
+
* title: ({ params }) => `Message ${params.id}`,
|
|
55
|
+
* canDeactivate: ({ state }) => !state.draft.dirty,
|
|
56
|
+
* });
|
|
57
|
+
*/
|
|
58
|
+
export declare const route: <P = Record<string, never>, Q = Record<string, never>, S = any, C = any>(path: string, config: RouteConfig<P, Q, S, C>) => Route<P, Q, S, C>;
|
package/dist/route.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { parsePattern } from "./pattern.js";
|
|
2
|
+
/**
|
|
3
|
+
* Defines a route.
|
|
4
|
+
*
|
|
5
|
+
* const message = route("/inbox/:id", {
|
|
6
|
+
* name: "message",
|
|
7
|
+
* params: Schema.Struct({ id: Schema.NumberFromString }),
|
|
8
|
+
* query: Schema.Struct({ tab: Schema.optional(Schema.Literal("all", "unread")) }),
|
|
9
|
+
* title: ({ params }) => `Message ${params.id}`,
|
|
10
|
+
* canDeactivate: ({ state }) => !state.draft.dirty,
|
|
11
|
+
* });
|
|
12
|
+
*/
|
|
13
|
+
export const route = (path, config) => ({
|
|
14
|
+
_tag: "Route",
|
|
15
|
+
pattern: parsePattern(path),
|
|
16
|
+
config,
|
|
17
|
+
name: config.name,
|
|
18
|
+
});
|
package/dist/router.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import * as Nexus from "@valancex/nexus";
|
|
2
|
+
import type { Plugin, RouteTable } from "@valancex/valance/web/plugin";
|
|
3
|
+
import { Effect } from "effect";
|
|
4
|
+
import type { Route } from "./route.js";
|
|
5
|
+
import { RouteState } from "./types.js";
|
|
6
|
+
import type { Decision, Guard, NavigationKind } from "./types.js";
|
|
7
|
+
export interface RouterOptions<S, C> {
|
|
8
|
+
readonly routes: ReadonlyArray<Route<any, any, S, C>>;
|
|
9
|
+
/** Where the application is served: `/` (default) or `/docs/`. */
|
|
10
|
+
readonly base?: string;
|
|
11
|
+
/** What `canMatch` is given. Read for every match, so it must be cheap and synchronous (a role from a store, a feature flag). */
|
|
12
|
+
readonly context?: () => C;
|
|
13
|
+
/** The key of the application's state that holds the `RouteState`. Default `route`. */
|
|
14
|
+
readonly key?: string;
|
|
15
|
+
/** A guard for every navigation, asked after the route being left and before the route being entered. */
|
|
16
|
+
readonly beforeEach?: Guard<S>;
|
|
17
|
+
/** The route state for an address no route matches. Default name `notFound`. */
|
|
18
|
+
readonly notFound?: {
|
|
19
|
+
readonly name?: string;
|
|
20
|
+
readonly title?: string;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export declare const createRouter: <S extends Record<string, any>, C = undefined>(options: RouterOptions<S, C>) => {
|
|
24
|
+
base: string;
|
|
25
|
+
key: string;
|
|
26
|
+
notFoundName: string;
|
|
27
|
+
table: RouteTable<S>;
|
|
28
|
+
plugin: Plugin<S>;
|
|
29
|
+
commands: (state: Nexus.State.StateHandle<S>) => {
|
|
30
|
+
navigate: Nexus.Command.Command<RouteState, void, never, never>;
|
|
31
|
+
go: Nexus.Command.Command<{
|
|
32
|
+
readonly to: string;
|
|
33
|
+
}, void, never, never>;
|
|
34
|
+
replace: Nexus.Command.Command<{
|
|
35
|
+
readonly to: string;
|
|
36
|
+
}, void, never, never>;
|
|
37
|
+
};
|
|
38
|
+
match: (address: string) => RouteState;
|
|
39
|
+
href: <P, Q>(to: Route<P, Q, S, C> | string, params?: P | Readonly<Record<string, unknown>>, query?: Q | Readonly<Record<string, unknown>>) => string;
|
|
40
|
+
hrefOf: (state: RouteState) => string;
|
|
41
|
+
resolve: (to: string) => RouteState | undefined;
|
|
42
|
+
safeNext: (raw: string | null | undefined) => string | undefined;
|
|
43
|
+
decide: (input: {
|
|
44
|
+
readonly from: RouteState | undefined;
|
|
45
|
+
readonly to: RouteState;
|
|
46
|
+
readonly state: S;
|
|
47
|
+
readonly kind: NavigationKind;
|
|
48
|
+
}) => Effect.Effect<Decision>;
|
|
49
|
+
titleOf: (state: RouteState) => string | undefined;
|
|
50
|
+
route: (name: string) => Route<any, any, S, C> | undefined;
|
|
51
|
+
paths: () => ReadonlyArray<string>;
|
|
52
|
+
};
|
|
53
|
+
export type Router<S extends Record<string, any>, C = undefined> = ReturnType<typeof createRouter<S, C>>;
|
package/dist/router.js
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
// The router: route definitions in, a URL policy for `Web.run` out. It matches an address to a route (path and query parameters decoded by the route's Schemas), builds an address for a route,
|
|
2
|
+
// decides whether a navigation may happen (matching conditions, guards for leaving and entering), titles a route, and offers the two commands that change where the application is.
|
|
3
|
+
import * as Nexus from "@valancex/nexus";
|
|
4
|
+
import { Effect, Either, Schema } from "effect";
|
|
5
|
+
import { runGuard } from "./guards.js";
|
|
6
|
+
import { bySpecificity, buildPath, LIMITS, matchSegments, RouteDefinitionError, segmentsOf } from "./pattern.js";
|
|
7
|
+
import { RouteState } from "./types.js";
|
|
8
|
+
const DEFAULT_KEY = "route";
|
|
9
|
+
const POISON = new Set(["__proto__", "constructor", "prototype"]);
|
|
10
|
+
const MAX_REDIRECTS = 8;
|
|
11
|
+
/** Whether a decoded value can live in the application's state and be embedded in a page: null, booleans, strings, finite numbers, and arrays and plain records of those, to a bounded depth. */
|
|
12
|
+
const jsonSafe = (value, depth = 0) => {
|
|
13
|
+
if (depth > 8) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
if (typeof value === "number") {
|
|
20
|
+
return Number.isFinite(value);
|
|
21
|
+
}
|
|
22
|
+
if (Array.isArray(value)) {
|
|
23
|
+
return value.length <= 1024 && value.every((each) => jsonSafe(each, depth + 1));
|
|
24
|
+
}
|
|
25
|
+
if (typeof value === "object") {
|
|
26
|
+
const proto = Object.getPrototypeOf(value);
|
|
27
|
+
return (proto === Object.prototype || proto === null) && Object.values(value).every((each) => jsonSafe(each, depth + 1));
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
};
|
|
31
|
+
const canonical = (value) => {
|
|
32
|
+
if (Array.isArray(value)) {
|
|
33
|
+
return `[${value.map(canonical).join(",")}]`;
|
|
34
|
+
}
|
|
35
|
+
if (typeof value === "object" && value !== null) {
|
|
36
|
+
return `{${Object.keys(value)
|
|
37
|
+
.sort()
|
|
38
|
+
.map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`)
|
|
39
|
+
.join(",")}}`;
|
|
40
|
+
}
|
|
41
|
+
return JSON.stringify(value) ?? "null";
|
|
42
|
+
};
|
|
43
|
+
const Destination = Schema.Struct({ to: Schema.String });
|
|
44
|
+
export const createRouter = (options) => {
|
|
45
|
+
const base = options.base ?? "/";
|
|
46
|
+
const key = options.key ?? DEFAULT_KEY;
|
|
47
|
+
const notFoundName = options.notFound?.name ?? "notFound";
|
|
48
|
+
if (!/^\/([\w.~-]+\/)*$/.test(base)) {
|
|
49
|
+
throw new RouteDefinitionError(`the base "${base}" must start and end with "/" ("/" or "/docs/")`);
|
|
50
|
+
}
|
|
51
|
+
const byName = new Map();
|
|
52
|
+
const shapes = new Map();
|
|
53
|
+
for (const each of options.routes) {
|
|
54
|
+
if (each.name === notFoundName || byName.has(each.name)) {
|
|
55
|
+
throw new RouteDefinitionError(`the route name "${each.name}" is ${each.name === notFoundName ? "the not-found name" : "used twice"}`);
|
|
56
|
+
}
|
|
57
|
+
if (shapes.has(each.pattern.shape)) {
|
|
58
|
+
throw new RouteDefinitionError(`the paths "${shapes.get(each.pattern.shape)}" and "${each.pattern.source}" cannot be told apart`);
|
|
59
|
+
}
|
|
60
|
+
byName.set(each.name, each);
|
|
61
|
+
shapes.set(each.pattern.shape, each.pattern.source);
|
|
62
|
+
}
|
|
63
|
+
for (const each of options.routes) {
|
|
64
|
+
if (each.config.redirectTo !== undefined && !byName.has(each.config.redirectTo.name)) {
|
|
65
|
+
throw new RouteDefinitionError(`the route "${each.name}" redirects to "${each.config.redirectTo.name}", which is not a route`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const ranked = [...options.routes]
|
|
69
|
+
.map((each, at) => ({ each, at }))
|
|
70
|
+
.sort((a, b) => bySpecificity(a.each.pattern, b.each.pattern) || a.at - b.at)
|
|
71
|
+
.map(({ each }) => each);
|
|
72
|
+
// ---- addresses ------------------------------------------------------------------------------------------------------------------------------------------
|
|
73
|
+
const routeNamed = (name) => byName.get(name);
|
|
74
|
+
const withBase = (path) => (base === "/" ? path : path === "/" ? base : `${base.slice(0, -1)}${path}`);
|
|
75
|
+
/** The query as the address holds it: a key once is a string, repeated is an array. Too many keys or too long a value is not an address this router reads. */
|
|
76
|
+
const readQuery = (search) => {
|
|
77
|
+
const found = {};
|
|
78
|
+
let count = 0;
|
|
79
|
+
for (const [name, value] of search.entries()) {
|
|
80
|
+
if (POISON.has(name) || value.length > LIMITS.queryValue) {
|
|
81
|
+
if (POISON.has(name)) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
const existing = found[name];
|
|
87
|
+
if (existing === undefined) {
|
|
88
|
+
count += 1;
|
|
89
|
+
found[name] = value;
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
found[name] = Array.isArray(existing) ? [...existing, value] : [existing, value];
|
|
93
|
+
}
|
|
94
|
+
if (count > LIMITS.queryKeys) {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return found;
|
|
99
|
+
};
|
|
100
|
+
const decodeWith = (schema, raw, fallback) => {
|
|
101
|
+
if (schema === undefined) {
|
|
102
|
+
return fallback;
|
|
103
|
+
}
|
|
104
|
+
const decoded = Schema.decodeUnknownEither(schema)(raw);
|
|
105
|
+
return Either.isRight(decoded) && jsonSafe(decoded.right) ? decoded.right : undefined;
|
|
106
|
+
};
|
|
107
|
+
/** The state for a route's decoded parameters and query, following its `redirectTo`, or `undefined` when it cannot be built. */
|
|
108
|
+
const settle = (start, params, query, depth = 0) => {
|
|
109
|
+
const target = start.config.redirectTo;
|
|
110
|
+
if (target === undefined) {
|
|
111
|
+
return { name: start.name, params, query };
|
|
112
|
+
}
|
|
113
|
+
const next = routeNamed(target.name);
|
|
114
|
+
return depth >= MAX_REDIRECTS || next === undefined ? undefined : settle(next, target.params, target.query, depth + 1);
|
|
115
|
+
};
|
|
116
|
+
const notFoundState = (url) => {
|
|
117
|
+
const path = `${url.pathname}${url.search}`;
|
|
118
|
+
return { name: notFoundName, params: { path: path.length <= LIMITS.path && path.startsWith("/") && !path.startsWith("//") ? path : "/" }, query: {} };
|
|
119
|
+
};
|
|
120
|
+
/** The route an address belongs to, and the state that says so. `undefined` when none does. */
|
|
121
|
+
const matchUrl = (url) => {
|
|
122
|
+
if (!url.pathname.startsWith(base) && url.pathname !== base.slice(0, -1)) {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
const inside = url.pathname === base.slice(0, -1) ? "/" : `/${url.pathname.slice(base.length)}`;
|
|
126
|
+
const segments = segmentsOf(inside);
|
|
127
|
+
const query = readQuery(url.searchParams);
|
|
128
|
+
if (segments === undefined || query === undefined) {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
const context = options.context?.();
|
|
132
|
+
for (const candidate of ranked) {
|
|
133
|
+
const values = matchSegments(candidate.pattern, segments);
|
|
134
|
+
if (values === undefined || (candidate.config.canMatch !== undefined && !candidate.config.canMatch(context))) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const params = decodeWith(candidate.config.params, values, { ...values });
|
|
138
|
+
const decodedQuery = decodeWith(candidate.config.query, query, {});
|
|
139
|
+
if (params === undefined || decodedQuery === undefined) {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const state = settle(candidate, params, decodedQuery);
|
|
143
|
+
if (state !== undefined) {
|
|
144
|
+
return { route: candidate, state };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return undefined;
|
|
148
|
+
};
|
|
149
|
+
const FALLBACK_ORIGIN = "http://router.invalid";
|
|
150
|
+
/** The state for an address (a path and query under the base): the route that matches, or not found. */
|
|
151
|
+
const stateFor = (address) => {
|
|
152
|
+
const url = new URL(address, FALLBACK_ORIGIN);
|
|
153
|
+
return matchUrl(url)?.state ?? notFoundState(url);
|
|
154
|
+
};
|
|
155
|
+
const encodeWith = (schema, value, what, route) => {
|
|
156
|
+
if (schema === undefined) {
|
|
157
|
+
return { ...value };
|
|
158
|
+
}
|
|
159
|
+
const encoded = Schema.encodeUnknownEither(schema)(value);
|
|
160
|
+
if (Either.isLeft(encoded)) {
|
|
161
|
+
throw new RouteDefinitionError(`the ${what} for route "${route}" do not fit its Schema`);
|
|
162
|
+
}
|
|
163
|
+
return encoded.right;
|
|
164
|
+
};
|
|
165
|
+
/** The address for a route and its decoded parameters and query: `href(message, { id: 42 }, { tab: "unread" })` is `/inbox/42?tab=unread`. */
|
|
166
|
+
const href = (to, params, query) => {
|
|
167
|
+
const target = typeof to === "string" ? routeNamed(to) : to;
|
|
168
|
+
if (target === undefined) {
|
|
169
|
+
throw new RouteDefinitionError(`there is no route named "${String(to)}"`);
|
|
170
|
+
}
|
|
171
|
+
const strings = {};
|
|
172
|
+
for (const [name, value] of Object.entries(encodeWith(target.config.params, params ?? {}, "parameters", target.name))) {
|
|
173
|
+
strings[name] = String(value);
|
|
174
|
+
}
|
|
175
|
+
const search = new URLSearchParams();
|
|
176
|
+
const encoded = encodeWith(target.config.query, query ?? {}, "query", target.name);
|
|
177
|
+
for (const name of Object.keys(encoded).sort()) {
|
|
178
|
+
const value = encoded[name];
|
|
179
|
+
for (const each of Array.isArray(value) ? value : [value]) {
|
|
180
|
+
if (each !== undefined) {
|
|
181
|
+
search.append(name, String(each));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const tail = search.toString();
|
|
186
|
+
return `${withBase(buildPath(target.pattern, strings))}${tail === "" ? "" : `?${tail}`}`;
|
|
187
|
+
};
|
|
188
|
+
/** The address a state is at. */
|
|
189
|
+
const hrefOf = (state) => {
|
|
190
|
+
if (state.name === notFoundName) {
|
|
191
|
+
const path = state.params["path"];
|
|
192
|
+
return typeof path === "string" && path.startsWith("/") && !path.startsWith("//") ? path : base;
|
|
193
|
+
}
|
|
194
|
+
return href(state.name, state.params, state.query);
|
|
195
|
+
};
|
|
196
|
+
/** The state for an address given as a string on this application (`/inbox/42?tab=unread`, under the base), for programmatic navigation. `undefined` for another origin or a path outside the base. */
|
|
197
|
+
const resolve = (to) => {
|
|
198
|
+
let url;
|
|
199
|
+
try {
|
|
200
|
+
url = new URL(to, `${FALLBACK_ORIGIN}${base}`);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
if (url.origin !== FALLBACK_ORIGIN || to.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(to)) {
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
return matchUrl(url)?.state ?? (url.pathname.startsWith(base) ? notFoundState(url) : undefined);
|
|
209
|
+
};
|
|
210
|
+
/** A `?next=`-style address from outside, made safe to navigate to: the address of a declared route on this application, or `undefined`. Never a route that is not declared, never another origin. */
|
|
211
|
+
const safeNext = (raw) => {
|
|
212
|
+
if (raw === null || raw === undefined || raw === "" || raw.length > LIMITS.path) {
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
const state = resolve(raw);
|
|
216
|
+
return state === undefined || state.name === notFoundName ? undefined : hrefOf(state);
|
|
217
|
+
};
|
|
218
|
+
const redirectState = (to) => {
|
|
219
|
+
const target = routeNamed(to.name);
|
|
220
|
+
if (target === undefined) {
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
href(target, to.params, to.query);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
return settle(target, to.params, to.query);
|
|
230
|
+
};
|
|
231
|
+
// ---- deciding -------------------------------------------------------------------------------------------------------------------------------------------
|
|
232
|
+
const sameDestination = (a, b) => a.name === b.name && canonical(a.params) === canonical(b.params) && canonical(a.query) === canonical(b.query);
|
|
233
|
+
/**
|
|
234
|
+
* May the application go from `from` to `to`? In order: the route being left (`canDeactivate`, when the route or its parameters change, not for a query alone), the router's `beforeEach`,
|
|
235
|
+
* then the route being entered (`canActivate`). A `Redirect` restarts the entering side at its target, at most eight times. Nothing runs when the destination is where the application already is.
|
|
236
|
+
*/
|
|
237
|
+
const decide = (input) => Effect.gen(function* () {
|
|
238
|
+
const { from, state, kind } = input;
|
|
239
|
+
let to = input.to;
|
|
240
|
+
if (from !== undefined && sameDestination(from, to)) {
|
|
241
|
+
return { _tag: "Allow", to };
|
|
242
|
+
}
|
|
243
|
+
const context = (route, destination) => ({
|
|
244
|
+
from,
|
|
245
|
+
to: destination,
|
|
246
|
+
state,
|
|
247
|
+
kind,
|
|
248
|
+
data: route?.config.data ?? {},
|
|
249
|
+
});
|
|
250
|
+
const leaving = from === undefined ? undefined : routeNamed(from.name);
|
|
251
|
+
if (from !== undefined && leaving?.config.canDeactivate !== undefined && (from.name !== to.name || canonical(from.params) !== canonical(to.params))) {
|
|
252
|
+
const answer = yield* runGuard(leaving.config.canDeactivate, context(leaving, to));
|
|
253
|
+
if (answer !== true) {
|
|
254
|
+
return { _tag: "Refuse", by: "canDeactivate", route: from.name };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
for (let hops = 0;; hops += 1) {
|
|
258
|
+
if (hops > MAX_REDIRECTS) {
|
|
259
|
+
return { _tag: "Refuse", by: "redirects", route: to.name };
|
|
260
|
+
}
|
|
261
|
+
const entering = routeNamed(to.name);
|
|
262
|
+
const checks = [
|
|
263
|
+
[options.beforeEach, "beforeEach", undefined],
|
|
264
|
+
[entering?.config.canActivate, "canActivate", entering],
|
|
265
|
+
];
|
|
266
|
+
let redirected;
|
|
267
|
+
for (const [guard, by, owner] of checks) {
|
|
268
|
+
if (guard === undefined) {
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
const answer = yield* runGuard(guard, context(owner, to));
|
|
272
|
+
if (answer === false) {
|
|
273
|
+
return { _tag: "Refuse", by, route: to.name };
|
|
274
|
+
}
|
|
275
|
+
if (answer !== true) {
|
|
276
|
+
redirected = redirectState(answer);
|
|
277
|
+
if (redirected === undefined) {
|
|
278
|
+
return { _tag: "Refuse", by, route: to.name };
|
|
279
|
+
}
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (redirected === undefined) {
|
|
284
|
+
return to === input.to ? { _tag: "Allow", to } : { _tag: "Redirect", to };
|
|
285
|
+
}
|
|
286
|
+
to = redirected;
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
// ---- what Web.run needs ---------------------------------------------------------------------------------------------------------------------------------
|
|
290
|
+
const routeOf = (state) => {
|
|
291
|
+
const value = state[key];
|
|
292
|
+
return value;
|
|
293
|
+
};
|
|
294
|
+
const strip = (state) => ({ name: state.name, params: state.params, query: state.query });
|
|
295
|
+
const titleOf = (state) => {
|
|
296
|
+
if (state.name === notFoundName) {
|
|
297
|
+
return options.notFound?.title;
|
|
298
|
+
}
|
|
299
|
+
const found = routeNamed(state.name);
|
|
300
|
+
const title = found?.config.title;
|
|
301
|
+
return typeof title === "function" ? title({ params: state.params, query: state.query, data: found?.config.data ?? {} }) : title;
|
|
302
|
+
};
|
|
303
|
+
const paths = () => {
|
|
304
|
+
const out = new Set();
|
|
305
|
+
for (const each of options.routes) {
|
|
306
|
+
if (each.config.delivery === "client" || each.config.redirectTo !== undefined) {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (each.pattern.names.length === 0) {
|
|
310
|
+
out.add(href(each));
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
for (const params of each.config.staticParams?.() ?? []) {
|
|
314
|
+
out.add(href(each, params));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return [...out];
|
|
319
|
+
};
|
|
320
|
+
const table = {
|
|
321
|
+
urlOf: (state) => {
|
|
322
|
+
const current = routeOf(state);
|
|
323
|
+
return current === undefined ? base : hrefOf(current);
|
|
324
|
+
},
|
|
325
|
+
stateOf: (url) => strip(matchUrl(url)?.state ?? notFoundState(url)),
|
|
326
|
+
navigate: "navigate",
|
|
327
|
+
known: (pathname) => matchUrl(new URL(pathname, FALLBACK_ORIGIN)) !== undefined,
|
|
328
|
+
paths,
|
|
329
|
+
delivery: (pathname) => {
|
|
330
|
+
const found = matchUrl(new URL(pathname, FALLBACK_ORIGIN));
|
|
331
|
+
return found?.route.config.delivery === "client" ? "server" : "static";
|
|
332
|
+
},
|
|
333
|
+
modeOf: (previous, next) => {
|
|
334
|
+
const before = routeOf(previous);
|
|
335
|
+
const after = routeOf(next);
|
|
336
|
+
if (after?.replace === true) {
|
|
337
|
+
return "replace";
|
|
338
|
+
}
|
|
339
|
+
const route = before === undefined ? undefined : routeNamed(before.name);
|
|
340
|
+
return before !== undefined &&
|
|
341
|
+
after !== undefined &&
|
|
342
|
+
route?.config.replaceQuery === true &&
|
|
343
|
+
before.name === after.name &&
|
|
344
|
+
canonical(before.params) === canonical(after.params)
|
|
345
|
+
? "replace"
|
|
346
|
+
: "push";
|
|
347
|
+
},
|
|
348
|
+
guard: (navigation, state) => {
|
|
349
|
+
const from = routeOf(state);
|
|
350
|
+
const to = matchUrl(navigation.url)?.state ?? notFoundState(navigation.url);
|
|
351
|
+
return Effect.runPromise(decide({ from, to, state, kind: navigation.kind }).pipe(Effect.map((decision) => (decision._tag === "Allow" ? true : decision._tag === "Redirect" ? { redirect: hrefOf(decision.to) } : false))));
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
const plugin = {
|
|
355
|
+
name: "router",
|
|
356
|
+
routes: table,
|
|
357
|
+
head: (state) => {
|
|
358
|
+
const current = routeOf(state);
|
|
359
|
+
const title = current === undefined ? undefined : titleOf(current);
|
|
360
|
+
return title === undefined ? {} : { title };
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
/**
|
|
364
|
+
* The commands the router adds to the application's table:
|
|
365
|
+
*
|
|
366
|
+
* - `navigate`: what `Web.run` invokes for a link or Back/Forward once the browser-side guard allowed it. It takes the route state and sets it.
|
|
367
|
+
* - `go` and `replace`: programmatic navigation to an address, `go({ to: "/inbox/42?tab=unread" })`; `replace` rewrites the current history entry instead of adding one. Each decides first (the guard
|
|
368
|
+
* for leaving, then the one for entering), then sets the state; a refusal leaves it where it is. They take one argument so a template can call them: `on.click={go("/inbox")}`.
|
|
369
|
+
*/
|
|
370
|
+
const moveTo = (state, to, replace) => Effect.gen(function* () {
|
|
371
|
+
const target = resolve(to);
|
|
372
|
+
if (target === undefined) {
|
|
373
|
+
yield* Effect.logWarning("the address is not on this application");
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const current = yield* state.get;
|
|
377
|
+
const decision = yield* decide({ from: routeOf(current), to: target, state: current, kind: "programmatic" });
|
|
378
|
+
if (decision._tag === "Refuse") {
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const next = decision._tag === "Redirect" ? { ...decision.to, replace: true } : { ...decision.to, ...(replace ? { replace: true } : {}) };
|
|
382
|
+
yield* state.update((latest) => Effect.succeed({ ...latest, [key]: next }));
|
|
383
|
+
});
|
|
384
|
+
const commands = (state) => ({
|
|
385
|
+
navigate: Nexus.Command.define("navigate", RouteState, (next) => state.update((current) => Effect.succeed({ ...current, [key]: strip(next) })).pipe(Effect.asVoid)),
|
|
386
|
+
go: Nexus.Command.define("go", Destination, ({ to }) => moveTo(state, to, false)),
|
|
387
|
+
replace: Nexus.Command.define("replace", Destination, ({ to }) => moveTo(state, to, true)),
|
|
388
|
+
});
|
|
389
|
+
return { base, key, notFoundName, table, plugin, commands, match: stateFor, href, hrefOf, resolve, safeNext, decide, titleOf, route: routeNamed, paths };
|
|
390
|
+
};
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Effect } from "effect";
|
|
2
|
+
import { Schema } from "effect";
|
|
3
|
+
/**
|
|
4
|
+
* Where the application is: the route's name and its decoded parameters and query. Plain data (it lives in the application's state, and the server embeds it for the browser), so what a
|
|
5
|
+
* route's Schemas decode to must be JSON: strings, numbers, booleans, null, arrays and records, never a Date or a class.
|
|
6
|
+
*/
|
|
7
|
+
export interface RouteState {
|
|
8
|
+
readonly name: string;
|
|
9
|
+
readonly params: Readonly<Record<string, unknown>>;
|
|
10
|
+
readonly query: Readonly<Record<string, unknown>>;
|
|
11
|
+
/** Set by programmatic navigation that asked to replace the current entry; read by the router's `modeOf`. Not part of the address. */
|
|
12
|
+
readonly replace?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare const RouteState: Schema.Schema<RouteState>;
|
|
15
|
+
/** Where a navigation came from. */
|
|
16
|
+
export type NavigationKind = "link" | "popstate" | "programmatic";
|
|
17
|
+
/** What a guard is told. `state` is the application's state now, before the navigation. */
|
|
18
|
+
export interface GuardContext<S> {
|
|
19
|
+
/** The route being left; `undefined` for the first navigation. */
|
|
20
|
+
readonly from: RouteState | undefined;
|
|
21
|
+
/** The route being entered. */
|
|
22
|
+
readonly to: RouteState;
|
|
23
|
+
readonly state: S;
|
|
24
|
+
readonly kind: NavigationKind;
|
|
25
|
+
/** The `data` of the route the guard belongs to. */
|
|
26
|
+
readonly data: Readonly<Record<string, unknown>>;
|
|
27
|
+
}
|
|
28
|
+
/** Send the navigation to another route instead. Make one with `redirect`. */
|
|
29
|
+
export interface Redirect {
|
|
30
|
+
readonly _tag: "Redirect";
|
|
31
|
+
readonly name: string;
|
|
32
|
+
readonly params: Readonly<Record<string, unknown>>;
|
|
33
|
+
readonly query: Readonly<Record<string, unknown>>;
|
|
34
|
+
}
|
|
35
|
+
/** `true` lets it through, `false` stops it, a `Redirect` sends it elsewhere. */
|
|
36
|
+
export type GuardResult = boolean | Redirect;
|
|
37
|
+
/** A guard may answer now, later (a Promise) or as an Effect that needs nothing. A throw or a failure counts as `false`. */
|
|
38
|
+
export type Guard<S> = (context: GuardContext<S>) => GuardResult | Promise<GuardResult> | Effect.Effect<GuardResult, unknown>;
|
|
39
|
+
/** What the router decided about one navigation. */
|
|
40
|
+
export type Decision = {
|
|
41
|
+
readonly _tag: "Allow";
|
|
42
|
+
readonly to: RouteState;
|
|
43
|
+
} | {
|
|
44
|
+
readonly _tag: "Redirect";
|
|
45
|
+
readonly to: RouteState;
|
|
46
|
+
} | {
|
|
47
|
+
readonly _tag: "Refuse";
|
|
48
|
+
readonly by: "canDeactivate" | "canActivate" | "beforeEach" | "redirects";
|
|
49
|
+
readonly route: string;
|
|
50
|
+
};
|
|
51
|
+
/** The data a route's title function sees. */
|
|
52
|
+
export interface TitleContext<P, Q> {
|
|
53
|
+
readonly params: P;
|
|
54
|
+
readonly query: Q;
|
|
55
|
+
readonly data: Readonly<Record<string, unknown>>;
|
|
56
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
export const RouteState = Schema.Struct({
|
|
3
|
+
name: Schema.String,
|
|
4
|
+
params: Schema.Record({ key: Schema.String, value: Schema.Unknown }),
|
|
5
|
+
query: Schema.Record({ key: Schema.String, value: Schema.Unknown }),
|
|
6
|
+
replace: Schema.optional(Schema.Boolean),
|
|
7
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,66 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@valancex/router",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.12.0",
|
|
4
|
+
"description": "Routing for VALANCE applications: route definitions with typed path and query parameters, matching conditions, guards for entering and leaving a route, programmatic navigation, and a title per route. Plugs into Web.run as the URL policy.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"valance",
|
|
7
|
+
"router",
|
|
8
|
+
"routes",
|
|
9
|
+
"guards"
|
|
10
|
+
],
|
|
11
|
+
"homepage": "https://github.com/ValanceX/Valance/tree/main/packages/router#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/ValanceX/Valance/issues"
|
|
14
|
+
},
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/ValanceX/Valance.git",
|
|
18
|
+
"directory": "packages/router"
|
|
19
|
+
},
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"author": "Jack Miller <jackmiller0899@gmail.com>",
|
|
22
|
+
"type": "module",
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=22"
|
|
25
|
+
},
|
|
26
|
+
"sideEffects": false,
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"default": "./dist/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./package.json": "./package.json"
|
|
33
|
+
},
|
|
34
|
+
"types": "./dist/index.d.ts",
|
|
35
|
+
"files": [
|
|
36
|
+
"dist"
|
|
37
|
+
],
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public",
|
|
40
|
+
"provenance": true
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@valancex/nexus": "^0.12.0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@valancex/valance": "^0.12.0",
|
|
47
|
+
"effect": "^3.10.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/jsdom": "^21.1.7",
|
|
51
|
+
"@types/node": "^22.0.0",
|
|
52
|
+
"@valancex/mesh-compiler": "^0.11.0",
|
|
53
|
+
"@valancex/mesh-runtime": "^0.11.0",
|
|
54
|
+
"@valancex/port-web": "^0.4.0",
|
|
55
|
+
"effect": "^3.10.0",
|
|
56
|
+
"jsdom": "^26.0.0",
|
|
57
|
+
"typescript": "^5.6.0",
|
|
58
|
+
"vitest": "^3.2.0",
|
|
59
|
+
"@valancex/valance": "^0.12.0"
|
|
60
|
+
},
|
|
61
|
+
"scripts": {
|
|
62
|
+
"build": "tsc -p tsconfig.json",
|
|
63
|
+
"typecheck": "tsc -p tsconfig.typecheck.json",
|
|
64
|
+
"test": "vitest run"
|
|
65
|
+
}
|
|
6
66
|
}
|