@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/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +608 -0
- package/examples/cache/index.html +16 -0
- package/examples/cache/main.js +47 -0
- package/examples/components/index.html +11 -0
- package/examples/components/main.js +26 -0
- package/examples/counter/index.html +15 -0
- package/examples/counter/main.js +17 -0
- package/examples/partials/index.html +15 -0
- package/examples/partials/main.js +43 -0
- package/examples/product/index.html +32 -0
- package/examples/product/main.js +24 -0
- package/examples/router/index.html +21 -0
- package/examples/router/main.js +52 -0
- package/examples/server-call/index.html +21 -0
- package/examples/server-call/main.js +22 -0
- package/examples/ssr/index.html +12 -0
- package/examples/ssr/main.js +89 -0
- package/examples/streaming/index.html +16 -0
- package/examples/streaming/main.js +30 -0
- package/package.json +67 -0
- package/src/app.js +383 -0
- package/src/async-signal.js +238 -0
- package/src/cache.js +145 -0
- package/src/component.js +182 -0
- package/src/delay.js +30 -0
- package/src/handlers.js +175 -0
- package/src/html.js +65 -0
- package/src/index.js +12 -0
- package/src/loader.js +394 -0
- package/src/partials.js +96 -0
- package/src/router.js +367 -0
- package/src/server.js +369 -0
- package/src/signals.js +483 -0
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
|
+
}
|