@aegisjsproject/atlas 0.0.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/router.js ADDED
@@ -0,0 +1,338 @@
1
+ import { registerModule, lookupRoute } from './routes.js';
2
+ import { observePreloadsOn } from './preload.js';
3
+
4
+ /**
5
+ * This is necessary since an HTML response from a same-origin
6
+ * request should result in the same document state as if
7
+ * it were initial load. CSP/Trusted Types requires `TrustedHTML`
8
+ * for `Document.parseHTMLUnsage` (or `innerHTML`), and `setHTML()`
9
+ * would filter out any `<iframe>` or `onclick` or `<form action>`.
10
+ */
11
+ const policy = 'trustedTypes' in globalThis
12
+ ? trustedTypes.createPolicy('aegis-atlas#html', {
13
+ createHTML(input) {
14
+ return input;
15
+ }
16
+ }) : Object.freeze({
17
+ createHTML(input) {
18
+ return input;
19
+ }
20
+ });
21
+
22
+ const DESC_SELECTOR = 'meta[name="description"], meta[itemprop="description"], meta[property="og:description"], meta[name="twitter:description"]';
23
+
24
+ /**
25
+ * @typedef RouteContextObject
26
+ * @property {URLPatternResult} result
27
+ * @property {Record<string, string>} params
28
+ * @property {DisposableStack} stack
29
+ * @property {AbortController} controller
30
+ * @property {AbortSignal} signal
31
+ * @property {NavigationType} type
32
+ * @property {URL} url
33
+ * @property {any} state
34
+ * @property {any} info
35
+ * @property {number} timestamp
36
+ * @readonly
37
+ */
38
+
39
+ /** @typedef {Response|DocumentFragment|Element|HTMLDocument|URL} HandlerResult */
40
+ /** @typedef {(request: Request, context: RouteContextObject) => Promise<HandlerResult>} RouteHandler */
41
+
42
+ /** @typedef {Readonly<Record<string, unknown>> & {default?: RouteHandler|HandlerResult, title?: string, description?: string, styles?: CSSStyleSheet|CSSStyleSheet[]}} Module */
43
+
44
+ /**
45
+ * @type HTMLElement
46
+ */
47
+ let root = document.body;
48
+
49
+ /**
50
+ *
51
+ * @param {string|HTMLElement} newRoot
52
+ * @param {DocumentOrShadowRoot} base
53
+ */
54
+ export function setRoot(newRoot, base = document) {
55
+ if (typeof newRoot === 'string') {
56
+ setRoot(base.getElementById(newRoot));
57
+ } else if (newRoot instanceof HTMLElement) {
58
+ root = newRoot;
59
+ } else {
60
+ throw new TypeError('New root must be an `Element` or `id` of an element.');
61
+ }
62
+ }
63
+
64
+ /**
65
+ *
66
+ * @param {HTMLFormElement|HTMLButtonElement|HTMLAnchorElement} source
67
+ * @returns {"GET"|"POST"}
68
+ */
69
+ function getRequestMethod(source) {
70
+ if (! (source instanceof HTMLElement) || source instanceof HTMLAnchorElement) {
71
+ return 'GET';
72
+ } else if (source instanceof HTMLFormElement) {
73
+ return source.method.toUpperCase();
74
+ } else if (! (source instanceof HTMLButtonElement)) {
75
+ return 'GET';
76
+ } else if (source.hasAttribute('formmethod') && source.formMethod.length !== 0) {
77
+ return source.formMethod.toUpperCase();
78
+ } else if (source.form instanceof HTMLFormElement) {
79
+ return source.form.method.toUpperCase();
80
+ } else {
81
+ console.warn('Not sure this should be possible...');
82
+ return 'GET';
83
+ }
84
+ }
85
+
86
+ /**
87
+ *
88
+ * @param {NavigationEvent} event
89
+ */
90
+ export async function handleNavigation(event) {
91
+ if (! (event instanceof NavigateEvent)) {
92
+ throw new TypeError('Not a navigation event.');
93
+ } else if (event.signal.aborted) {
94
+ throw event.signal.reason;
95
+ } else {
96
+ const method = getRequestMethod(event.sourceElement);
97
+ const request = new Request(event.destination.url, {
98
+ // `sourceElement` could be a form, a `<button type="submit">`, or an `<a>
99
+ method: method,
100
+ body: method === 'GET' ? undefined : event.formData,// ?? new FormData(event.sourceElement?.form ?? event.sourceElement),
101
+ signal: event.signal,
102
+ });
103
+
104
+ const { result, specifier, hasRegExpGroups } = lookupRoute(event.destination.url);
105
+
106
+ if (typeof specifier !== 'string' || result === null) {
107
+ const resp = await fetch(request);
108
+ await updateContent(resp);
109
+ } else {
110
+ const params = hasRegExpGroups ? {
111
+ ...result.protocol.groups, ...result.username.groups, ...result.password.groups, ...result.hostname.groups,
112
+ ...result.port.groups, ...result.pathname.groups, ...result.search.groups, ...result.hash.groups,
113
+ }: {};
114
+
115
+ delete params['0'];
116
+ const module = await import(specifier);
117
+ const stack = new DisposableStack();
118
+ const controller = stack.adopt(
119
+ new AbortController(),
120
+ controller => controller.abort(new DOMException('Stack was disposed.', 'AbortError')),
121
+ );
122
+
123
+ const timestamp = performance.now();
124
+ const signal = AbortSignal.any([controller.signal, request.signal]);
125
+
126
+ /**
127
+ * @type {RouteContextObject}
128
+ */
129
+ const context = Object.freeze({
130
+ timestamp,
131
+ stack,
132
+ controller,
133
+ type: event.navigationType,
134
+ state: event.destination.getState(),
135
+ info: event.info,
136
+ url: new URL(event.destination.url),
137
+ signal,
138
+ result,
139
+ params,
140
+ });
141
+
142
+ try {
143
+ return await handleRequestModule(request, context, module);
144
+ } catch(err) {
145
+ reportError(err);
146
+ } finally {
147
+ stack.dispose();
148
+ }
149
+ }
150
+ }
151
+ }
152
+
153
+ /**
154
+ *
155
+ * @param {unknown} routes
156
+ * @param {object} config
157
+ * @param {HTMLElement|string} [config.root]
158
+ * @param {boolean} [config.preload=false]
159
+ * @param {AbortSignal} [config.signal]
160
+ */
161
+ export function init(routes, {
162
+ root,
163
+ preload = false,
164
+ signal,
165
+ } = {}) {
166
+ if (typeof routes === 'string') {
167
+ init(JSON.parse(document.scripts.namedItem(routes).textContent), { root, preload, signal });
168
+ } else if (typeof routes === 'number') {
169
+ init(JSON.parse(document.scripts.item(routes).textContent), { root, preload, signal });
170
+ } else if (routes instanceof HTMLScriptElement) {
171
+ init(JSON.parse(routes.textContent), { root, preload, signal });
172
+ } else if (typeof routes === 'object') {
173
+ Object.entries(routes).forEach(([key, val]) => registerModule(key, val));
174
+
175
+ if (typeof root === 'string' || root instanceof HTMLElement) {
176
+ setRoot(root);
177
+ }
178
+
179
+ navigation.addEventListener('navigate', event => {
180
+ if (event.canIntercept && event.destination.url.startsWith(location.origin) && ! event.sourceElement?.classList?.contains?.('no-router')) {
181
+ event.intercept({ handler: () => handleNavigation(event) });
182
+ }
183
+ }, { signal });
184
+
185
+ if (preload) {
186
+ observePreloadsOn(document.body);
187
+ }
188
+ } else {
189
+ throw new TypeError(`Routes must be an object, \`<script>\`, or name/index of \`document.scripts\`. Got a ${typeof routes}.`);
190
+ }
191
+ }
192
+
193
+ /**
194
+ *
195
+ * @param {object} options
196
+ * @param {AbortSignal} [options.signal]
197
+ * @returns {Promise<NavigationHistoryEntry>}
198
+ */
199
+ export async function whenLoaded({ signal } = {}) {
200
+ const { resolve, reject, promise } = Promise.withResolvers();
201
+
202
+ if (signal?.aborted) {
203
+ reject(signal.reason);
204
+ } else {
205
+ const controller = new AbortController();
206
+ const opts = {
207
+ once: true,
208
+ signal: signal instanceof AbortSignal ? AbortSignal.any([signal, controller.signal]) : controller.signal,
209
+ };
210
+
211
+ navigation.addEventListener('navigatesuccess', () => {
212
+ resolve(navigation.currentEntry);
213
+ controller.abort();
214
+ }, opts);
215
+
216
+ navigation.addEventListener('navigateerror', event => {
217
+ reject(event.error);
218
+ controller.abort();
219
+ }, opts);
220
+
221
+ if (signal instanceof AbortSignal) {
222
+ signal.addEventListener('abort', ({ target }) => {
223
+ reject(target.reason);
224
+ controller.abort(target.reason);
225
+ }, { once: true, signal: controller.signal });
226
+ }
227
+ }
228
+
229
+ return promise;
230
+ }
231
+
232
+ /**
233
+ *
234
+ * @param {string|URL} newURL
235
+ * @param {NavigationOptions} options
236
+ * @returns {NavigationResult}
237
+ */
238
+ export const navigate = (newURL, options) => navigation.navigate(newURL, options);
239
+
240
+ /**
241
+ *
242
+ * @param {NavigationOptions} options
243
+ * @returns {NavigationResult}
244
+ */
245
+ export const back = (options) => navigation.back(options);
246
+
247
+ /**
248
+ *
249
+ * @param {NavigationOptions} options
250
+ * @returns {NavigationResult}
251
+ */
252
+ export const forward = (options) => navigation.forward(options);
253
+
254
+ /**
255
+ *
256
+ * @param {NavigationReloadOptions} options
257
+ * @returns {NavigationResult}
258
+ */
259
+ export const reload = (options) => navigation.reload(options);
260
+
261
+ /**
262
+ *
263
+ * @param {Request} request
264
+ * @param {RouteContextObject} context
265
+ * @param {Module} module
266
+ */
267
+ async function handleRequestModule(request, context, module) {
268
+ if (typeof module.default === 'undefined') {
269
+ throw new TypeError(`No default export in module for <${request.url}>.`);
270
+ } else if (typeof module.default === 'function') {
271
+ const result = await module.default(request, context);
272
+ await updateContent(result);
273
+ updateMeta(module);
274
+ } else {
275
+ await updateContent(module.default);
276
+ updateMeta(module);
277
+ }
278
+ }
279
+
280
+ function updateMeta({ title, description, styles }) {
281
+ if (typeof title === 'string') {
282
+ document.title = title;
283
+ }
284
+
285
+ if (typeof description === 'string') {
286
+ setDescription(description);
287
+ }
288
+
289
+ if (styles instanceof CSSStyleSheet) {
290
+ document.adoptedStyleSheets = [...document.adoptedStyleSheets, styles];
291
+ } else if (Array.isArray(styles) && styles.length !== 0) {
292
+ document.adoptedStyleSheets = [...document.adoptedStyleSheets, ...styles];
293
+ }
294
+ }
295
+
296
+ /**
297
+ *
298
+ * @param {HandlerResult} content
299
+ */
300
+ async function updateContent(content) {
301
+ if (content instanceof URL) {
302
+ navigate(content);
303
+ } else if (content instanceof Response) {
304
+ if (! content.ok) {
305
+ throw new DOMException(`${content.url} [${content.status}]`, 'NetworkError');
306
+ } else if (! content.headers.get('Content-Type')?.startsWith?.('text/html')) {
307
+ throw new TypeError(`Unsupported Content-Type for <${content.url}> - "${content.headers.get('Content-Type') ?? 'Unset'}".`);
308
+ } else {
309
+ const html = await content.text();
310
+ /** @type HTMLDocument */
311
+ const doc = Document.parseHTMLUnsafe(policy.createHTML(html)); // Unsafe, but necessary... Same-origin at least
312
+ await updateContent(doc);
313
+ }
314
+ } else if (content instanceof Element || content instanceof DocumentFragment) {
315
+ root.replaceChildren(content);
316
+ } else if (content instanceof HTMLDocument) {
317
+ document.title = content.title;
318
+ setDescription(content.head.querySelector(DESC_SELECTOR)?.content);
319
+
320
+ if (root instanceof HTMLBodyElement) {
321
+ root.replaceChildren(...content.body.childNodes);
322
+ } else if (root instanceof HTMLElement && typeof root.id === 'string') {
323
+ root.replaceChildren(...content.getElementById(root.id)?.childNodes ?? []);
324
+ } else {
325
+ throw new TypeError('Root must be `<body>` or an element with an `id`.');
326
+ }
327
+ } else {
328
+ throw new TypeError('Content must be an `Element`, `DocumentFragment`, `HTMLDocument`, or `Response`.');
329
+ }
330
+ }
331
+
332
+ /**
333
+ *
334
+ * @param {string} description
335
+ */
336
+ function setDescription(description = '') {
337
+ document.head.querySelectorAll(DESC_SELECTOR).forEach(el => el.content = description);
338
+ }
package/routes.js ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @type {Map<URLPattern, string>}
3
+ */
4
+ const reg = new Map();
5
+
6
+ /**
7
+ * @typedef RouteMatch
8
+ * @property {URLPatternResult|null} result The results of `pattern.exec(url)`
9
+ * @property {string|null} specifier The module specifier mapped to the URL
10
+ * @property {boolean} hasRegExpGroups
11
+ * @readonly
12
+ */
13
+
14
+ /**
15
+ * @type RouteMatch
16
+ */
17
+ const invalidMatchResult = Object.freeze({ result: null, specifier: null, hasRegExpGroups: false });
18
+
19
+ /**
20
+ * Finds the URLPattern that corresponds to the given URL
21
+ *
22
+ * @param {string} url
23
+ * @returns {URLPattern|undefined}
24
+ */
25
+ export const getRegistryKey = url => reg.keys().find(pattern => pattern.test(url));
26
+
27
+ /**
28
+ *
29
+ * @param {URLPattern} key
30
+ * @returns {string|null} The module specifier
31
+ */
32
+ export const getRegistrySpecifier = key => reg.get(key);
33
+
34
+ /**
35
+ *
36
+ * @param {string} url
37
+ * @returns {RouteMatch}
38
+ */
39
+ export function lookupRoute(url) {
40
+ const key = getRegistryKey(url);
41
+
42
+ if (key instanceof URLPattern) {
43
+ return Object.freeze({
44
+ result: key.exec(url),
45
+ specifier: reg.get(key),
46
+ hasRegExpGroups: key.hasRegExpGroups,
47
+ });
48
+ } else {
49
+ return invalidMatchResult;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Registers module `specifier` to handle routes matching `pattern`
55
+ *
56
+ * @param {string|URLPattern} pattern The pattern to handle
57
+ * @param {string|URL} specifier The module to register to the pattern
58
+ */
59
+ export function registerModule(pattern, specifier) {
60
+ if (typeof specifier !== 'string' && ! (specifier instanceof URL)) {
61
+ throw new TypeError(`Invalid specifier type ${typeof specifier}.`);
62
+ } else if (typeof pattern === 'string') {
63
+ reg.set(
64
+ URL.canParse(pattern) ? new URLPattern(pattern) : new URLPattern({ pathname: pattern }),
65
+ specifier.toString()
66
+ );
67
+ } else if (! (pattern instanceof URLPattern)) {
68
+ throw new TypeError(`Invalid pattner "${pattern}".`);
69
+ } else if (specifier instanceof URL) {
70
+ reg.set(pattern, specifier.href);
71
+ } else {
72
+ reg.set(pattern, specifier);
73
+ }
74
+ }