@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/app.js ADDED
@@ -0,0 +1,383 @@
1
+ import { createCacheRegistry } from "./cache.js";
2
+ import { createComponentRegistry } from "./component.js";
3
+ import { createHandlerRegistry } from "./handlers.js";
4
+ import { AsyncLoader } from "./loader.js";
5
+ import { createPartialRegistry } from "./partials.js";
6
+ import { createRouteRegistry, createRouter } from "./router.js";
7
+ import { createServerRegistry } from "./server.js";
8
+ import { createSignal, createSignalRegistry } from "./signals.js";
9
+
10
+ const registryTypes = new Set(["signal", "handler", "server", "partial", "route", "component"]);
11
+
12
+ export function defineApp(initial) {
13
+ const declarations = emptyDeclarations();
14
+ const runtimes = new Set();
15
+
16
+ const app = {
17
+ use(typeOrModule, entries) {
18
+ const normalized = normalizeUse(typeOrModule, entries);
19
+ appendDeclarations(declarations, normalized);
20
+ for (const runtime of runtimes) {
21
+ runtime._applyUse(normalized);
22
+ }
23
+ return app;
24
+ },
25
+
26
+ snapshot() {
27
+ return cloneDeclarations(declarations);
28
+ },
29
+
30
+ start(options = {}) {
31
+ const runtime = createApp(app, options).start();
32
+ app.runtime = runtime;
33
+ return runtime;
34
+ },
35
+
36
+ _attach(runtime) {
37
+ runtimes.add(runtime);
38
+ return () => app._detach(runtime);
39
+ },
40
+
41
+ _detach(runtime) {
42
+ runtimes.delete(runtime);
43
+ }
44
+ };
45
+
46
+ if (initial) {
47
+ app.use(initial);
48
+ }
49
+
50
+ return app;
51
+ }
52
+
53
+ export function createApp(appOrDefinition = Async, options = {}) {
54
+ const app = isAppHub(appOrDefinition) ? appOrDefinition : defineApp(appOrDefinition ?? {});
55
+ const target = options.target ?? "browser";
56
+ const snapshot = app.snapshot();
57
+ const signals = options.signals ?? createSignalRegistry(snapshot.signal);
58
+ const handlers = options.handlers ?? createHandlerRegistry(snapshot.handler);
59
+ const serverCache = createCacheRegistry(snapshot.cache.server);
60
+ const browserCache = createCacheRegistry(snapshot.cache.browser);
61
+ const server = options.server ?? createServerRegistry(snapshot.server);
62
+ const partials = options.partials ?? createPartialRegistry(snapshot.partial);
63
+ const routes = options.routes ?? createRouteRegistry(snapshot.route);
64
+ const components = options.components ?? createComponentRegistry(snapshot.component);
65
+ let loader = options.loader;
66
+ let router = options.router;
67
+ let detach = () => {};
68
+ let started = false;
69
+ let destroyed = false;
70
+
71
+ applySnapshot(signals, browserCache, options.snapshot);
72
+ attachServerCache(server, serverCache);
73
+
74
+ const runtime = {
75
+ app,
76
+ target,
77
+ signals,
78
+ handlers,
79
+ server,
80
+ partials,
81
+ routes,
82
+ components,
83
+ browser: {
84
+ cache: browserCache
85
+ },
86
+ loader,
87
+ router,
88
+
89
+ start() {
90
+ assertActive();
91
+ if (started) {
92
+ return runtime;
93
+ }
94
+ started = true;
95
+
96
+ if (target !== "server") {
97
+ loader = loader ?? AsyncLoader({
98
+ root: options.root,
99
+ signals,
100
+ handlers,
101
+ server,
102
+ cache: browserCache
103
+ });
104
+ runtime.loader = loader;
105
+
106
+ configureServerContext({ cache: browserCache });
107
+ signals._setContext?.({ server, loader, cache: browserCache });
108
+
109
+ loader.start();
110
+
111
+ if (router !== false && (router || shouldStartRouter(routes, options))) {
112
+ router = router ?? createRouter({
113
+ mode: options.mode ?? "ssr-spa",
114
+ root: options.root,
115
+ boundary: options.boundary ?? "route",
116
+ routes,
117
+ loader,
118
+ signals,
119
+ handlers,
120
+ server,
121
+ cache: browserCache,
122
+ partials,
123
+ fetch: options.fetch,
124
+ routeEndpoint: options.routeEndpoint
125
+ });
126
+ runtime.router = router;
127
+ loader.router = router;
128
+ configureServerContext({ cache: browserCache, router });
129
+ router.start();
130
+ }
131
+ } else {
132
+ configureServerContext({ cache: serverCache });
133
+ signals._setContext?.({ server, cache: serverCache });
134
+ }
135
+
136
+ return runtime;
137
+ },
138
+
139
+ use(typeOrModule, entries) {
140
+ app.use(typeOrModule, entries);
141
+ return runtime;
142
+ },
143
+
144
+ async render(url) {
145
+ assertActive();
146
+ configureServerContext({ cache: serverCache });
147
+ signals._setContext?.({ server, cache: serverCache });
148
+ const matched = routes.match(url);
149
+ if (!matched) {
150
+ return {
151
+ html: renderDocument("", { status: 404, signals, browserCache, boundary: options.boundary ?? "route" }),
152
+ status: 404,
153
+ signals: signals.snapshot(),
154
+ cache: { browser: browserCache.snapshot() }
155
+ };
156
+ }
157
+
158
+ const partialId = matched.route.partial;
159
+ const result = partialId && partials.resolve(partialId)
160
+ ? await partials.render(partialId, matched.params, {
161
+ params: matched.params,
162
+ route: matched.route,
163
+ signals,
164
+ handlers,
165
+ server,
166
+ cache: serverCache,
167
+ browserCache,
168
+ partials,
169
+ request: options.request,
170
+ locals: options.locals
171
+ })
172
+ : { html: "" };
173
+
174
+ if (result.signals) {
175
+ for (const [path, value] of Object.entries(result.signals)) {
176
+ setOrRegisterSignal(signals, path, value);
177
+ }
178
+ }
179
+ if (result.cache?.browser) {
180
+ browserCache.restore(result.cache.browser);
181
+ }
182
+
183
+ const status = result.status ?? 200;
184
+ return {
185
+ html: renderDocument(result.html, { status, signals, browserCache, boundary: result.boundary ?? options.boundary ?? "route" }),
186
+ status,
187
+ signals: signals.snapshot(),
188
+ cache: { browser: browserCache.snapshot() }
189
+ };
190
+ },
191
+
192
+ destroy() {
193
+ if (destroyed) {
194
+ return;
195
+ }
196
+ destroyed = true;
197
+ detach();
198
+ router?.destroy?.();
199
+ loader?.destroy?.();
200
+ signals.destroy?.();
201
+ },
202
+
203
+ _applyUse(normalized) {
204
+ applyUseToRuntime(runtime, normalized);
205
+ }
206
+ };
207
+
208
+ server.cache = serverCache;
209
+ runtime.server.cache = serverCache;
210
+ detach = app._attach(runtime);
211
+
212
+ return runtime;
213
+
214
+ function configureServerContext(extra = {}) {
215
+ const cache = isLocalServerRegistry(server) ? serverCache : extra.cache;
216
+ server._setContext?.({
217
+ signals,
218
+ loader,
219
+ router,
220
+ cache,
221
+ request: options.request,
222
+ locals: options.locals
223
+ });
224
+ }
225
+
226
+ function assertActive() {
227
+ if (destroyed) {
228
+ throw new Error("Async app runtime has been destroyed.");
229
+ }
230
+ }
231
+ }
232
+
233
+ export const Async = defineApp();
234
+
235
+ function applyUseToRuntime(runtime, normalized) {
236
+ runtime.signals.registerMany(normalized.signal);
237
+ runtime.handlers.registerMany(normalized.handler);
238
+ if (typeof runtime.server.registerMany === "function") {
239
+ runtime.server.registerMany(normalized.server);
240
+ }
241
+ runtime.partials.registerMany(normalized.partial);
242
+ runtime.routes.registerMany(normalized.route);
243
+ runtime.components.registerMany(normalized.component);
244
+ runtime.browser.cache.registerMany(normalized.cache.browser);
245
+ runtime.server.cache.registerMany(normalized.cache.server);
246
+ }
247
+
248
+ function emptyDeclarations() {
249
+ return {
250
+ signal: {},
251
+ handler: {},
252
+ server: {},
253
+ partial: {},
254
+ route: {},
255
+ component: {},
256
+ cache: {
257
+ browser: {},
258
+ server: {}
259
+ }
260
+ };
261
+ }
262
+
263
+ function normalizeUse(typeOrModule, entries) {
264
+ const normalized = emptyDeclarations();
265
+
266
+ if (typeof typeOrModule === "string") {
267
+ if (!registryTypes.has(typeOrModule)) {
268
+ throw new Error(`Unknown Async registry type "${typeOrModule}".`);
269
+ }
270
+ normalized[typeOrModule] = { ...(entries ?? {}) };
271
+ return normalized;
272
+ }
273
+
274
+ if (!typeOrModule || typeof typeOrModule !== "object") {
275
+ throw new TypeError("Async.use(...) requires a registry type or module object.");
276
+ }
277
+
278
+ for (const [type, value] of Object.entries(typeOrModule)) {
279
+ if (type === "cache") {
280
+ normalized.cache.browser = { ...(value?.browser ?? {}) };
281
+ normalized.cache.server = { ...(value?.server ?? {}) };
282
+ continue;
283
+ }
284
+ if (!registryTypes.has(type)) {
285
+ throw new Error(`Unknown Async registry type "${type}".`);
286
+ }
287
+ normalized[type] = { ...(value ?? {}) };
288
+ }
289
+
290
+ return normalized;
291
+ }
292
+
293
+ function appendDeclarations(target, source) {
294
+ for (const type of registryTypes) {
295
+ addEntries(target[type], source[type], type);
296
+ }
297
+ addEntries(target.cache.browser, source.cache.browser, "cache.browser");
298
+ addEntries(target.cache.server, source.cache.server, "cache.server");
299
+ }
300
+
301
+ function addEntries(target, source, label) {
302
+ for (const [id, value] of Object.entries(source ?? {})) {
303
+ if (Object.hasOwn(target, id)) {
304
+ throw new Error(`${label} "${id}" is already registered.`);
305
+ }
306
+ target[id] = value;
307
+ }
308
+ }
309
+
310
+ function cloneDeclarations(source) {
311
+ return {
312
+ signal: { ...source.signal },
313
+ handler: { ...source.handler },
314
+ server: { ...source.server },
315
+ partial: { ...source.partial },
316
+ route: { ...source.route },
317
+ component: { ...source.component },
318
+ cache: {
319
+ browser: { ...source.cache.browser },
320
+ server: { ...source.cache.server }
321
+ }
322
+ };
323
+ }
324
+
325
+ function isAppHub(value) {
326
+ return Boolean(value && typeof value.use === "function" && typeof value.snapshot === "function");
327
+ }
328
+
329
+ function applySnapshot(signals, browserCache, snapshot = {}) {
330
+ for (const [path, value] of Object.entries(snapshot.signals ?? {})) {
331
+ setOrRegisterSignal(signals, path, value);
332
+ }
333
+ browserCache.restore(snapshot.cache?.browser);
334
+ }
335
+
336
+ function setOrRegisterSignal(signals, path, value) {
337
+ const id = String(path).split(".")[0];
338
+ if (signals.has?.(id)) {
339
+ signals.set(path, value);
340
+ return;
341
+ }
342
+ signals.register(id, createSignal(path === id ? value : {}));
343
+ if (path !== id) {
344
+ signals.set(path, value);
345
+ }
346
+ }
347
+
348
+ function attachServerCache(server, cache) {
349
+ try {
350
+ server.cache = cache;
351
+ } catch {
352
+ // Proxies that reject assignment can still receive cache through _setContext.
353
+ }
354
+ }
355
+
356
+ function isLocalServerRegistry(server) {
357
+ return typeof server?.registerMany === "function";
358
+ }
359
+
360
+ function shouldStartRouter(routes, options) {
361
+ return Boolean(options.routerOptions || options.mode || routes.entries().length > 0);
362
+ }
363
+
364
+ function renderDocument(routeHtml, { signals, browserCache, boundary }) {
365
+ const snapshot = {
366
+ signals: signals.snapshot(),
367
+ cache: {
368
+ browser: browserCache.snapshot()
369
+ }
370
+ };
371
+ return `<section data-async-boundary="${escapeAttribute(boundary)}">${routeHtml ?? ""}</section><script type="application/json" data-async-snapshot>${escapeScriptJson(snapshot)}</script>`;
372
+ }
373
+
374
+ function escapeAttribute(value) {
375
+ return String(value)
376
+ .replaceAll("&", "&amp;")
377
+ .replaceAll('"', "&quot;")
378
+ .replaceAll("<", "&lt;");
379
+ }
380
+
381
+ function escapeScriptJson(value) {
382
+ return JSON.stringify(value).replaceAll("<", "\\u003c");
383
+ }
@@ -0,0 +1,238 @@
1
+ const asyncSignalKind = Symbol.for("@async/framework.asyncSignal");
2
+
3
+ export function asyncSignal(id, fn) {
4
+ if (typeof id !== "string" || id.length === 0) {
5
+ throw new TypeError("asyncSignal(id, fn) requires a non-empty string id.");
6
+ }
7
+ if (typeof fn !== "function") {
8
+ throw new TypeError("asyncSignal(id, fn) requires a function.");
9
+ }
10
+
11
+ let value;
12
+ let loading = false;
13
+ let error = null;
14
+ let status = "idle";
15
+ let version = 0;
16
+ let registry;
17
+ let registeredId = id;
18
+ let activeController;
19
+ let activeAbort;
20
+ const subscribers = new Set();
21
+ const dependencyCleanups = new Set();
22
+
23
+ const state = {
24
+ [asyncSignalKind]: true,
25
+ kind: "async-signal",
26
+
27
+ get id() {
28
+ return registeredId;
29
+ },
30
+
31
+ get value() {
32
+ return value;
33
+ },
34
+
35
+ get loading() {
36
+ return loading;
37
+ },
38
+
39
+ get error() {
40
+ return error;
41
+ },
42
+
43
+ get status() {
44
+ return status;
45
+ },
46
+
47
+ get version() {
48
+ return version;
49
+ },
50
+
51
+ set(nextValue) {
52
+ value = nextValue;
53
+ loading = false;
54
+ error = null;
55
+ status = "ready";
56
+ notify();
57
+ return value;
58
+ },
59
+
60
+ refresh() {
61
+ if (!registry) {
62
+ throw new Error(`Async signal "${registeredId}" is not registered.`);
63
+ }
64
+
65
+ if (activeAbort && !activeAbort.aborted) {
66
+ activeAbort.cancel(new Error(`Async signal "${registeredId}" refreshed.`));
67
+ }
68
+
69
+ const runVersion = version + 1;
70
+ version = runVersion;
71
+ loading = true;
72
+ error = null;
73
+ status = "loading";
74
+
75
+ const controller = new AbortController();
76
+ activeController = controller;
77
+ activeAbort = controller.signal;
78
+ attachCancel(activeAbort, controller);
79
+ notify();
80
+
81
+ const context = {
82
+ signals: registry,
83
+ id: registeredId,
84
+ get server() {
85
+ return registry._context?.().server;
86
+ },
87
+ get router() {
88
+ return registry._context?.().router;
89
+ },
90
+ get loader() {
91
+ return registry._context?.().loader;
92
+ },
93
+ get cache() {
94
+ return registry._context?.().cache;
95
+ },
96
+ get version() {
97
+ return runVersion;
98
+ },
99
+ get abort() {
100
+ return activeAbort;
101
+ },
102
+ refresh() {
103
+ return state.refresh();
104
+ }
105
+ };
106
+
107
+ let outcome;
108
+ try {
109
+ outcome = registry._collectDependencies(() => fn.call(context));
110
+ } catch (cause) {
111
+ finishError(runVersion, cause);
112
+ return Promise.reject(cause);
113
+ }
114
+
115
+ syncDependencies(outcome.dependencies);
116
+
117
+ return Promise.resolve(outcome.value).then(
118
+ (nextValue) => {
119
+ if (!isCurrent(runVersion)) {
120
+ return value;
121
+ }
122
+ value = nextValue;
123
+ loading = false;
124
+ error = null;
125
+ status = "ready";
126
+ notify();
127
+ return value;
128
+ },
129
+ (cause) => {
130
+ if (!isCurrent(runVersion)) {
131
+ return value;
132
+ }
133
+ if (activeAbort?.aborted) {
134
+ loading = false;
135
+ status = value === undefined ? "idle" : "ready";
136
+ notify();
137
+ return value;
138
+ }
139
+ finishError(runVersion, cause);
140
+ return value;
141
+ }
142
+ );
143
+ },
144
+
145
+ cancel(reason) {
146
+ if (activeAbort && !activeAbort.aborted) {
147
+ activeAbort.cancel(reason);
148
+ }
149
+ },
150
+
151
+ subscribe(fn) {
152
+ if (typeof fn !== "function") {
153
+ throw new TypeError("subscribe(fn) requires a function.");
154
+ }
155
+ subscribers.add(fn);
156
+ return () => subscribers.delete(fn);
157
+ },
158
+
159
+ snapshot() {
160
+ return {
161
+ value,
162
+ loading,
163
+ error,
164
+ status,
165
+ version
166
+ };
167
+ },
168
+
169
+ _bindRegistry(nextRegistry, nextId) {
170
+ registry = nextRegistry;
171
+ registeredId = nextId;
172
+ queueMicrotask(() => {
173
+ if (registry === nextRegistry && status === "idle") {
174
+ state.refresh();
175
+ }
176
+ });
177
+ },
178
+
179
+ _dispose() {
180
+ state.cancel(new Error(`Async signal "${registeredId}" disposed.`));
181
+ for (const cleanup of dependencyCleanups) {
182
+ cleanup();
183
+ }
184
+ dependencyCleanups.clear();
185
+ subscribers.clear();
186
+ }
187
+ };
188
+
189
+ function finishError(runVersion, cause) {
190
+ if (!isCurrent(runVersion)) {
191
+ return;
192
+ }
193
+ loading = false;
194
+ error = cause;
195
+ status = "error";
196
+ notify();
197
+ }
198
+
199
+ function isCurrent(runVersion) {
200
+ return runVersion === version && activeController?.signal === activeAbort;
201
+ }
202
+
203
+ function syncDependencies(dependencies) {
204
+ for (const cleanup of dependencyCleanups) {
205
+ cleanup();
206
+ }
207
+ dependencyCleanups.clear();
208
+
209
+ for (const dependency of dependencies) {
210
+ const dependencyId = String(dependency).split(".")[0];
211
+ if (dependencyId && dependencyId !== registeredId) {
212
+ dependencyCleanups.add(registry.subscribe(dependency, () => state.refresh()));
213
+ }
214
+ }
215
+ }
216
+
217
+ function notify() {
218
+ for (const subscriber of [...subscribers]) {
219
+ subscriber(state);
220
+ }
221
+ }
222
+
223
+ return state;
224
+ }
225
+
226
+ export function isAsyncSignal(value) {
227
+ return Boolean(value?.[asyncSignalKind]);
228
+ }
229
+
230
+ function attachCancel(signal, controller) {
231
+ Object.defineProperty(signal, "cancel", {
232
+ configurable: true,
233
+ enumerable: false,
234
+ value(reason) {
235
+ controller.abort(reason);
236
+ }
237
+ });
238
+ }