@kasperrt/smolter 0.0.0-stage → 0.0.1-rc.2
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/LICENSE +21 -0
- package/README.md +470 -2
- package/dist/client.d.ts +2 -0
- package/dist/client.js +2 -0
- package/dist/core.d.ts +2 -0
- package/dist/core.js +2 -0
- package/dist/files.d.ts +2 -0
- package/dist/files.js +2 -0
- package/dist/internal/client/deserialize.d.ts +3 -0
- package/dist/internal/client/deserialize.js +11 -0
- package/dist/internal/client/index.d.ts +23 -0
- package/dist/internal/client/index.js +44 -0
- package/dist/internal/core/errors.d.ts +3 -0
- package/dist/internal/core/errors.js +6 -0
- package/dist/internal/core/index.d.ts +6 -0
- package/dist/internal/core/index.js +6 -0
- package/dist/internal/core/match.d.ts +5 -0
- package/dist/internal/core/match.js +27 -0
- package/dist/internal/core/router.d.ts +3 -0
- package/dist/internal/core/router.js +189 -0
- package/dist/internal/core/types.d.ts +119 -0
- package/dist/internal/core/types.js +5 -0
- package/dist/internal/core/url.d.ts +2 -0
- package/dist/internal/core/url.js +6 -0
- package/dist/internal/files/index.d.ts +11 -0
- package/dist/internal/files/index.js +77 -0
- package/dist/internal/preact/context.d.ts +18 -0
- package/dist/internal/preact/context.js +20 -0
- package/dist/internal/preact/fetcher.d.ts +22 -0
- package/dist/internal/preact/fetcher.js +28 -0
- package/dist/internal/preact/form.d.ts +17 -0
- package/dist/internal/preact/form.js +41 -0
- package/dist/internal/preact/hooks.d.ts +21 -0
- package/dist/internal/preact/hooks.js +35 -0
- package/dist/internal/preact/index.d.ts +9 -0
- package/dist/internal/preact/index.js +7 -0
- package/dist/internal/preact/link.d.ts +12 -0
- package/dist/internal/preact/link.js +28 -0
- package/dist/internal/preact/routes.d.ts +22 -0
- package/dist/internal/preact/routes.js +22 -0
- package/dist/internal/preact/runtime.d.ts +2 -0
- package/dist/internal/preact/runtime.js +1 -0
- package/dist/internal/react/context.d.ts +18 -0
- package/dist/internal/react/context.js +20 -0
- package/dist/internal/react/fetcher.d.ts +22 -0
- package/dist/internal/react/fetcher.js +28 -0
- package/dist/internal/react/form.d.ts +17 -0
- package/dist/internal/react/form.js +41 -0
- package/dist/internal/react/hooks.d.ts +21 -0
- package/dist/internal/react/hooks.js +35 -0
- package/dist/internal/react/index.d.ts +9 -0
- package/dist/internal/react/index.js +7 -0
- package/dist/internal/react/link.d.ts +12 -0
- package/dist/internal/react/link.js +28 -0
- package/dist/internal/react/routes.d.ts +22 -0
- package/dist/internal/react/routes.js +22 -0
- package/dist/internal/react/runtime.d.ts +2 -0
- package/dist/internal/react/runtime.js +2 -0
- package/dist/internal/server/index.d.ts +11 -0
- package/dist/internal/server/index.js +81 -0
- package/dist/internal/vite/index.d.ts +10 -0
- package/dist/internal/vite/index.js +132 -0
- package/dist/internal/vite/transport.d.ts +2 -0
- package/dist/internal/vite/transport.js +42 -0
- package/dist/preact.d.ts +2 -0
- package/dist/preact.js +2 -0
- package/dist/react.d.ts +2 -0
- package/dist/react.js +2 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +2 -0
- package/dist/vite.d.ts +2 -0
- package/dist/vite.js +2 -0
- package/jsr.json +26 -0
- package/package.json +94 -4
- package/src/client.ts +2 -0
- package/src/core.ts +2 -0
- package/src/files.ts +2 -0
- package/src/internal/client/deserialize.ts +15 -0
- package/src/internal/client/index.ts +84 -0
- package/src/internal/core/errors.ts +13 -0
- package/src/internal/core/index.ts +7 -0
- package/src/internal/core/match.ts +45 -0
- package/src/internal/core/router.ts +251 -0
- package/src/internal/core/types.ts +151 -0
- package/src/internal/core/url.ts +6 -0
- package/src/internal/files/index.ts +112 -0
- package/src/internal/preact/context.ts +46 -0
- package/src/internal/preact/fetcher.ts +83 -0
- package/src/internal/preact/form.ts +79 -0
- package/src/internal/preact/hooks.ts +72 -0
- package/src/internal/preact/index.ts +13 -0
- package/src/internal/preact/link.ts +46 -0
- package/src/internal/preact/routes.ts +50 -0
- package/src/internal/preact/runtime.ts +15 -0
- package/src/internal/react/context.ts +46 -0
- package/src/internal/react/fetcher.ts +83 -0
- package/src/internal/react/form.ts +79 -0
- package/src/internal/react/hooks.ts +72 -0
- package/src/internal/react/index.ts +13 -0
- package/src/internal/react/link.ts +46 -0
- package/src/internal/react/routes.ts +50 -0
- package/src/internal/react/runtime.ts +18 -0
- package/src/internal/server/index.ts +120 -0
- package/src/internal/vite/index.ts +210 -0
- package/src/internal/vite/transport.ts +60 -0
- package/src/preact.ts +2 -0
- package/src/react.ts +2 -0
- package/src/server.ts +2 -0
- package/src/vite.ts +2 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export function configureTransport(server, publicId) {
|
|
2
|
+
server.middlewares.use((incoming, outgoing, next) => {
|
|
3
|
+
if (!incoming.url?.startsWith("/_smolter?"))
|
|
4
|
+
return next();
|
|
5
|
+
const dispatch = async () => {
|
|
6
|
+
const origin = `http://${incoming.headers.host}`;
|
|
7
|
+
const target = new URL(incoming.url, origin).searchParams.get("url");
|
|
8
|
+
if (!target?.startsWith("/") || target.startsWith("//")) {
|
|
9
|
+
outgoing.statusCode = 400;
|
|
10
|
+
outgoing.end("Invalid route URL");
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const headers = new Headers();
|
|
14
|
+
for (const [name, value] of Object.entries(incoming.headers)) {
|
|
15
|
+
if (value)
|
|
16
|
+
headers.set(name, Array.isArray(value) ? value.join(", ") : value);
|
|
17
|
+
}
|
|
18
|
+
const chunks = [];
|
|
19
|
+
for await (const chunk of incoming)
|
|
20
|
+
chunks.push(chunk);
|
|
21
|
+
const controller = new AbortController();
|
|
22
|
+
outgoing.once("close", () => controller.abort());
|
|
23
|
+
const request = new Request(new URL(target, origin), {
|
|
24
|
+
signal: controller.signal,
|
|
25
|
+
method: incoming.method,
|
|
26
|
+
headers,
|
|
27
|
+
...(incoming.method !== "GET" && {
|
|
28
|
+
body: new Uint8Array(Buffer.concat(chunks)),
|
|
29
|
+
}),
|
|
30
|
+
});
|
|
31
|
+
const [{ default: routes }, { handleRequest }] = await Promise.all([
|
|
32
|
+
server.ssrLoadModule(publicId),
|
|
33
|
+
server.ssrLoadModule("@kasperrt/smolter/server"),
|
|
34
|
+
]);
|
|
35
|
+
const response = await handleRequest(routes, request);
|
|
36
|
+
outgoing.statusCode = response.status;
|
|
37
|
+
response.headers.forEach((value, name) => outgoing.setHeader(name, value));
|
|
38
|
+
outgoing.end(new Uint8Array(await response.arrayBuffer()));
|
|
39
|
+
};
|
|
40
|
+
void dispatch().catch(next);
|
|
41
|
+
});
|
|
42
|
+
}
|
package/dist/preact.d.ts
ADDED
package/dist/preact.js
ADDED
package/dist/react.d.ts
ADDED
package/dist/react.js
ADDED
package/dist/server.d.ts
ADDED
package/dist/server.js
ADDED
package/dist/vite.d.ts
ADDED
package/dist/vite.js
ADDED
package/jsr.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kasperrt/smolter",
|
|
3
|
+
"version": "0.0.1-rc.2",
|
|
4
|
+
"exports": {
|
|
5
|
+
".": "./src/core.ts",
|
|
6
|
+
"./preact": "./src/preact.ts",
|
|
7
|
+
"./core": "./src/core.ts",
|
|
8
|
+
"./server": "./src/server.ts",
|
|
9
|
+
"./client": "./src/client.ts",
|
|
10
|
+
"./files": "./src/files.ts",
|
|
11
|
+
"./react": "./src/react.ts"
|
|
12
|
+
},
|
|
13
|
+
"imports": {
|
|
14
|
+
"react": "npm:react@19.3.1",
|
|
15
|
+
"preact/compat": "npm:preact@11.0.1/compat"
|
|
16
|
+
},
|
|
17
|
+
"publish": {
|
|
18
|
+
"include": ["src", "README.md", "LICENSE", "jsr.json"],
|
|
19
|
+
"exclude": ["src/vite.ts", "src/internal/vite"]
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"compatibility": {
|
|
23
|
+
"node": ">=22",
|
|
24
|
+
"browser": true
|
|
25
|
+
}
|
|
26
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,96 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kasperrt/smolter",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.1-rc.2",
|
|
4
|
+
"description": "A tiny typed data router for React and Preact.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/kasperrt/smolter.git",
|
|
11
|
+
"directory": "packages/smolter"
|
|
12
|
+
},
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/core.d.ts",
|
|
16
|
+
"import": "./dist/core.js"
|
|
17
|
+
},
|
|
18
|
+
"./preact": {
|
|
19
|
+
"types": "./dist/preact.d.ts",
|
|
20
|
+
"import": "./dist/preact.js"
|
|
21
|
+
},
|
|
22
|
+
"./core": {
|
|
23
|
+
"types": "./dist/core.d.ts",
|
|
24
|
+
"import": "./dist/core.js"
|
|
25
|
+
},
|
|
26
|
+
"./server": {
|
|
27
|
+
"types": "./dist/server.d.ts",
|
|
28
|
+
"import": "./dist/server.js"
|
|
29
|
+
},
|
|
30
|
+
"./files": {
|
|
31
|
+
"types": "./dist/files.d.ts",
|
|
32
|
+
"import": "./dist/files.js"
|
|
33
|
+
},
|
|
34
|
+
"./vite": {
|
|
35
|
+
"types": "./dist/vite.d.ts",
|
|
36
|
+
"import": "./dist/vite.js"
|
|
37
|
+
},
|
|
38
|
+
"./client": {
|
|
39
|
+
"types": "./dist/client.d.ts",
|
|
40
|
+
"import": "./dist/client.js"
|
|
41
|
+
},
|
|
42
|
+
"./package.json": "./package.json",
|
|
43
|
+
"./react": {
|
|
44
|
+
"types": "./dist/react.d.ts",
|
|
45
|
+
"import": "./dist/react.js"
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"files": [
|
|
49
|
+
"dist",
|
|
50
|
+
"src",
|
|
51
|
+
"README.md",
|
|
52
|
+
"LICENSE",
|
|
53
|
+
"jsr.json"
|
|
54
|
+
],
|
|
55
|
+
"scripts": {
|
|
56
|
+
"build": "node ../../scripts/build.mjs"
|
|
57
|
+
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"react": ">=18",
|
|
60
|
+
"preact": ">=10.25.0",
|
|
61
|
+
"vite": ">=8"
|
|
62
|
+
},
|
|
63
|
+
"peerDependenciesMeta": {
|
|
64
|
+
"react": {
|
|
65
|
+
"optional": true
|
|
66
|
+
},
|
|
67
|
+
"preact": {
|
|
68
|
+
"optional": true
|
|
69
|
+
},
|
|
70
|
+
"vite": {
|
|
71
|
+
"optional": true
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
"author": "Kasper Rynning-Tønnesen <kasperrt@users.noreply.github.com> (https://github.com/kasperrt)",
|
|
75
|
+
"homepage": "https://kasperrt.github.io/smolter/",
|
|
76
|
+
"bugs": {
|
|
77
|
+
"url": "https://github.com/kasperrt/smolter/issues"
|
|
78
|
+
},
|
|
79
|
+
"keywords": [
|
|
80
|
+
"router",
|
|
81
|
+
"react",
|
|
82
|
+
"preact",
|
|
83
|
+
"typescript",
|
|
84
|
+
"loader",
|
|
85
|
+
"action",
|
|
86
|
+
"fetcher",
|
|
87
|
+
"ssr",
|
|
88
|
+
"file-routing"
|
|
89
|
+
],
|
|
90
|
+
"main": "./dist/core.js",
|
|
91
|
+
"types": "./dist/core.d.ts",
|
|
92
|
+
"publishConfig": {
|
|
93
|
+
"access": "public",
|
|
94
|
+
"provenance": true
|
|
95
|
+
}
|
|
96
|
+
}
|
package/src/client.ts
ADDED
package/src/core.ts
ADDED
package/src/files.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Snapshot } from "../core/index.ts";
|
|
2
|
+
|
|
3
|
+
/** Restore a trusted snapshot produced by @kasperrt/smolter/server serialize, including its route error. */
|
|
4
|
+
export function deserialize(json: string): Snapshot {
|
|
5
|
+
const snapshot: Snapshot = JSON.parse(json);
|
|
6
|
+
const error = snapshot.error;
|
|
7
|
+
|
|
8
|
+
if (error) {
|
|
9
|
+
snapshot.error = Object.assign(new Error(error.message), {
|
|
10
|
+
name: error.name,
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return snapshot;
|
|
15
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional browser transport for server loaders and actions.
|
|
3
|
+
* @module
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
ClientActionArgs,
|
|
8
|
+
ClientLoaderArgs,
|
|
9
|
+
DataArgs,
|
|
10
|
+
Route,
|
|
11
|
+
RouteModule,
|
|
12
|
+
} from "../core/index.ts";
|
|
13
|
+
|
|
14
|
+
export type { ClientActionArgs, ClientLoaderArgs } from "../core/index.ts";
|
|
15
|
+
export { deserialize } from "./deserialize.ts";
|
|
16
|
+
|
|
17
|
+
/** Browser-safe route exports, excluding server implementation code. */
|
|
18
|
+
export interface ClientRouteModule {
|
|
19
|
+
default?: unknown;
|
|
20
|
+
HydrateFallback?: unknown;
|
|
21
|
+
ErrorBoundary?: unknown;
|
|
22
|
+
clientLoader?(args: ClientLoaderArgs): unknown;
|
|
23
|
+
clientAction?(args: ClientActionArgs): unknown;
|
|
24
|
+
shouldRevalidate?: RouteModule["shouldRevalidate"];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Select the server loader and action proxies available on a browser route. */
|
|
28
|
+
export interface ServerHandlers {
|
|
29
|
+
loader?: boolean;
|
|
30
|
+
action?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Browser transport for server routes. File-router builds generate this call. */
|
|
34
|
+
export function serverRoute<M extends RouteModule = RouteModule>(
|
|
35
|
+
path: string,
|
|
36
|
+
module: ClientRouteModule,
|
|
37
|
+
handlers: ServerHandlers = { loader: true, action: true },
|
|
38
|
+
): Route<M> {
|
|
39
|
+
const remote = async ({ request, signal }: DataArgs) => {
|
|
40
|
+
const url = new URL(request.url);
|
|
41
|
+
const response = await fetch(
|
|
42
|
+
`/_smolter?url=${encodeURIComponent(url.pathname + url.search)}`,
|
|
43
|
+
{
|
|
44
|
+
method: request.method,
|
|
45
|
+
signal,
|
|
46
|
+
...(request.method !== "GET" && { body: await request.formData() }),
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
const location = response.headers.get("X-Smolter-Redirect");
|
|
50
|
+
|
|
51
|
+
if (location)
|
|
52
|
+
return new Response(null, {
|
|
53
|
+
status: 303,
|
|
54
|
+
headers: { Location: location },
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const data = await response.json();
|
|
58
|
+
|
|
59
|
+
if (!response.ok) return new Error(data.error ?? "Route request failed");
|
|
60
|
+
|
|
61
|
+
return data;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
...module,
|
|
66
|
+
path,
|
|
67
|
+
...(handlers.loader && { loader: remote }),
|
|
68
|
+
...(handlers.action && { action: remote }),
|
|
69
|
+
...(module.clientLoader && {
|
|
70
|
+
clientLoader: (args: DataArgs) =>
|
|
71
|
+
module.clientLoader!({ ...args, serverLoader: () => remote(args) }),
|
|
72
|
+
}),
|
|
73
|
+
...(module.clientAction && {
|
|
74
|
+
clientAction: (args: DataArgs) => {
|
|
75
|
+
const request = args.request.clone();
|
|
76
|
+
|
|
77
|
+
return module.clientAction!({
|
|
78
|
+
...args,
|
|
79
|
+
serverAction: () => remote({ ...args, request: request.clone() }),
|
|
80
|
+
});
|
|
81
|
+
},
|
|
82
|
+
}),
|
|
83
|
+
} as Route<M>;
|
|
84
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Result } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/** Resolve returned or rejected errors into Error values without losing their cause. */
|
|
4
|
+
export function safeWrapAsync<T>(
|
|
5
|
+
work: () => T | Promise<T>,
|
|
6
|
+
): Promise<Result<T>> {
|
|
7
|
+
return Promise.resolve()
|
|
8
|
+
.then(work)
|
|
9
|
+
.then(
|
|
10
|
+
(data) => (data instanceof Error ? { error: data } : { data }),
|
|
11
|
+
(cause: unknown) => ({ error: new Error(String(cause), { cause }) }),
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Match, Params, Route, RouteModule } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/** Associate a URL pattern with a module while retaining its inferred handler types. */
|
|
4
|
+
export function defineRoute<const M extends RouteModule>(
|
|
5
|
+
path: string,
|
|
6
|
+
module: M,
|
|
7
|
+
): Route<M> {
|
|
8
|
+
return { ...module, path };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Static segments, :params and a terminal * wildcard; first match wins. */
|
|
12
|
+
export function matchRoutes(
|
|
13
|
+
routes: readonly Route[],
|
|
14
|
+
pathname: string,
|
|
15
|
+
): Match | undefined {
|
|
16
|
+
const segments = pathname.replace(/\/$/, "").split("/");
|
|
17
|
+
|
|
18
|
+
for (const route of routes) {
|
|
19
|
+
const parts = route.path.replace(/\/$/, "").split("/");
|
|
20
|
+
const params: Params = {};
|
|
21
|
+
const matched =
|
|
22
|
+
(parts.length === segments.length || parts.at(-1) === "*") &&
|
|
23
|
+
parts.every((part, index) => {
|
|
24
|
+
const value = segments[index];
|
|
25
|
+
|
|
26
|
+
if (part === "*") {
|
|
27
|
+
params["*"] = segments.slice(index).join("/");
|
|
28
|
+
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (part[0] === ":" && value) {
|
|
33
|
+
params[part.slice(1)] = new URLSearchParams(
|
|
34
|
+
`=${value.replace(/[+&]/g, encodeURIComponent)}`,
|
|
35
|
+
).get("")!;
|
|
36
|
+
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return part === value;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (matched) return { route, params };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { safeWrapAsync } from "./errors.ts";
|
|
2
|
+
import { matchRoutes } from "./match.ts";
|
|
3
|
+
import type {
|
|
4
|
+
DataFunction,
|
|
5
|
+
NavigateOptions,
|
|
6
|
+
Result,
|
|
7
|
+
Route,
|
|
8
|
+
Router,
|
|
9
|
+
RouterOptions,
|
|
10
|
+
Snapshot,
|
|
11
|
+
SubmitOptions,
|
|
12
|
+
} from "./types.ts";
|
|
13
|
+
import { internalURL } from "./url.ts";
|
|
14
|
+
|
|
15
|
+
type ChannelSnapshot = Partial<Snapshot> & Pick<Snapshot, "state">;
|
|
16
|
+
|
|
17
|
+
interface OperationOptions extends SubmitOptions, NavigateOptions {
|
|
18
|
+
hops?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Create an isolated router. Use browser: true for browser history, or loadRouter for SSR. */
|
|
22
|
+
export function createRouter(
|
|
23
|
+
routes: readonly Route[],
|
|
24
|
+
options: RouterOptions = {},
|
|
25
|
+
): Router {
|
|
26
|
+
const browser = options.browser && typeof window !== "undefined";
|
|
27
|
+
let initialized = options.hydrationData?.state === "idle";
|
|
28
|
+
const initial: Snapshot = {
|
|
29
|
+
loaded: initialized,
|
|
30
|
+
...(options.hydrationData ?? {
|
|
31
|
+
url: new URL(
|
|
32
|
+
options.url ?? (browser ? location.href : "/"),
|
|
33
|
+
"http://localhost",
|
|
34
|
+
).href,
|
|
35
|
+
params: {},
|
|
36
|
+
state: "idle",
|
|
37
|
+
}),
|
|
38
|
+
};
|
|
39
|
+
const disposers = new Set<() => void>();
|
|
40
|
+
const channel = <S extends ChannelSnapshot>(initial: S, local = false) => {
|
|
41
|
+
let snapshot = initial;
|
|
42
|
+
let controller: AbortController | undefined;
|
|
43
|
+
const listeners = new Set<() => void>();
|
|
44
|
+
const dispose = () => {
|
|
45
|
+
disposers.delete(dispose);
|
|
46
|
+
controller?.abort();
|
|
47
|
+
listeners.clear();
|
|
48
|
+
};
|
|
49
|
+
const emit = (next: Partial<Snapshot>) => {
|
|
50
|
+
snapshot = { ...snapshot, ...next };
|
|
51
|
+
|
|
52
|
+
for (const listener of listeners) listener();
|
|
53
|
+
};
|
|
54
|
+
const run = async (
|
|
55
|
+
href: string,
|
|
56
|
+
body?: FormData,
|
|
57
|
+
operation: OperationOptions = {},
|
|
58
|
+
// true pushes history; false handles popstate; undefined is a data request.
|
|
59
|
+
navigation?: boolean,
|
|
60
|
+
): Promise<Result> => {
|
|
61
|
+
controller?.abort();
|
|
62
|
+
|
|
63
|
+
controller = new AbortController();
|
|
64
|
+
|
|
65
|
+
const { signal } = controller;
|
|
66
|
+
disposers.add(dispose);
|
|
67
|
+
|
|
68
|
+
const previous = snapshot;
|
|
69
|
+
const url = internalURL(main.getSnapshot().url, href);
|
|
70
|
+
const match = url && matchRoutes(routes, url.pathname);
|
|
71
|
+
const commit = () => {
|
|
72
|
+
if (local || !url) return;
|
|
73
|
+
|
|
74
|
+
const changed = snapshot.url !== url.href;
|
|
75
|
+
|
|
76
|
+
if (!changed && snapshot.path !== undefined) return;
|
|
77
|
+
|
|
78
|
+
if (browser && (navigation || body) && changed)
|
|
79
|
+
history[operation.replace ? "replaceState" : "pushState"](
|
|
80
|
+
null,
|
|
81
|
+
"",
|
|
82
|
+
url,
|
|
83
|
+
);
|
|
84
|
+
const sameRoute = snapshot.path === match?.route.path;
|
|
85
|
+
|
|
86
|
+
snapshot = {
|
|
87
|
+
...snapshot,
|
|
88
|
+
url: url.href,
|
|
89
|
+
path: match?.route.path,
|
|
90
|
+
params: match?.params ?? {},
|
|
91
|
+
actionData: undefined,
|
|
92
|
+
data: sameRoute ? snapshot.data : undefined,
|
|
93
|
+
loaded: sameRoute && snapshot.loaded,
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
if (!body) commit();
|
|
98
|
+
emit({ state: body ? "submitting" : "loading", error: undefined });
|
|
99
|
+
|
|
100
|
+
const result = await safeWrapAsync(() => {
|
|
101
|
+
if (!match) return new Error(url ? "No route" : "External URL");
|
|
102
|
+
|
|
103
|
+
if (
|
|
104
|
+
navigation !== undefined &&
|
|
105
|
+
!body &&
|
|
106
|
+
previous.loaded &&
|
|
107
|
+
!previous.error &&
|
|
108
|
+
previous.path === match.route.path &&
|
|
109
|
+
match.route.shouldRevalidate?.({
|
|
110
|
+
url,
|
|
111
|
+
currentUrl: new URL(previous.url!),
|
|
112
|
+
nextUrl: url,
|
|
113
|
+
defaultShouldRevalidate: true,
|
|
114
|
+
reason: "navigation",
|
|
115
|
+
}) === false
|
|
116
|
+
)
|
|
117
|
+
return previous.data;
|
|
118
|
+
|
|
119
|
+
const fn =
|
|
120
|
+
match.route[body ? "clientAction" : "clientLoader"] ||
|
|
121
|
+
match.route[body ? "action" : "loader"];
|
|
122
|
+
|
|
123
|
+
if (!fn) return body && new Error("No action");
|
|
124
|
+
|
|
125
|
+
return (fn as DataFunction)({
|
|
126
|
+
request: new Request(url, {
|
|
127
|
+
signal,
|
|
128
|
+
method: body && (operation.method ?? "POST"),
|
|
129
|
+
body,
|
|
130
|
+
}),
|
|
131
|
+
params: match.params,
|
|
132
|
+
signal,
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
if (signal.aborted) return result;
|
|
137
|
+
|
|
138
|
+
const redirectTo =
|
|
139
|
+
result.data instanceof Response && result.data.headers.get("Location");
|
|
140
|
+
|
|
141
|
+
if (redirectTo && (operation.hops ?? 0) < 10) {
|
|
142
|
+
await main.load(
|
|
143
|
+
new URL(redirectTo, url!).href,
|
|
144
|
+
undefined,
|
|
145
|
+
{
|
|
146
|
+
replace: true,
|
|
147
|
+
hops: (operation.hops ?? 0) + 1,
|
|
148
|
+
},
|
|
149
|
+
true,
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
if (!signal.aborted) emit({ state: "idle" });
|
|
153
|
+
|
|
154
|
+
return result;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (redirectTo) result.error = new Error("Redirect loop");
|
|
158
|
+
|
|
159
|
+
if (body && !result.error) commit();
|
|
160
|
+
|
|
161
|
+
emit({
|
|
162
|
+
[body && !local ? "actionData" : "data"]: result.data,
|
|
163
|
+
error: result.error,
|
|
164
|
+
loaded: body ? snapshot.loaded : !result.error,
|
|
165
|
+
state: body && !result.error ? "loading" : "idle",
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
if (body && !result.error) {
|
|
169
|
+
await router.revalidate("action", {
|
|
170
|
+
formMethod: operation.method ?? "POST",
|
|
171
|
+
formAction: url!.href,
|
|
172
|
+
formData: body,
|
|
173
|
+
actionResult: result.data,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
if (!signal.aborted) emit({ state: "idle" });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return result;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
getSnapshot: () => snapshot,
|
|
184
|
+
subscribe(listener: () => void) {
|
|
185
|
+
listeners.add(listener);
|
|
186
|
+
|
|
187
|
+
return () => {
|
|
188
|
+
listeners.delete(listener);
|
|
189
|
+
};
|
|
190
|
+
},
|
|
191
|
+
load: run,
|
|
192
|
+
submit: run,
|
|
193
|
+
dispose,
|
|
194
|
+
};
|
|
195
|
+
};
|
|
196
|
+
const main = channel(initial);
|
|
197
|
+
let started = 0;
|
|
198
|
+
const pop = () => {
|
|
199
|
+
void main.load(location.href, undefined, undefined, false);
|
|
200
|
+
};
|
|
201
|
+
const router: Router = {
|
|
202
|
+
...main,
|
|
203
|
+
routes,
|
|
204
|
+
start() {
|
|
205
|
+
if (!started++ && browser) addEventListener("popstate", pop);
|
|
206
|
+
|
|
207
|
+
if (!initialized) {
|
|
208
|
+
initialized = true;
|
|
209
|
+
void main.load(initial.url);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return () => {
|
|
213
|
+
if (!--started && browser) removeEventListener("popstate", pop);
|
|
214
|
+
};
|
|
215
|
+
},
|
|
216
|
+
async navigate(url, operation) {
|
|
217
|
+
initialized = true;
|
|
218
|
+
await main.load(url, undefined, operation, true);
|
|
219
|
+
},
|
|
220
|
+
async revalidate(reason = "manual", context = {}) {
|
|
221
|
+
const url = new URL(main.getSnapshot().url);
|
|
222
|
+
|
|
223
|
+
if (
|
|
224
|
+
matchRoutes(routes, url.pathname)?.route.shouldRevalidate?.({
|
|
225
|
+
...context,
|
|
226
|
+
url,
|
|
227
|
+
currentUrl: url,
|
|
228
|
+
nextUrl: url,
|
|
229
|
+
defaultShouldRevalidate: true,
|
|
230
|
+
reason,
|
|
231
|
+
}) === false
|
|
232
|
+
)
|
|
233
|
+
return;
|
|
234
|
+
await main.load(url.href);
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
createFetcher() {
|
|
238
|
+
return channel({ state: "idle" }, true);
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
dispose() {
|
|
242
|
+
main.dispose();
|
|
243
|
+
|
|
244
|
+
for (const dispose of disposers) dispose();
|
|
245
|
+
|
|
246
|
+
if (browser) removeEventListener("popstate", pop);
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
return router;
|
|
251
|
+
}
|