@async/framework 0.1.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/src/router.js ADDED
@@ -0,0 +1,367 @@
1
+ import { AsyncLoader } from "./loader.js";
2
+ import { createHandlerRegistry } from "./handlers.js";
3
+ import { createSignalRegistry } from "./signals.js";
4
+ import { applyServerResult } from "./server.js";
5
+
6
+ export function defineRoute(partial, options = {}) {
7
+ return {
8
+ ...options,
9
+ partial
10
+ };
11
+ }
12
+
13
+ export const route = defineRoute;
14
+
15
+ export function createRouteRegistry(initialMap = {}) {
16
+ const routes = [];
17
+
18
+ const registry = {
19
+ register(pattern, definition) {
20
+ assertPattern(pattern);
21
+ if (routes.some((candidate) => candidate.pattern === pattern)) {
22
+ throw new Error(`Route "${pattern}" is already registered.`);
23
+ }
24
+ const nextRoute = normalizeRoute(pattern, definition);
25
+ routes.push(nextRoute);
26
+ return nextRoute;
27
+ },
28
+
29
+ registerMany(map) {
30
+ for (const [pattern, definition] of Object.entries(map ?? {})) {
31
+ registry.register(pattern, definition);
32
+ }
33
+ return registry;
34
+ },
35
+
36
+ match(url) {
37
+ const path = toUrl(url).pathname;
38
+ for (const candidate of routes) {
39
+ const match = candidate.regex.exec(path);
40
+ if (!match) {
41
+ continue;
42
+ }
43
+ const params = {};
44
+ candidate.keys.forEach((key, index) => {
45
+ params[key] = decodeURIComponent(match[index + 1] ?? "");
46
+ });
47
+ return {
48
+ pattern: candidate.pattern,
49
+ params,
50
+ route: candidate.definition
51
+ };
52
+ }
53
+ return null;
54
+ },
55
+
56
+ entries() {
57
+ return routes.map(({ pattern, definition }) => ({ pattern, route: definition }));
58
+ }
59
+ };
60
+
61
+ registry.registerMany(initialMap);
62
+ return registry;
63
+ }
64
+
65
+ export function createRouter({
66
+ mode = "spa",
67
+ root,
68
+ boundary = "route",
69
+ routes = createRouteRegistry(),
70
+ loader,
71
+ signals,
72
+ handlers,
73
+ server,
74
+ cache,
75
+ partials,
76
+ fetch: fetchImpl = globalThis.fetch?.bind(globalThis),
77
+ routeEndpoint = "/__async/route"
78
+ } = {}) {
79
+ const documentRef = root?.ownerDocument ?? root ?? globalThis.document;
80
+ const rootNode = root ?? documentRef;
81
+ const signalRegistry = signals ?? loader?.signals ?? createSignalRegistry();
82
+ const handlerRegistry = handlers ?? loader?.handlers ?? createHandlerRegistry();
83
+ const loaderInstance =
84
+ loader ??
85
+ AsyncLoader({
86
+ root: rootNode,
87
+ signals: signalRegistry,
88
+ handlers: handlerRegistry,
89
+ server,
90
+ cache
91
+ });
92
+ const cleanups = new Set();
93
+ let destroyed = false;
94
+
95
+ const api = {
96
+ mode,
97
+ root: rootNode,
98
+ boundary,
99
+ routes,
100
+ loader: loaderInstance,
101
+ signals: signalRegistry,
102
+ handlers: handlerRegistry,
103
+ server,
104
+ cache,
105
+ partials,
106
+
107
+ start() {
108
+ assertActive();
109
+ loaderInstance.router = api;
110
+ signalRegistry._setContext?.({ router: api, loader: loaderInstance, server, cache });
111
+ ensureRouterState(currentUrl());
112
+ if (mode === "spa" || mode === "ssr-spa") {
113
+ bindNavigation();
114
+ }
115
+ return api;
116
+ },
117
+
118
+ match(url) {
119
+ return routes.match(url);
120
+ },
121
+
122
+ prefetch(url) {
123
+ assertActive();
124
+ const matched = api.match(url);
125
+ if (matched?.route?.partial && partials?.resolve?.(matched.route.partial)) {
126
+ return partials.render(matched.route.partial, matched.params, contextFor(matched));
127
+ }
128
+ if (typeof fetchImpl === "function") {
129
+ return fetchRoute(url, { prefetch: true });
130
+ }
131
+ return Promise.resolve(null);
132
+ },
133
+
134
+ async navigate(url, options = {}) {
135
+ assertActive();
136
+ if (mode === "mpa") {
137
+ documentRef.defaultView?.location?.assign?.(url);
138
+ return null;
139
+ }
140
+
141
+ const target = toUrl(url);
142
+ const matched = api.match(target);
143
+ setRouterState({
144
+ url: target.href,
145
+ path: target.pathname,
146
+ query: queryObject(target),
147
+ params: matched?.params ?? {},
148
+ route: matched?.pattern ?? null,
149
+ pending: true,
150
+ error: null
151
+ });
152
+
153
+ try {
154
+ const result = await resolveNavigation(target, matched);
155
+ await applyServerResult(result, {
156
+ signals: signalRegistry,
157
+ loader: loaderInstance,
158
+ router: api,
159
+ cache
160
+ });
161
+ if (result?.html != null && !result.boundary && !result.redirect) {
162
+ loaderInstance.swap(boundary, result.html);
163
+ }
164
+ if (options.history !== false) {
165
+ documentRef.defaultView?.history?.pushState?.({}, "", target.href);
166
+ }
167
+ setRouterState({ pending: false, error: null });
168
+ return result;
169
+ } catch (error) {
170
+ setRouterState({ pending: false, error });
171
+ throw error;
172
+ }
173
+ },
174
+
175
+ destroy() {
176
+ if (destroyed) {
177
+ return;
178
+ }
179
+ destroyed = true;
180
+ for (const cleanup of cleanups) {
181
+ cleanup();
182
+ }
183
+ cleanups.clear();
184
+ }
185
+ };
186
+
187
+ return api;
188
+
189
+ function bindNavigation() {
190
+ const click = (event) => {
191
+ const anchor = closest(event.target, "a[href]");
192
+ if (!anchor || shouldIgnoreLink(event, anchor)) {
193
+ return;
194
+ }
195
+ event.preventDefault();
196
+ api.navigate(anchor.href);
197
+ };
198
+ const submit = (event) => {
199
+ const form = closest(event.target, "form");
200
+ if (!form || shouldIgnoreForm(form)) {
201
+ return;
202
+ }
203
+ event.preventDefault();
204
+ api.navigate(formActionUrl(form));
205
+ };
206
+ const popstate = () => api.navigate(currentUrl(), { history: false });
207
+
208
+ rootNode.addEventListener?.("click", click);
209
+ rootNode.addEventListener?.("submit", submit);
210
+ documentRef.defaultView?.addEventListener?.("popstate", popstate);
211
+ cleanups.add(() => rootNode.removeEventListener?.("click", click));
212
+ cleanups.add(() => rootNode.removeEventListener?.("submit", submit));
213
+ cleanups.add(() => documentRef.defaultView?.removeEventListener?.("popstate", popstate));
214
+ }
215
+
216
+ async function resolveNavigation(target, matched) {
217
+ if (matched?.route?.partial && partials?.resolve?.(matched.route.partial)) {
218
+ return partials.render(matched.route.partial, matched.params, contextFor(matched));
219
+ }
220
+ return fetchRoute(target.href);
221
+ }
222
+
223
+ async function fetchRoute(url, { prefetch = false } = {}) {
224
+ if (typeof fetchImpl !== "function") {
225
+ throw new Error("Router navigation requires a partial registry or fetch.");
226
+ }
227
+ const response = await fetchImpl(`${routeEndpoint}?to=${encodeURIComponent(String(url))}`, {
228
+ headers: {
229
+ accept: "application/json, text/html"
230
+ }
231
+ });
232
+ if (!response.ok) {
233
+ throw new Error(`Route "${url}" failed with ${response.status}.`);
234
+ }
235
+ if (prefetch) {
236
+ return response;
237
+ }
238
+ const type = response.headers.get("content-type") ?? "";
239
+ if (type.includes("application/json")) {
240
+ return response.json();
241
+ }
242
+ return { boundary, html: await response.text() };
243
+ }
244
+
245
+ function contextFor(matched) {
246
+ return {
247
+ params: matched.params,
248
+ route: matched.route,
249
+ router: api,
250
+ signals: signalRegistry,
251
+ handlers: handlerRegistry,
252
+ loader: loaderInstance,
253
+ server,
254
+ cache,
255
+ abort: undefined
256
+ };
257
+ }
258
+
259
+ function ensureRouterState(url) {
260
+ signalRegistry.ensure("router", {});
261
+ const matched = api.match(url);
262
+ setRouterState({
263
+ url: url.href,
264
+ path: url.pathname,
265
+ query: queryObject(url),
266
+ params: matched?.params ?? {},
267
+ route: matched?.pattern ?? null,
268
+ pending: false,
269
+ error: null
270
+ });
271
+ }
272
+
273
+ function setRouterState(patch) {
274
+ signalRegistry.ensure("router", {});
275
+ for (const [key, value] of Object.entries(patch)) {
276
+ signalRegistry.set(`router.${key}`, value);
277
+ }
278
+ }
279
+
280
+ function currentUrl() {
281
+ return toUrl(documentRef.defaultView?.location?.href ?? "http://localhost/");
282
+ }
283
+
284
+ function assertActive() {
285
+ if (destroyed) {
286
+ throw new Error("Router has been destroyed.");
287
+ }
288
+ }
289
+ }
290
+
291
+ function normalizeRoute(pattern, definition) {
292
+ const normalized = typeof definition === "string" ? defineRoute(definition) : definition;
293
+ const { regex, keys } = compilePattern(pattern);
294
+ return {
295
+ pattern,
296
+ regex,
297
+ keys,
298
+ definition: normalized
299
+ };
300
+ }
301
+
302
+ function compilePattern(pattern) {
303
+ const keys = [];
304
+ if (pattern === "/") {
305
+ return { regex: /^\/$/, keys };
306
+ }
307
+
308
+ const source = pattern
309
+ .split("/")
310
+ .map((segment) => {
311
+ if (segment.startsWith(":")) {
312
+ keys.push(segment.slice(1));
313
+ return "([^/]+)";
314
+ }
315
+ return escapeRegExp(segment);
316
+ })
317
+ .join("/");
318
+
319
+ return { regex: new RegExp(`^${source}$`), keys };
320
+ }
321
+
322
+ function shouldIgnoreLink(event, anchor) {
323
+ if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
324
+ return true;
325
+ }
326
+ if (anchor.target || anchor.hasAttribute("download")) {
327
+ return true;
328
+ }
329
+ return toUrl(anchor.href).origin !== toUrl(anchor.ownerDocument.defaultView.location.href).origin;
330
+ }
331
+
332
+ function shouldIgnoreForm(form) {
333
+ const method = String(form.method || "get").toLowerCase();
334
+ return method !== "get" || toUrl(form.action).origin !== toUrl(form.ownerDocument.defaultView.location.href).origin;
335
+ }
336
+
337
+ function formActionUrl(form) {
338
+ const url = toUrl(form.action || form.ownerDocument.defaultView.location.href);
339
+ const formData = new form.ownerDocument.defaultView.FormData(form);
340
+ url.search = new URLSearchParams(formData).toString();
341
+ return url.href;
342
+ }
343
+
344
+ function closest(target, selector) {
345
+ return target?.closest?.(selector);
346
+ }
347
+
348
+ function toUrl(url) {
349
+ if (url instanceof URL) {
350
+ return url;
351
+ }
352
+ return new URL(String(url), globalThis.location?.href ?? "http://localhost/");
353
+ }
354
+
355
+ function queryObject(url) {
356
+ return Object.fromEntries(url.searchParams.entries());
357
+ }
358
+
359
+ function escapeRegExp(value) {
360
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
361
+ }
362
+
363
+ function assertPattern(pattern) {
364
+ if (typeof pattern !== "string" || !pattern.startsWith("/")) {
365
+ throw new TypeError("Route pattern must be a path string.");
366
+ }
367
+ }