ignotum 0.0.13 → 0.0.15
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 +6 -6
- package/dist/cli/bin.mjs +6111 -4448
- package/dist/cli/bin.mjs.map +1 -1
- package/dist/cli/emscripten-module-D5GzfUNv.mjs +533 -0
- package/dist/cli/emscripten-module-D5GzfUNv.mjs.map +1 -0
- package/dist/cli/ffi-7IElU4Co.mjs +148 -0
- package/dist/cli/ffi-7IElU4Co.mjs.map +1 -0
- package/dist/cli/managed-release-WHYLZwaS.mjs +10902 -0
- package/dist/cli/managed-release-WHYLZwaS.mjs.map +1 -0
- package/dist/cli/managed-release.d.mts +1 -0
- package/dist/cli/managed-release.mjs +14 -0
- package/dist/cli/managed-release.mjs.map +1 -0
- package/dist/cli/module-ES6BEMUI-D36I4NVn.mjs +1289 -0
- package/dist/cli/module-ES6BEMUI-D36I4NVn.mjs.map +1 -0
- package/dist/runtime/{api-Cv3hMbzo.d.ts → api-B4fwp5a2.d.ts} +4 -4
- package/dist/runtime/bootstrap-BISQSn_O.js +916 -0
- package/dist/runtime/bootstrap-BISQSn_O.js.map +1 -0
- package/dist/runtime/bootstrap-BTFScKAc.d.ts +545 -0
- package/dist/runtime/client.d.ts +50 -15
- package/dist/runtime/client.js +7 -1138
- package/dist/runtime/client.js.map +1 -1
- package/dist/runtime/descriptor-CTY4mtwS-cS43EbwS.js +269 -0
- package/dist/runtime/descriptor-CTY4mtwS-cS43EbwS.js.map +1 -0
- package/dist/runtime/{id-Bt9XWRGL.js → id--1m0NuUL.js} +4 -4
- package/dist/runtime/id--1m0NuUL.js.map +1 -0
- package/dist/runtime/{id-BzFHf3Wo-DGPCjgrf.d.ts → id-7an3nxTu-DZJSM5xh.d.ts} +2 -2
- package/dist/runtime/id-CBOt2kDo.d.ts +1 -0
- package/dist/runtime/identity-QjtJRxBD.d.ts +2 -0
- package/dist/runtime/image-Djtjy4Z8.js +19 -0
- package/dist/runtime/image-Djtjy4Z8.js.map +1 -0
- package/dist/runtime/index-D4seNHlX.d.ts +184 -0
- package/dist/runtime/internal/api.d.ts +1 -1
- package/dist/runtime/internal/api.js +30 -1
- package/dist/runtime/internal/api.js.map +1 -0
- package/dist/runtime/internal/bootstrap.d.ts +2 -0
- package/dist/runtime/internal/bootstrap.js +2 -0
- package/dist/runtime/internal/client.d.ts +2 -0
- package/dist/runtime/internal/client.js +75 -0
- package/dist/runtime/internal/client.js.map +1 -0
- package/dist/runtime/internal/host.d.ts +25 -8
- package/dist/runtime/internal/host.js +27 -6
- package/dist/runtime/internal/host.js.map +1 -1
- package/dist/runtime/internal/routes.d.ts +2 -0
- package/dist/runtime/internal/routes.js +2 -0
- package/dist/runtime/internal/server.d.ts +1 -1
- package/dist/runtime/internal/server.js +1 -1
- package/dist/runtime/internal/types.d.ts +2 -1
- package/dist/runtime/internal/types.js +1 -1
- package/dist/runtime/pagination-CBOt2kDo.d.ts +1 -0
- package/dist/runtime/pagination-D3qd6s9N.js +33 -0
- package/dist/runtime/pagination-D3qd6s9N.js.map +1 -0
- package/dist/runtime/result-BkziOG1L.d.ts +1 -0
- package/dist/runtime/router-BSBI1oN1.js +2377 -0
- package/dist/runtime/router-BSBI1oN1.js.map +1 -0
- package/dist/runtime/routes-Ce8TVP-T.js +690 -0
- package/dist/runtime/routes-Ce8TVP-T.js.map +1 -0
- package/dist/runtime/{schema-1Zs03-iS.js → schema-D1wOqiNH.js} +40 -12
- package/dist/runtime/schema-D1wOqiNH.js.map +1 -0
- package/dist/runtime/server.d.ts +5 -5
- package/dist/runtime/server.js +2 -2
- package/dist/runtime/{sync-Bs8J3fIr.d.ts → sync-D-GK2sv9.d.ts} +3 -2
- package/dist/runtime/{api-CAgKDij7.js → sync-DzUwA8W9.js} +22 -41
- package/dist/runtime/sync-DzUwA8W9.js.map +1 -0
- package/dist/runtime/types-DeCCyqLU-BIm7rU-0.d.ts +341 -0
- package/package.json +29 -4
- package/src/cli/agent-files.ts +14 -8
- package/src/cli/build/managed-client.ts +411 -0
- package/src/cli/build/managed-server.ts +294 -0
- package/src/cli/build/managed.ts +67 -0
- package/src/cli/build/output.ts +47 -0
- package/src/cli/build/server.ts +51 -220
- package/src/cli/client-config.ts +1 -140
- package/src/cli/client-styles.ts +1 -0
- package/src/cli/codegen.ts +9 -4
- package/src/cli/command.ts +28 -5
- package/src/cli/control-client.ts +84 -101
- package/src/cli/deploy.ts +133 -177
- package/src/cli/dev.ts +506 -107
- package/src/cli/image-assets.ts +499 -0
- package/src/cli/managed-client-boundaries.ts +103 -0
- package/src/cli/managed-dev-platform.ts +118 -0
- package/src/cli/managed-exports.ts +219 -0
- package/src/cli/managed-release-bin.ts +13 -0
- package/src/cli/managed-release.ts +260 -0
- package/src/cli/module-boundaries.ts +11 -1
- package/src/cli/new-app.ts +111 -87
- package/src/cli/route-codegen.ts +311 -0
- package/src/cli/route-static.ts +710 -0
- package/src/client/bootstrap.ts +110 -0
- package/src/client/errors.ts +13 -7
- package/src/client/files.ts +64 -0
- package/src/client/id.ts +9 -5
- package/src/client/image.ts +28 -0
- package/src/client/index.ts +25 -2
- package/src/client/managed-client.ts +729 -0
- package/src/client/managed-upgrade.ts +283 -0
- package/src/client/recovery-journal.ts +195 -0
- package/src/client/route-boundaries.ts +184 -0
- package/src/client/router-history.ts +152 -0
- package/src/client/router-store.ts +644 -0
- package/src/client/router.ts +324 -0
- package/src/client/routes.ts +28 -0
- package/src/client/sync.ts +66 -592
- package/src/dev-runtime/database.ts +77 -2
- package/src/dev-runtime/functions.ts +3 -3
- package/src/dev-runtime/id.ts +19 -4
- package/src/dev-runtime/managed-functions.ts +255 -0
- package/src/dev-runtime/managed-sync.ts +263 -0
- package/src/dev-runtime/managed-websocket.ts +71 -0
- package/src/dev-runtime/migrations.ts +20 -0
- package/src/dev-runtime/sync.ts +236 -236
- package/src/image-assets.d.ts +36 -0
- package/src/internal/client.ts +186 -0
- package/src/internal/host.ts +1 -1
- package/src/internal/http-paths.ts +0 -3
- package/src/internal/routes.ts +45 -0
- package/dist/runtime/api-CAgKDij7.js.map +0 -1
- package/dist/runtime/descriptor-Cyo9FP9n-C0SRVXNW.js +0 -154
- package/dist/runtime/descriptor-Cyo9FP9n-C0SRVXNW.js.map +0 -1
- package/dist/runtime/id-Bt9XWRGL.js.map +0 -1
- package/dist/runtime/id-Dz0apuB3.d.ts +0 -1
- package/dist/runtime/index-D54flWtH.d.ts +0 -402
- package/dist/runtime/pagination-DnKg3dkI-r5ZUxBBx.d.ts +0 -112
- package/dist/runtime/pagination-Dz0apuB3.d.ts +0 -1
- package/dist/runtime/result-DKAA4gpS.d.ts +0 -1
- package/dist/runtime/schema-1Zs03-iS.js.map +0 -1
- package/src/cli/build/client.ts +0 -119
- package/src/cli/build/public.ts +0 -186
- package/src/cli/client-entry.ts +0 -152
- package/src/cli/client-plugin.ts +0 -136
- package/src/client/app.ts +0 -15
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AppModuleId,
|
|
3
|
+
LayoutDefinition,
|
|
4
|
+
ModuleExportReference,
|
|
5
|
+
RouteDefinition,
|
|
6
|
+
} from "@ignotum/contracts/app-definition";
|
|
7
|
+
import { ExecutionSelection } from "@ignotum/contracts/platform";
|
|
8
|
+
import { RouterHistory, type HistoryPosition } from "./router-history.js";
|
|
9
|
+
import {
|
|
10
|
+
compilePatterns,
|
|
11
|
+
matchLocation,
|
|
12
|
+
resolveMetadata,
|
|
13
|
+
type RouteOutcome,
|
|
14
|
+
type UrlValue,
|
|
15
|
+
type UrlIssue,
|
|
16
|
+
} from "@ignotum/shared/routing";
|
|
17
|
+
import { Deferred, Effect, Predicate, Schema } from "effect";
|
|
18
|
+
import type { ComponentChildren, ComponentType } from "preact";
|
|
19
|
+
import type { NavigationResult, RouteLocation } from "./routes.js";
|
|
20
|
+
|
|
21
|
+
export interface RegisteredClientModule {
|
|
22
|
+
readonly load: () => Promise<object>;
|
|
23
|
+
readonly styles?: readonly string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Executable registration produced by the compiler, separate from the immutable declaration. */
|
|
27
|
+
export interface ClientRegistration {
|
|
28
|
+
readonly applicationName: string;
|
|
29
|
+
readonly routes: readonly RouteDefinition[];
|
|
30
|
+
readonly layouts: readonly LayoutDefinition[];
|
|
31
|
+
readonly modules: ReadonlyMap<AppModuleId, RegisteredClientModule>;
|
|
32
|
+
readonly styles?: readonly string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface RouterPageProps {
|
|
36
|
+
readonly params: Readonly<Record<string, UrlValue>>;
|
|
37
|
+
readonly searchParams: Readonly<Record<string, UrlValue>>;
|
|
38
|
+
}
|
|
39
|
+
export interface RouterLayoutProps {
|
|
40
|
+
readonly children: ComponentChildren;
|
|
41
|
+
}
|
|
42
|
+
export interface ErrorBoundaryProps {
|
|
43
|
+
readonly error: unknown;
|
|
44
|
+
readonly retry: () => void;
|
|
45
|
+
}
|
|
46
|
+
export interface InvalidBoundaryProps {
|
|
47
|
+
readonly location: RouteLocation;
|
|
48
|
+
readonly issues: readonly UrlIssue[];
|
|
49
|
+
}
|
|
50
|
+
export interface NotFoundBoundaryProps {
|
|
51
|
+
readonly location: RouteLocation;
|
|
52
|
+
}
|
|
53
|
+
const ErrorComponent = Schema.declare((value): value is ComponentType<ErrorBoundaryProps> =>
|
|
54
|
+
Predicate.isFunction(value),
|
|
55
|
+
);
|
|
56
|
+
const InvalidComponent = Schema.declare((value): value is ComponentType<InvalidBoundaryProps> =>
|
|
57
|
+
Predicate.isFunction(value),
|
|
58
|
+
);
|
|
59
|
+
const NotFoundComponent = Schema.declare((value): value is ComponentType<NotFoundBoundaryProps> =>
|
|
60
|
+
Predicate.isFunction(value),
|
|
61
|
+
);
|
|
62
|
+
const PageModule = Schema.Struct({
|
|
63
|
+
error: Schema.optional(ErrorComponent),
|
|
64
|
+
invalid: Schema.optional(InvalidComponent),
|
|
65
|
+
component: Schema.declare((value): value is ComponentType<RouterPageProps> =>
|
|
66
|
+
Predicate.isFunction(value),
|
|
67
|
+
),
|
|
68
|
+
});
|
|
69
|
+
const LayoutModule = Schema.Struct({
|
|
70
|
+
error: Schema.optional(ErrorComponent),
|
|
71
|
+
invalid: Schema.optional(InvalidComponent),
|
|
72
|
+
notFound: Schema.optional(NotFoundComponent),
|
|
73
|
+
component: Schema.declare((value): value is ComponentType<RouterLayoutProps> =>
|
|
74
|
+
Predicate.isFunction(value),
|
|
75
|
+
),
|
|
76
|
+
});
|
|
77
|
+
const ModuleExports = Schema.Record(Schema.String, Schema.Unknown);
|
|
78
|
+
|
|
79
|
+
export interface LoadedBranch {
|
|
80
|
+
readonly outcome: RouteOutcome;
|
|
81
|
+
readonly layouts: readonly {
|
|
82
|
+
readonly id: LayoutDefinition["id"];
|
|
83
|
+
readonly component: ComponentType<RouterLayoutProps>;
|
|
84
|
+
readonly error?: ComponentType<ErrorBoundaryProps>;
|
|
85
|
+
readonly invalid?: ComponentType<InvalidBoundaryProps>;
|
|
86
|
+
readonly notFound?: ComponentType<NotFoundBoundaryProps>;
|
|
87
|
+
}[];
|
|
88
|
+
readonly page: ComponentType<RouterPageProps> | undefined;
|
|
89
|
+
readonly pageBoundaries?: {
|
|
90
|
+
readonly error?: ComponentType<ErrorBoundaryProps>;
|
|
91
|
+
readonly invalid?: ComponentType<InvalidBoundaryProps>;
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
export class BranchLoadError extends Schema.TaggedError<BranchLoadError>()("BranchLoadError", {
|
|
95
|
+
error: Schema.Unknown,
|
|
96
|
+
branch: Schema.declare((value): value is LoadedBranch => Predicate.isObject(value)),
|
|
97
|
+
}) {}
|
|
98
|
+
export type BlockerState =
|
|
99
|
+
| { readonly status: "idle" }
|
|
100
|
+
| {
|
|
101
|
+
readonly status: "blocked";
|
|
102
|
+
readonly from: RouteLocation;
|
|
103
|
+
readonly to: RouteLocation;
|
|
104
|
+
readonly proceed: () => void;
|
|
105
|
+
readonly reset: () => void;
|
|
106
|
+
};
|
|
107
|
+
export type NavigationState =
|
|
108
|
+
| { readonly status: "idle" }
|
|
109
|
+
| { readonly status: "loading"; readonly to: RouteLocation };
|
|
110
|
+
export interface RouterSnapshot {
|
|
111
|
+
readonly location: RouteLocation | undefined;
|
|
112
|
+
readonly branch: LoadedBranch | undefined;
|
|
113
|
+
readonly navigation: NavigationState;
|
|
114
|
+
readonly failure:
|
|
115
|
+
| { readonly error: unknown; readonly retry: () => void; readonly branch?: LoadedBranch }
|
|
116
|
+
| undefined;
|
|
117
|
+
readonly commit: number;
|
|
118
|
+
}
|
|
119
|
+
export interface NavigationOptions {
|
|
120
|
+
readonly replace?: boolean;
|
|
121
|
+
readonly scroll?: "auto" | "preserve";
|
|
122
|
+
}
|
|
123
|
+
interface Transition {
|
|
124
|
+
readonly id: number;
|
|
125
|
+
readonly href: string;
|
|
126
|
+
readonly location: RouteLocation;
|
|
127
|
+
readonly kind: "initial" | "push" | "replace" | "traverse";
|
|
128
|
+
readonly completion: Deferred.Deferred<NavigationResult>;
|
|
129
|
+
readonly position: HistoryPosition;
|
|
130
|
+
readonly scroll: "auto" | "preserve";
|
|
131
|
+
readonly searchOnly: boolean;
|
|
132
|
+
readonly sourceScroll: { readonly x: number; readonly y: number };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const locationOf = (href: string, key: string, origin: string): RouteLocation => {
|
|
136
|
+
const parsed = new URL(href, origin);
|
|
137
|
+
let hash = parsed.hash.slice(1);
|
|
138
|
+
try {
|
|
139
|
+
hash = decodeURIComponent(hash);
|
|
140
|
+
} catch {
|
|
141
|
+
// Invalid fragments still need a readable location for the invalid-URL view.
|
|
142
|
+
}
|
|
143
|
+
return { pathname: parsed.pathname, search: parsed.search, hash, key };
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/** One store per mount. Load caches contain code and CSS only, never component/query state. */
|
|
147
|
+
export class RouterStore {
|
|
148
|
+
readonly selection: ExecutionSelection;
|
|
149
|
+
private readonly patterns;
|
|
150
|
+
private readonly listeners = new Set<() => void>();
|
|
151
|
+
private readonly modules = new Map<AppModuleId, Promise<typeof ModuleExports.Type>>();
|
|
152
|
+
private readonly styles = new Map<string, Promise<void>>();
|
|
153
|
+
private readonly styleCleanups = new Set<() => void>();
|
|
154
|
+
private readonly redirects = new Set<string>();
|
|
155
|
+
private history: RouterHistory | undefined;
|
|
156
|
+
private readonly blockers = new Set<symbol>();
|
|
157
|
+
private blocked: { transition: Transition; remaining: Set<symbol> } | undefined;
|
|
158
|
+
private upgrading = false;
|
|
159
|
+
private admission:
|
|
160
|
+
| { readonly validate: () => Promise<boolean>; readonly recoverLoad: () => Promise<void> }
|
|
161
|
+
| undefined;
|
|
162
|
+
private serial = 0;
|
|
163
|
+
private transition: Transition | undefined;
|
|
164
|
+
private committedHref: string | undefined;
|
|
165
|
+
private disposed = false;
|
|
166
|
+
private started = false;
|
|
167
|
+
private snapshot: RouterSnapshot = {
|
|
168
|
+
location: undefined,
|
|
169
|
+
branch: undefined,
|
|
170
|
+
navigation: { status: "idle" },
|
|
171
|
+
failure: undefined,
|
|
172
|
+
commit: 0,
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
constructor(
|
|
176
|
+
private readonly registration: ClientRegistration,
|
|
177
|
+
selection: ExecutionSelection,
|
|
178
|
+
readonly browser: Window,
|
|
179
|
+
) {
|
|
180
|
+
this.selection = Schema.decodeSync(ExecutionSelection)(selection);
|
|
181
|
+
this.patterns = Effect.runSync(compilePatterns(registration.routes, registration.layouts));
|
|
182
|
+
for (const reference of [
|
|
183
|
+
...registration.routes.map((route) => route.page),
|
|
184
|
+
...registration.layouts.map((layout) => layout.module),
|
|
185
|
+
]) {
|
|
186
|
+
if (!registration.modules.has(reference.module))
|
|
187
|
+
throw new Error(`Client module '${reference.module}' is missing from registration.`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
getSnapshot = (): RouterSnapshot => this.snapshot;
|
|
192
|
+
subscribe = (listener: () => void): (() => void) => {
|
|
193
|
+
this.listeners.add(listener);
|
|
194
|
+
return () => this.listeners.delete(listener);
|
|
195
|
+
};
|
|
196
|
+
private update(snapshot: RouterSnapshot): void {
|
|
197
|
+
this.snapshot = snapshot;
|
|
198
|
+
for (const listener of this.listeners) listener();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
start(): Promise<NavigationResult> {
|
|
202
|
+
if (this.started) throw new Error("The Ignotum router has already started.");
|
|
203
|
+
this.started = true;
|
|
204
|
+
this.history = new RouterHistory(this.browser, this.onTraversal);
|
|
205
|
+
this.browser.addEventListener("beforeunload", this.onBeforeUnload);
|
|
206
|
+
const position = this.history.committed;
|
|
207
|
+
return this.transitionTo(position.href, "initial", position.key, {}, position);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private onTraversal = (position: HistoryPosition): void => {
|
|
211
|
+
this.redirects.clear();
|
|
212
|
+
// Native traversal already changed the address. Restore the source while confirmation/code waits.
|
|
213
|
+
void this.history?.seek(this.history.committed);
|
|
214
|
+
void this.transitionTo(position.href, "traverse", position.key, {}, position);
|
|
215
|
+
};
|
|
216
|
+
back = (): void => this.history?.back();
|
|
217
|
+
forward = (): void => this.history?.forward();
|
|
218
|
+
private onBeforeUnload = (event: BeforeUnloadEvent): void => {
|
|
219
|
+
if (this.upgrading || this.blockers.size === 0) return;
|
|
220
|
+
event.preventDefault();
|
|
221
|
+
event.returnValue = "";
|
|
222
|
+
};
|
|
223
|
+
/** Internal platform upgrade entry; route confirmation never delays this reload. */
|
|
224
|
+
stopForUpgrade = (): void => {
|
|
225
|
+
if (this.disposed) return;
|
|
226
|
+
this.upgrading = true;
|
|
227
|
+
this.browser.removeEventListener("beforeunload", this.onBeforeUnload);
|
|
228
|
+
this.blockers.clear();
|
|
229
|
+
this.cancelPending();
|
|
230
|
+
};
|
|
231
|
+
upgrade = (): void => {
|
|
232
|
+
if (this.disposed) return;
|
|
233
|
+
this.stopForUpgrade();
|
|
234
|
+
this.browser.location.reload();
|
|
235
|
+
};
|
|
236
|
+
setAdmission(admission: NonNullable<RouterStore["admission"]>): void {
|
|
237
|
+
this.admission = admission;
|
|
238
|
+
}
|
|
239
|
+
setBlocker(id: symbol, when: boolean): void {
|
|
240
|
+
if (when) this.blockers.add(id);
|
|
241
|
+
else this.blockers.delete(id);
|
|
242
|
+
if (!when && this.blocked?.remaining.delete(id) === true) this.releaseBlocked();
|
|
243
|
+
}
|
|
244
|
+
getBlocker(id: symbol): BlockerState {
|
|
245
|
+
const blocked = this.blocked;
|
|
246
|
+
const from = this.snapshot.location;
|
|
247
|
+
if (blocked === undefined || from === undefined || !blocked.remaining.has(id))
|
|
248
|
+
return { status: "idle" };
|
|
249
|
+
return {
|
|
250
|
+
status: "blocked",
|
|
251
|
+
from,
|
|
252
|
+
to: blocked.transition.location,
|
|
253
|
+
proceed: () => {
|
|
254
|
+
if (this.blocked !== blocked) return;
|
|
255
|
+
blocked.remaining.delete(id);
|
|
256
|
+
this.releaseBlocked();
|
|
257
|
+
},
|
|
258
|
+
reset: () => {
|
|
259
|
+
if (this.blocked !== blocked) return;
|
|
260
|
+
this.cancelPending();
|
|
261
|
+
this.update({ ...this.snapshot, navigation: { status: "idle" } });
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
private releaseBlocked(): void {
|
|
266
|
+
const blocked = this.blocked;
|
|
267
|
+
if (blocked === undefined) return;
|
|
268
|
+
if (blocked.remaining.size === 0) {
|
|
269
|
+
this.blocked = undefined;
|
|
270
|
+
this.loadTransition(blocked.transition);
|
|
271
|
+
} else this.update({ ...this.snapshot });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
navigate(
|
|
275
|
+
href: string,
|
|
276
|
+
options: NavigationOptions = {},
|
|
277
|
+
redirect = false,
|
|
278
|
+
): Promise<NavigationResult> {
|
|
279
|
+
if (this.disposed || this.upgrading) return Promise.resolve({ status: "cancelled" });
|
|
280
|
+
if (!redirect) this.redirects.clear();
|
|
281
|
+
if (href === this.committedHref) {
|
|
282
|
+
this.cancelPending();
|
|
283
|
+
this.update({ ...this.snapshot, navigation: { status: "idle" }, failure: undefined });
|
|
284
|
+
return Promise.resolve({ status: "committed" });
|
|
285
|
+
}
|
|
286
|
+
if (redirect) {
|
|
287
|
+
if (this.committedHref !== undefined) this.redirects.add(this.committedHref);
|
|
288
|
+
if (this.redirects.has(href) || this.redirects.size >= 32) {
|
|
289
|
+
const error = new Error(`Ignotum redirect loop at '${href}'.`);
|
|
290
|
+
this.cancelPending();
|
|
291
|
+
this.update({
|
|
292
|
+
...this.snapshot,
|
|
293
|
+
navigation: { status: "idle" },
|
|
294
|
+
failure: {
|
|
295
|
+
error,
|
|
296
|
+
retry: () => {
|
|
297
|
+
this.redirects.clear();
|
|
298
|
+
void this.navigate(href, options, true);
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
return Promise.resolve({ status: "failed", error });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const key =
|
|
306
|
+
options.replace === true
|
|
307
|
+
? (this.snapshot.location?.key ?? this.browser.crypto.randomUUID())
|
|
308
|
+
: this.browser.crypto.randomUUID();
|
|
309
|
+
return this.transitionTo(href, options.replace === true ? "replace" : "push", key, options);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
preload = async (href: string): Promise<void> => {
|
|
313
|
+
if (
|
|
314
|
+
this.disposed ||
|
|
315
|
+
this.upgrading ||
|
|
316
|
+
(this.admission !== undefined && !(await this.admission.validate()))
|
|
317
|
+
)
|
|
318
|
+
return;
|
|
319
|
+
const outcome = Effect.runSync(matchLocation(this.patterns, href));
|
|
320
|
+
await this.loadBranch(outcome, false);
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
private cancelPending(): void {
|
|
324
|
+
if (this.history !== undefined) void this.history.seek(this.history.committed);
|
|
325
|
+
this.blocked = undefined;
|
|
326
|
+
if (this.transition !== undefined)
|
|
327
|
+
Effect.runSync(Deferred.succeed(this.transition.completion, { status: "cancelled" }));
|
|
328
|
+
this.transition = undefined;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private transitionTo(
|
|
332
|
+
href: string,
|
|
333
|
+
kind: Transition["kind"],
|
|
334
|
+
key: string,
|
|
335
|
+
options: NavigationOptions = {},
|
|
336
|
+
historyPosition?: HistoryPosition,
|
|
337
|
+
): Promise<NavigationResult> {
|
|
338
|
+
if (this.disposed || this.upgrading) return Promise.resolve({ status: "cancelled" });
|
|
339
|
+
const sourceScroll = this.history?.captureScroll() ?? {
|
|
340
|
+
x: this.browser.scrollX,
|
|
341
|
+
y: this.browser.scrollY,
|
|
342
|
+
};
|
|
343
|
+
this.cancelPending();
|
|
344
|
+
const location = locationOf(href, key, this.browser.location.origin);
|
|
345
|
+
const previous = this.snapshot.location;
|
|
346
|
+
const searchOnly =
|
|
347
|
+
previous !== undefined &&
|
|
348
|
+
previous.pathname === location.pathname &&
|
|
349
|
+
previous.hash === location.hash;
|
|
350
|
+
const completion = Deferred.makeUnsafe<NavigationResult>();
|
|
351
|
+
const position = historyPosition ?? {
|
|
352
|
+
key,
|
|
353
|
+
href,
|
|
354
|
+
index: (this.history?.committed.index ?? 0) + (kind === "push" ? 1 : 0),
|
|
355
|
+
};
|
|
356
|
+
const transition: Transition = {
|
|
357
|
+
id: ++this.serial,
|
|
358
|
+
href,
|
|
359
|
+
location,
|
|
360
|
+
kind,
|
|
361
|
+
completion,
|
|
362
|
+
position,
|
|
363
|
+
scroll: options.scroll ?? (searchOnly ? "preserve" : "auto"),
|
|
364
|
+
searchOnly,
|
|
365
|
+
sourceScroll,
|
|
366
|
+
};
|
|
367
|
+
this.transition = transition;
|
|
368
|
+
if (kind !== "initial" && this.blockers.size > 0 && this.snapshot.location !== undefined) {
|
|
369
|
+
this.blocked = { transition, remaining: new Set(this.blockers) };
|
|
370
|
+
this.update({ ...this.snapshot, navigation: { status: "idle" }, failure: undefined });
|
|
371
|
+
} else this.loadTransition(transition);
|
|
372
|
+
return Effect.runPromise(Deferred.await(completion));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private loadTransition(transition: Transition): void {
|
|
376
|
+
const { href, location, kind } = transition;
|
|
377
|
+
const outcome = Effect.runSync(matchLocation(this.patterns, href));
|
|
378
|
+
this.update({
|
|
379
|
+
...this.snapshot,
|
|
380
|
+
navigation: { status: "loading", to: location },
|
|
381
|
+
failure: undefined,
|
|
382
|
+
});
|
|
383
|
+
const load = async () => {
|
|
384
|
+
if (this.admission !== undefined && !(await this.admission.validate()))
|
|
385
|
+
throw new Error("The app must verify its current selection before navigating.");
|
|
386
|
+
if (this.upgrading || this.disposed) throw new Error("The app is updating.");
|
|
387
|
+
return this.loadBranch(outcome);
|
|
388
|
+
};
|
|
389
|
+
void load().then(
|
|
390
|
+
async (branch) => {
|
|
391
|
+
if (this.transition !== transition || this.disposed) return;
|
|
392
|
+
try {
|
|
393
|
+
const history = this.history;
|
|
394
|
+
if (history !== undefined) {
|
|
395
|
+
const restored = await history.seek(history.committed);
|
|
396
|
+
if (!restored || this.transition !== transition || this.disposed) return;
|
|
397
|
+
if (kind === "traverse") {
|
|
398
|
+
const arrived = await history.seek(transition.position);
|
|
399
|
+
if (!arrived || this.transition !== transition || this.disposed) return;
|
|
400
|
+
}
|
|
401
|
+
history.commit(transition.position, kind);
|
|
402
|
+
}
|
|
403
|
+
this.applyMetadata(outcome);
|
|
404
|
+
this.committedHref = href;
|
|
405
|
+
this.update({
|
|
406
|
+
location,
|
|
407
|
+
branch,
|
|
408
|
+
navigation: { status: "idle" },
|
|
409
|
+
failure: undefined,
|
|
410
|
+
commit: transition.id,
|
|
411
|
+
});
|
|
412
|
+
} catch (error) {
|
|
413
|
+
this.fail(transition, error);
|
|
414
|
+
}
|
|
415
|
+
},
|
|
416
|
+
(error) => {
|
|
417
|
+
this.fail(transition, error);
|
|
418
|
+
if (!this.upgrading) void this.admission?.recoverLoad();
|
|
419
|
+
},
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Called by the mounted view after Preact has committed the destination DOM. */
|
|
424
|
+
rendered(commit: number): void {
|
|
425
|
+
if (this.transition?.id !== commit || this.snapshot.commit !== commit) {
|
|
426
|
+
if (this.snapshot.navigation.status === "idle" && this.blocked === undefined)
|
|
427
|
+
this.history?.restoreSourceScroll();
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
const transition = this.transition;
|
|
431
|
+
const document = this.browser.document;
|
|
432
|
+
if (!transition.searchOnly) {
|
|
433
|
+
const target =
|
|
434
|
+
document.querySelector<HTMLElement>("#app main, #app [role=main]") ??
|
|
435
|
+
document.getElementById("app");
|
|
436
|
+
if (target !== null) {
|
|
437
|
+
const hadTabIndex = target.hasAttribute("tabindex");
|
|
438
|
+
if (!hadTabIndex) target.setAttribute("tabindex", "-1");
|
|
439
|
+
target.focus({ preventScroll: true });
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
const saved = this.history?.scroll.get(transition.location.key);
|
|
443
|
+
if (transition.kind === "traverse" && saved !== undefined)
|
|
444
|
+
this.browser.scrollTo(saved.x, saved.y);
|
|
445
|
+
else if (transition.scroll === "auto") {
|
|
446
|
+
const target =
|
|
447
|
+
transition.location.hash === "" ? null : document.getElementById(transition.location.hash);
|
|
448
|
+
if (target !== null) target.scrollIntoView();
|
|
449
|
+
else this.browser.scrollTo(0, 0);
|
|
450
|
+
} else this.browser.scrollTo(transition.sourceScroll.x, transition.sourceScroll.y);
|
|
451
|
+
this.history?.finishScroll();
|
|
452
|
+
Effect.runSync(Deferred.succeed(this.transition.completion, { status: "committed" }));
|
|
453
|
+
this.transition = undefined;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- Module loaders and DOM APIs can throw arbitrary values; NavigationResult preserves the original error.
|
|
457
|
+
private fail(transition: Transition, error: unknown): void {
|
|
458
|
+
if (this.transition !== transition || this.disposed) return;
|
|
459
|
+
this.transition = undefined;
|
|
460
|
+
const loadFailure = error instanceof BranchLoadError ? error : undefined;
|
|
461
|
+
const originalError = loadFailure === undefined ? error : loadFailure.error;
|
|
462
|
+
this.update({
|
|
463
|
+
...this.snapshot,
|
|
464
|
+
navigation: { status: "idle" },
|
|
465
|
+
failure: {
|
|
466
|
+
error: originalError,
|
|
467
|
+
branch: transition.kind === "initial" ? undefined : loadFailure?.branch,
|
|
468
|
+
retry: () => {
|
|
469
|
+
void this.transitionTo(
|
|
470
|
+
transition.href,
|
|
471
|
+
transition.kind,
|
|
472
|
+
transition.location.key,
|
|
473
|
+
{ scroll: transition.scroll },
|
|
474
|
+
transition.position,
|
|
475
|
+
);
|
|
476
|
+
},
|
|
477
|
+
},
|
|
478
|
+
});
|
|
479
|
+
Effect.runSync(
|
|
480
|
+
Deferred.succeed(transition.completion, { status: "failed", error: originalError }),
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
private loadStyle(href: string): Promise<void> {
|
|
485
|
+
const absolute = new URL(href, this.browser.location.origin).href;
|
|
486
|
+
const cached = this.styles.get(absolute);
|
|
487
|
+
if (cached !== undefined) return cached;
|
|
488
|
+
const document = this.browser.document;
|
|
489
|
+
const existing = Array.from(
|
|
490
|
+
document.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]'),
|
|
491
|
+
).find((link) => link.href === absolute);
|
|
492
|
+
if (existing?.sheet !== null && existing?.sheet !== undefined) return Promise.resolve();
|
|
493
|
+
const link = existing ?? document.createElement("link");
|
|
494
|
+
const promise = Effect.runPromise(
|
|
495
|
+
Effect.callback<void, Error>((resume) => {
|
|
496
|
+
const cleanup = () => {
|
|
497
|
+
link.removeEventListener("load", onLoad);
|
|
498
|
+
link.removeEventListener("error", onError);
|
|
499
|
+
this.styleCleanups.delete(cancel);
|
|
500
|
+
};
|
|
501
|
+
const onLoad = () => {
|
|
502
|
+
cleanup();
|
|
503
|
+
resume(Effect.void);
|
|
504
|
+
};
|
|
505
|
+
const onError = () => {
|
|
506
|
+
cleanup();
|
|
507
|
+
link.remove();
|
|
508
|
+
resume(Effect.fail(new Error(`Could not load route stylesheet '${href}'.`)));
|
|
509
|
+
};
|
|
510
|
+
const cancel = () => {
|
|
511
|
+
cleanup();
|
|
512
|
+
if (existing === undefined) link.remove();
|
|
513
|
+
resume(Effect.fail(new Error("Router disposed.")));
|
|
514
|
+
};
|
|
515
|
+
this.styleCleanups.add(cancel);
|
|
516
|
+
link.addEventListener("load", onLoad);
|
|
517
|
+
link.addEventListener("error", onError);
|
|
518
|
+
if (existing === undefined) {
|
|
519
|
+
link.rel = "stylesheet";
|
|
520
|
+
link.crossOrigin = "anonymous";
|
|
521
|
+
link.href = absolute;
|
|
522
|
+
document.head.append(link);
|
|
523
|
+
}
|
|
524
|
+
return Effect.sync(cleanup);
|
|
525
|
+
}),
|
|
526
|
+
).catch((error) => {
|
|
527
|
+
this.styles.delete(absolute);
|
|
528
|
+
throw error;
|
|
529
|
+
});
|
|
530
|
+
this.styles.set(absolute, promise);
|
|
531
|
+
return promise;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private loadModule(id: AppModuleId): Promise<typeof ModuleExports.Type> {
|
|
535
|
+
const cached = this.modules.get(id);
|
|
536
|
+
if (cached !== undefined) return cached;
|
|
537
|
+
const module = this.registration.modules.get(id);
|
|
538
|
+
if (module === undefined)
|
|
539
|
+
return Promise.reject(new Error(`Client module '${id}' is not registered.`));
|
|
540
|
+
const loaded = Promise.resolve()
|
|
541
|
+
.then(module.load)
|
|
542
|
+
.then(Schema.decodeUnknownSync(ModuleExports))
|
|
543
|
+
.catch((error) => {
|
|
544
|
+
this.modules.delete(id);
|
|
545
|
+
throw error;
|
|
546
|
+
});
|
|
547
|
+
this.modules.set(id, loaded);
|
|
548
|
+
return loaded;
|
|
549
|
+
}
|
|
550
|
+
private async loadExport(reference: ModuleExportReference): Promise<typeof Schema.Unknown.Type> {
|
|
551
|
+
const exports = await this.loadModule(reference.module);
|
|
552
|
+
if (!Object.hasOwn(exports, reference.exportName))
|
|
553
|
+
throw new Error(`Module '${reference.module}' has no '${reference.exportName}' export.`);
|
|
554
|
+
return exports[reference.exportName];
|
|
555
|
+
}
|
|
556
|
+
private async loadBranch(outcome: RouteOutcome, withStyles = true): Promise<LoadedBranch> {
|
|
557
|
+
const route = outcome._tag === "NotFound" ? undefined : outcome.route;
|
|
558
|
+
const available = new Map<LayoutDefinition["id"], LoadedBranch["layouts"][number]>();
|
|
559
|
+
try {
|
|
560
|
+
const [layouts, page] = await Promise.all([
|
|
561
|
+
Promise.all(
|
|
562
|
+
outcome.layouts.map(async (layout) => {
|
|
563
|
+
const [loaded] = await Promise.all([
|
|
564
|
+
this.loadExport(layout.module).then(Schema.decodeUnknownSync(LayoutModule)),
|
|
565
|
+
withStyles
|
|
566
|
+
? Promise.all(
|
|
567
|
+
(this.registration.modules.get(layout.module.module)?.styles ?? []).map(
|
|
568
|
+
(href) => this.loadStyle(href),
|
|
569
|
+
),
|
|
570
|
+
)
|
|
571
|
+
: undefined,
|
|
572
|
+
]);
|
|
573
|
+
const result = { id: layout.id, ...loaded };
|
|
574
|
+
available.set(layout.id, result);
|
|
575
|
+
return result;
|
|
576
|
+
}),
|
|
577
|
+
),
|
|
578
|
+
route === undefined
|
|
579
|
+
? undefined
|
|
580
|
+
: this.loadExport(route.page).then(Schema.decodeUnknownSync(PageModule)),
|
|
581
|
+
Promise.all(
|
|
582
|
+
withStyles
|
|
583
|
+
? [
|
|
584
|
+
...(this.registration.styles ?? []),
|
|
585
|
+
...(route === undefined
|
|
586
|
+
? []
|
|
587
|
+
: (this.registration.modules.get(route.page.module)?.styles ?? [])),
|
|
588
|
+
].map(async (href) => this.loadStyle(href))
|
|
589
|
+
: [],
|
|
590
|
+
),
|
|
591
|
+
]);
|
|
592
|
+
return { outcome, layouts, page: page?.component, pageBoundaries: page };
|
|
593
|
+
} catch (error) {
|
|
594
|
+
const layouts: Array<LoadedBranch["layouts"][number]> = [];
|
|
595
|
+
for (const definition of outcome.layouts) {
|
|
596
|
+
const loaded = available.get(definition.id);
|
|
597
|
+
if (loaded === undefined) break;
|
|
598
|
+
layouts.push(loaded);
|
|
599
|
+
}
|
|
600
|
+
throw BranchLoadError.make({ error, branch: { outcome, layouts, page: undefined } });
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
setRenderError(error: boolean): void {
|
|
605
|
+
if (this.snapshot.branch !== undefined) this.applyMetadata(this.snapshot.branch.outcome, error);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
private applyMetadata(outcome: RouteOutcome, renderError = false): void {
|
|
609
|
+
const resolved = Effect.runSync(
|
|
610
|
+
resolveMetadata({
|
|
611
|
+
applicationName: this.registration.applicationName,
|
|
612
|
+
layouts: outcome.layouts.map((layout) => layout.metadata),
|
|
613
|
+
page: outcome._tag === "NotFound" ? undefined : outcome.route?.metadata,
|
|
614
|
+
outcome: renderError ? "Invalid" : outcome._tag,
|
|
615
|
+
}),
|
|
616
|
+
);
|
|
617
|
+
const document = this.browser.document;
|
|
618
|
+
document.title = resolved.title;
|
|
619
|
+
for (const name of ["description", "robots"] as const) {
|
|
620
|
+
const tags = Array.from(
|
|
621
|
+
document.head.querySelectorAll<HTMLMetaElement>(`meta[name="${name}"]`),
|
|
622
|
+
);
|
|
623
|
+
const value = resolved.meta[name];
|
|
624
|
+
if (value === undefined) {
|
|
625
|
+
for (const tag of tags) tag.remove();
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
const tag = tags.shift() ?? document.createElement("meta");
|
|
629
|
+
for (const duplicate of tags) duplicate.remove();
|
|
630
|
+
tag.name = name;
|
|
631
|
+
tag.content = value;
|
|
632
|
+
if (!tag.isConnected) document.head.append(tag);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
dispose(): void {
|
|
637
|
+
this.disposed = true;
|
|
638
|
+
this.cancelPending();
|
|
639
|
+
this.history?.dispose();
|
|
640
|
+
this.browser.removeEventListener("beforeunload", this.onBeforeUnload);
|
|
641
|
+
for (const cleanup of this.styleCleanups) cleanup();
|
|
642
|
+
this.listeners.clear();
|
|
643
|
+
}
|
|
644
|
+
}
|