@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/cache.js ADDED
@@ -0,0 +1,145 @@
1
+ const cacheDefinitionKind = Symbol.for("@async/framework.cacheDefinition");
2
+
3
+ export function defineCache(options = {}) {
4
+ return {
5
+ [cacheDefinitionKind]: true,
6
+ kind: "cache-definition",
7
+ store: options.store ?? "memory",
8
+ ttl: options.ttl
9
+ };
10
+ }
11
+
12
+ export function createCacheRegistry(initialMap = {}, { now = () => Date.now() } = {}) {
13
+ const definitions = new Map();
14
+ const entries = new Map();
15
+
16
+ const registry = {
17
+ register(id, definition = defineCache()) {
18
+ assertId(id);
19
+ const normalized = normalizeDefinition(definition);
20
+ if (definitions.has(id)) {
21
+ throw new Error(`Cache "${id}" is already registered.`);
22
+ }
23
+ definitions.set(id, normalized);
24
+ return id;
25
+ },
26
+
27
+ registerMany(map) {
28
+ for (const [id, definition] of Object.entries(map ?? {})) {
29
+ registry.register(id, definition);
30
+ }
31
+ return registry;
32
+ },
33
+
34
+ resolve(id) {
35
+ assertId(id);
36
+ return definitions.get(id);
37
+ },
38
+
39
+ get(key) {
40
+ assertKey(key);
41
+ const entry = entries.get(key);
42
+ if (!entry) {
43
+ return undefined;
44
+ }
45
+ if (entry.expiresAt !== undefined && entry.expiresAt <= now()) {
46
+ entries.delete(key);
47
+ return undefined;
48
+ }
49
+ return entry.value;
50
+ },
51
+
52
+ set(key, value, options = {}) {
53
+ assertKey(key);
54
+ const ttl = options.ttl ?? resolvePolicy(key, options.cache)?.ttl;
55
+ entries.set(key, {
56
+ value,
57
+ expiresAt: ttl === undefined ? undefined : now() + ttl
58
+ });
59
+ return value;
60
+ },
61
+
62
+ async getOrSet(key, fn, options = {}) {
63
+ assertKey(key);
64
+ if (typeof fn !== "function") {
65
+ throw new TypeError("cache.getOrSet(key, fn) requires a function.");
66
+ }
67
+ const cached = registry.get(key);
68
+ if (cached !== undefined) {
69
+ return cached;
70
+ }
71
+ const value = await fn();
72
+ registry.set(key, value, options);
73
+ return value;
74
+ },
75
+
76
+ delete(key) {
77
+ assertKey(key);
78
+ return entries.delete(key);
79
+ },
80
+
81
+ clear(prefix) {
82
+ if (prefix === undefined) {
83
+ entries.clear();
84
+ return registry;
85
+ }
86
+ for (const key of [...entries.keys()]) {
87
+ if (key.startsWith(prefix)) {
88
+ entries.delete(key);
89
+ }
90
+ }
91
+ return registry;
92
+ },
93
+
94
+ snapshot() {
95
+ const snapshot = {};
96
+ for (const [key] of entries) {
97
+ const value = registry.get(key);
98
+ if (value !== undefined) {
99
+ snapshot[key] = value;
100
+ }
101
+ }
102
+ return snapshot;
103
+ },
104
+
105
+ restore(snapshot = {}) {
106
+ for (const [key, value] of Object.entries(snapshot ?? {})) {
107
+ registry.set(key, value);
108
+ }
109
+ return registry;
110
+ }
111
+ };
112
+
113
+ registry.registerMany(initialMap);
114
+ return registry;
115
+
116
+ function resolvePolicy(key, explicitId) {
117
+ if (explicitId !== undefined) {
118
+ return definitions.get(explicitId);
119
+ }
120
+ if (definitions.has(key)) {
121
+ return definitions.get(key);
122
+ }
123
+ const prefix = key.split(":")[0];
124
+ return definitions.get(prefix);
125
+ }
126
+ }
127
+
128
+ function normalizeDefinition(definition) {
129
+ if (definition?.[cacheDefinitionKind]) {
130
+ return definition;
131
+ }
132
+ return defineCache(definition);
133
+ }
134
+
135
+ function assertId(id) {
136
+ if (typeof id !== "string" || id.length === 0) {
137
+ throw new TypeError("Cache id must be a non-empty string.");
138
+ }
139
+ }
140
+
141
+ function assertKey(key) {
142
+ if (typeof key !== "string" || key.length === 0) {
143
+ throw new TypeError("Cache key must be a non-empty string.");
144
+ }
145
+ }
@@ -0,0 +1,182 @@
1
+ import { rawHtml, renderTemplate } from "./html.js";
2
+
3
+ const componentKind = Symbol.for("@async/framework.component");
4
+ let componentCounter = 0;
5
+
6
+ export function defineComponent(fn) {
7
+ if (typeof fn !== "function") {
8
+ throw new TypeError("defineComponent(fn) requires a function.");
9
+ }
10
+ Object.defineProperty(fn, componentKind, {
11
+ configurable: true,
12
+ value: true
13
+ });
14
+ return fn;
15
+ }
16
+
17
+ export const component = defineComponent;
18
+
19
+ export function createComponentRegistry(initialMap = {}) {
20
+ const entries = new Map();
21
+
22
+ const registry = {
23
+ register(id, Component) {
24
+ if (typeof id !== "string" || id.length === 0) {
25
+ throw new TypeError("Component id must be a non-empty string.");
26
+ }
27
+ if (!isComponent(Component) && typeof Component !== "function") {
28
+ throw new TypeError(`Component "${id}" must be a component function.`);
29
+ }
30
+ if (entries.has(id)) {
31
+ throw new Error(`Component "${id}" is already registered.`);
32
+ }
33
+ entries.set(id, Component);
34
+ return id;
35
+ },
36
+
37
+ registerMany(map) {
38
+ for (const [id, Component] of Object.entries(map ?? {})) {
39
+ registry.register(id, Component);
40
+ }
41
+ return registry;
42
+ },
43
+
44
+ resolve(id) {
45
+ if (typeof id !== "string" || id.length === 0) {
46
+ throw new TypeError("Component id must be a non-empty string.");
47
+ }
48
+ return entries.get(id);
49
+ }
50
+ };
51
+
52
+ registry.registerMany(initialMap);
53
+ return registry;
54
+ }
55
+
56
+ export function isComponent(value) {
57
+ return Boolean(value?.[componentKind]);
58
+ }
59
+
60
+ export function renderComponent(Component, props = {}, runtime, parentScope = "component") {
61
+ if (!isComponent(Component) && typeof Component !== "function") {
62
+ throw new TypeError("renderComponent(Component) requires a component function.");
63
+ }
64
+
65
+ const scope = `${parentScope}.${componentName(Component)}.${++componentCounter}`;
66
+ const cleanups = [];
67
+ const mountHooks = [];
68
+ const visibleHooks = [];
69
+ const context = createComponentContext({
70
+ runtime,
71
+ scope,
72
+ cleanups,
73
+ mountHooks,
74
+ visibleHooks
75
+ });
76
+
77
+ const output = Component.call(context, props);
78
+ const html = renderTemplate(output);
79
+
80
+ return {
81
+ html,
82
+ mount(target) {
83
+ for (const hook of mountHooks) {
84
+ const cleanup = hook(target);
85
+ if (typeof cleanup === "function") {
86
+ cleanups.push(cleanup);
87
+ }
88
+ }
89
+ },
90
+ visible(target, observeVisible) {
91
+ for (const hook of visibleHooks) {
92
+ const cleanup = observeVisible(target, hook);
93
+ if (typeof cleanup === "function") {
94
+ cleanups.push(cleanup);
95
+ }
96
+ }
97
+ },
98
+ cleanup() {
99
+ while (cleanups.length > 0) {
100
+ cleanups.pop()?.();
101
+ }
102
+ }
103
+ };
104
+ }
105
+
106
+ function createComponentContext({ runtime, scope, cleanups, mountHooks, visibleHooks }) {
107
+ const { signals, handlers, loader, server, router, cache } = runtime;
108
+ const context = {
109
+ scope,
110
+ signals,
111
+ handlers,
112
+ loader,
113
+ server,
114
+ router,
115
+ cache,
116
+
117
+ signal(name, initial) {
118
+ return signals.ensure(scoped(scope, name), initial);
119
+ },
120
+
121
+ computed(name, fn) {
122
+ const id = scoped(scope, name);
123
+ const ref = signals.ensure(id, undefined);
124
+ const cleanup = signals.effect(() => {
125
+ signals.set(id, fn.call(context));
126
+ });
127
+ cleanups.push(cleanup);
128
+ return ref;
129
+ },
130
+
131
+ asyncSignal(name, fn) {
132
+ const id = scoped(scope, name);
133
+ if (!signals.has(id)) {
134
+ signals.asyncSignal(id, fn);
135
+ }
136
+ return signals.ref(id);
137
+ },
138
+
139
+ effect(fn) {
140
+ const cleanup = signals.effect(() => fn.call(context));
141
+ cleanups.push(cleanup);
142
+ return cleanup;
143
+ },
144
+
145
+ handler(name, fn) {
146
+ const id = scoped(scope, name);
147
+ handlers.register(id, function runComponentHandler(handlerContext) {
148
+ return fn.call({ ...context, ...handlerContext }, handlerContext);
149
+ });
150
+ return id;
151
+ },
152
+
153
+ render(Child, childProps = {}) {
154
+ const child = renderComponent(Child, childProps, runtime, scope);
155
+ cleanups.push(child.cleanup);
156
+ mountHooks.push((target) => child.mount(target));
157
+ visibleHooks.push((target) => child.visible(target, loader._observeVisible));
158
+ return rawHtml(child.html);
159
+ },
160
+
161
+ onMount(fn) {
162
+ mountHooks.push((target) => fn.call(context, target));
163
+ },
164
+
165
+ onVisible(fn) {
166
+ visibleHooks.push((target) => fn.call(context, target));
167
+ }
168
+ };
169
+
170
+ return context;
171
+ }
172
+
173
+ function scoped(scope, name) {
174
+ if (typeof name !== "string" || name.length === 0) {
175
+ throw new TypeError("Scoped signal or handler name must be a non-empty string.");
176
+ }
177
+ return `${scope}.${name}`;
178
+ }
179
+
180
+ function componentName(Component) {
181
+ return Component.displayName || Component.name || "anonymous";
182
+ }
package/src/delay.js ADDED
@@ -0,0 +1,30 @@
1
+ export function delay(ms, signal) {
2
+ if (signal?.aborted) {
3
+ return Promise.reject(abortReason(signal));
4
+ }
5
+
6
+ return new Promise((resolve, reject) => {
7
+ let timer = setTimeout(done, ms);
8
+
9
+ function done() {
10
+ timer = undefined;
11
+ signal?.removeEventListener?.("abort", aborted);
12
+ resolve();
13
+ }
14
+
15
+ function aborted() {
16
+ if (timer !== undefined) {
17
+ clearTimeout(timer);
18
+ }
19
+ timer = undefined;
20
+ signal?.removeEventListener?.("abort", aborted);
21
+ reject(abortReason(signal));
22
+ }
23
+
24
+ signal?.addEventListener?.("abort", aborted, { once: true });
25
+ });
26
+ }
27
+
28
+ function abortReason(signal) {
29
+ return signal?.reason ?? new Error("Operation aborted");
30
+ }
@@ -0,0 +1,175 @@
1
+ import {
2
+ applyServerResult,
3
+ defaultInput,
4
+ resolveServerCommandArguments,
5
+ unwrapServerResult
6
+ } from "./server.js";
7
+
8
+ const builtInTokens = new Set(["preventDefault", "stopPropagation", "stopImmediatePropagation"]);
9
+ const builtInHandlers = {
10
+ preventDefault() {
11
+ this.event?.preventDefault?.();
12
+ },
13
+ stopPropagation() {
14
+ this.event?.stopPropagation?.();
15
+ },
16
+ stopImmediatePropagation() {
17
+ this.event?.stopImmediatePropagation?.();
18
+ }
19
+ };
20
+
21
+ export function createHandlerRegistry(initialMap = {}) {
22
+ const handlers = new Map();
23
+
24
+ const registry = {
25
+ register(id, fn) {
26
+ assertId(id);
27
+ if (typeof fn !== "function") {
28
+ throw new TypeError(`Handler "${id}" must be a function.`);
29
+ }
30
+ if (handlers.has(id)) {
31
+ throw new Error(`Handler "${id}" is already registered.`);
32
+ }
33
+ handlers.set(id, fn);
34
+ return id;
35
+ },
36
+
37
+ registerMany(map) {
38
+ for (const [id, fn] of Object.entries(map ?? {})) {
39
+ registry.register(id, fn);
40
+ }
41
+ return registry;
42
+ },
43
+
44
+ resolve(id) {
45
+ assertId(id);
46
+ return handlers.get(id);
47
+ },
48
+
49
+ async run(ref, context = {}) {
50
+ const steps = parseHandlerRef(ref);
51
+ const results = [];
52
+ let stopped = false;
53
+ const runContext = {
54
+ ...context,
55
+ handlers: registry,
56
+ input: context.input ?? defaultInput(context),
57
+ stop() {
58
+ stopped = true;
59
+ }
60
+ };
61
+
62
+ for (const step of steps) {
63
+ if (stopped) {
64
+ break;
65
+ }
66
+
67
+ if (step.type === "server") {
68
+ if (!runContext.server || typeof runContext.server.run !== "function") {
69
+ throw new Error(`Server command "${step.id}" cannot run without a server registry.`);
70
+ }
71
+ const resolved = resolveServerCommandArguments(step.args, runContext);
72
+ const result = await runContext.server.run(step.id, resolved.args, {
73
+ ...runContext,
74
+ signalPaths: resolved.signalPaths,
75
+ signalValues: resolved.signalValues
76
+ });
77
+ await applyServerResult(result, runContext);
78
+ results.push(unwrapServerResult(result));
79
+ continue;
80
+ }
81
+
82
+ const handler = registry.resolve(step.id);
83
+ if (!handler) {
84
+ throw new Error(`Handler "${step.id}" is not registered.`);
85
+ }
86
+ const value = await handler.call(runContext, runContext);
87
+ if (!(builtInTokens.has(step.id) && handler === builtInHandlers[step.id])) {
88
+ results.push(value);
89
+ }
90
+ }
91
+
92
+ return results;
93
+ }
94
+ };
95
+
96
+ registry.registerMany(builtInHandlers);
97
+ registry.registerMany(initialMap);
98
+ return registry;
99
+ }
100
+
101
+ export function parseHandlerRef(ref) {
102
+ if (typeof ref !== "string" || ref.trim().length === 0) {
103
+ throw new TypeError("Handler ref must be a non-empty string.");
104
+ }
105
+
106
+ return ref
107
+ .split(";")
108
+ .map((part) => part.trim())
109
+ .filter(Boolean)
110
+ .map(parseCommand);
111
+ }
112
+
113
+ export function isHandlerToken(value) {
114
+ return builtInTokens.has(value);
115
+ }
116
+
117
+ function assertId(id) {
118
+ if (typeof id !== "string" || id.length === 0) {
119
+ throw new TypeError("Handler id must be a non-empty string.");
120
+ }
121
+ }
122
+
123
+ function parseCommand(command) {
124
+ if (command.startsWith("server.")) {
125
+ return parseServerCommand(command);
126
+ }
127
+ if (command.includes("(") || command.includes(")")) {
128
+ throw new Error(`Command "${command}" is not supported.`);
129
+ }
130
+ return { type: "handler", id: command };
131
+ }
132
+
133
+ function parseServerCommand(command) {
134
+ const open = command.indexOf("(");
135
+ if (open === -1 || !command.endsWith(")")) {
136
+ throw new Error(`Server command "${command}" must be called with parentheses.`);
137
+ }
138
+
139
+ const id = command.slice("server.".length, open).trim();
140
+ if (!isServerCommandId(id)) {
141
+ throw new Error(`Server command "${command}" has an invalid function id.`);
142
+ }
143
+
144
+ return {
145
+ type: "server",
146
+ id,
147
+ args: parseArguments(command.slice(open + 1, -1))
148
+ };
149
+ }
150
+
151
+ function parseArguments(source) {
152
+ if (source.trim().length === 0) {
153
+ return [];
154
+ }
155
+
156
+ return source
157
+ .split(",")
158
+ .map((part) => part.trim())
159
+ .filter(Boolean)
160
+ .map(parseArgument);
161
+ }
162
+
163
+ function parseArgument(token) {
164
+ if (!/^[^\s,();]+$/.test(token)) {
165
+ throw new Error(`Argument "${token}" is not supported.`);
166
+ }
167
+ if (token.startsWith("$")) {
168
+ return { type: "local", name: token };
169
+ }
170
+ return { type: "signal", path: token };
171
+ }
172
+
173
+ function isServerCommandId(id) {
174
+ return /^[^.\s();]+(?:\.[^.\s();]+)*$/.test(id);
175
+ }
package/src/html.js ADDED
@@ -0,0 +1,65 @@
1
+ import { isSignalRef } from "./signals.js";
2
+
3
+ const templateKind = Symbol.for("@async/framework.template");
4
+ const rawKind = Symbol.for("@async/framework.rawHtml");
5
+
6
+ export function html(strings, ...values) {
7
+ return {
8
+ [templateKind]: true,
9
+ strings,
10
+ values
11
+ };
12
+ }
13
+
14
+ export function isTemplateResult(value) {
15
+ return Boolean(value?.[templateKind]);
16
+ }
17
+
18
+ export function rawHtml(value) {
19
+ return {
20
+ [rawKind]: true,
21
+ html: String(value ?? "")
22
+ };
23
+ }
24
+
25
+ export function renderTemplate(value) {
26
+ if (isTemplateResult(value)) {
27
+ let output = "";
28
+ for (let index = 0; index < value.strings.length; index += 1) {
29
+ output += value.strings[index];
30
+ if (index < value.values.length) {
31
+ output += renderValue(value.values[index]);
32
+ }
33
+ }
34
+ return output;
35
+ }
36
+ return renderValue(value);
37
+ }
38
+
39
+ function renderValue(value) {
40
+ if (value?.[rawKind]) {
41
+ return value.html;
42
+ }
43
+ if (isTemplateResult(value)) {
44
+ return renderTemplate(value);
45
+ }
46
+ if (Array.isArray(value)) {
47
+ return value.map(renderValue).join("");
48
+ }
49
+ if (isSignalRef(value)) {
50
+ return escapeHtml(value.value);
51
+ }
52
+ if (value == null || value === false) {
53
+ return "";
54
+ }
55
+ return escapeHtml(value);
56
+ }
57
+
58
+ export function escapeHtml(value) {
59
+ return String(value)
60
+ .replaceAll("&", "&amp;")
61
+ .replaceAll("<", "&lt;")
62
+ .replaceAll(">", "&gt;")
63
+ .replaceAll('"', "&quot;")
64
+ .replaceAll("'", "&#39;");
65
+ }
package/src/index.js ADDED
@@ -0,0 +1,12 @@
1
+ export { asyncSignal } from "./async-signal.js";
2
+ export { Async, createApp, defineApp } from "./app.js";
3
+ export { createCacheRegistry, defineCache } from "./cache.js";
4
+ export { component, createComponentRegistry, defineComponent } from "./component.js";
5
+ export { delay } from "./delay.js";
6
+ export { createHandlerRegistry } from "./handlers.js";
7
+ export { html } from "./html.js";
8
+ export { AsyncLoader } from "./loader.js";
9
+ export { createPartialRegistry } from "./partials.js";
10
+ export { createRouteRegistry, createRouter, defineRoute, route } from "./router.js";
11
+ export { createServerProxy, createServerRegistry } from "./server.js";
12
+ export { computed, createSignal, createSignalRegistry, effect, signal } from "./signals.js";