@async/framework 0.3.0 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@async/framework",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "No-build AsyncLoader app runtime with signals, command events, server calls, route partials, cache split, SSR activation, and streaming boundaries.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.1.0",
@@ -28,19 +28,21 @@
28
28
  "web-framework"
29
29
  ],
30
30
  "license": "MIT",
31
- "unpkg": "./src/index.js",
31
+ "unpkg": "./framework.js",
32
32
  "exports": {
33
33
  ".": {
34
- "unpkg": "./src/index.js",
35
- "browser": "./src/index.js",
34
+ "unpkg": "./framework.js",
35
+ "browser": "./framework.js",
36
36
  "import": "./src/index.js",
37
37
  "default": "./src/index.js"
38
38
  },
39
+ "./framework.js": "./framework.js",
39
40
  "./package.json": "./package.json"
40
41
  },
41
42
  "files": [
42
43
  "CHANGELOG.md",
43
44
  "README.md",
45
+ "framework.js",
44
46
  "src",
45
47
  "examples",
46
48
  "LICENSE",
@@ -48,10 +50,12 @@
48
50
  ],
49
51
  "scripts": {
50
52
  "async-pipeline": "async-pipeline",
53
+ "bundle": "node scripts/build-framework-bundle.js",
54
+ "bundle:check": "node scripts/build-framework-bundle.js --check",
51
55
  "docs:build": "node scripts/build-pages.js",
52
56
  "examples": "node --test tests/examples.test.js",
53
57
  "examples:check": "node --test tests/examples.test.js",
54
- "pack:check": "npm pack --dry-run --ignore-scripts",
58
+ "pack:check": "pnpm run bundle:check && npm pack --dry-run --ignore-scripts",
55
59
  "pipeline:github:check": "async-pipeline github check",
56
60
  "pipeline:github:generate": "async-pipeline github generate",
57
61
  "pipeline:pages": "async-pipeline run pages",
@@ -82,7 +82,18 @@ export function asyncSignal(id, fn) {
82
82
  signals: registry,
83
83
  id: registeredId,
84
84
  get server() {
85
- return registry._context?.().server;
85
+ const context = registry._context?.() ?? {};
86
+ const server = context.server;
87
+ if (typeof server?._withContext === "function") {
88
+ return server._withContext({
89
+ signals: registry,
90
+ router: context.router,
91
+ loader: context.loader,
92
+ cache: context.cache,
93
+ abort: activeAbort
94
+ });
95
+ }
96
+ return server;
86
97
  },
87
98
  get router() {
88
99
  return registry._context?.().router;
package/src/attributes.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const defaultPrefixes = Object.freeze({
2
2
  async: ["async:"],
3
+ class: ["class:"],
3
4
  signal: ["signal:"],
4
5
  on: ["on:"]
5
6
  });
@@ -11,6 +12,7 @@ export function defineAttributeConfig(config = {}) {
11
12
  export function normalizeAttributeConfig(config = {}) {
12
13
  return {
13
14
  async: normalizePrefixes(config.async, defaultPrefixes.async),
15
+ class: normalizePrefixes(config.class, defaultPrefixes.class),
14
16
  signal: normalizePrefixes(config.signal, defaultPrefixes.signal),
15
17
  on: normalizePrefixes(config.on, defaultPrefixes.on)
16
18
  };
package/src/cache.js CHANGED
@@ -34,6 +34,11 @@ export function createCacheRegistry(initialMap = {}, { now = () => Date.now(), r
34
34
  return registryApi;
35
35
  },
36
36
 
37
+ unregister(id) {
38
+ assertId(id);
39
+ return definitions.delete(id);
40
+ },
41
+
37
42
  resolve(id) {
38
43
  assertId(id);
39
44
  return definitions.get(id);
package/src/component.js CHANGED
@@ -1,4 +1,5 @@
1
- import { rawHtml, renderTemplate } from "./html.js";
1
+ import { attributeName } from "./attributes.js";
2
+ import { escapeHtml, rawHtml, renderTemplate } from "./html.js";
2
3
  import { attachRegistryInspection, createRegistryStore } from "./registry-store.js";
3
4
 
4
5
  const componentKind = Symbol.for("@async/framework.component");
@@ -44,6 +45,13 @@ export function createComponentRegistry(initialMap = {}, options = {}) {
44
45
  return registry;
45
46
  },
46
47
 
48
+ unregister(id) {
49
+ if (typeof id !== "string" || id.length === 0) {
50
+ throw new TypeError("Component id must be a non-empty string.");
51
+ }
52
+ return entries.delete(id);
53
+ },
54
+
47
55
  resolve(id) {
48
56
  if (typeof id !== "string" || id.length === 0) {
49
57
  throw new TypeError("Component id must be a non-empty string.");
@@ -71,29 +79,49 @@ export function renderComponent(Component, props = {}, runtime, parentScope = "c
71
79
 
72
80
  const scope = `${parentScope}.${componentName(Component)}.${++componentCounter}`;
73
81
  const cleanups = [];
74
- const mountHooks = [];
82
+ const attachHooks = [];
75
83
  const visibleHooks = [];
84
+ const destroyHooks = [];
85
+ const bindingIds = [];
86
+ const templateOptions = {
87
+ attributes: runtime.attributes,
88
+ signals: runtime.signals,
89
+ bind(value) {
90
+ const id = runtime.loader?._registerBinding?.(value);
91
+ if (!id) {
92
+ throw new Error("Inline template bindings require an AsyncLoader.");
93
+ }
94
+ bindingIds.push(id);
95
+ return id;
96
+ }
97
+ };
98
+ const renderScopedTemplate = (value) => renderTemplate(value, templateOptions);
76
99
  const context = createComponentContext({
77
100
  runtime,
78
101
  scope,
79
102
  cleanups,
80
- mountHooks,
81
- visibleHooks
103
+ attachHooks,
104
+ visibleHooks,
105
+ destroyHooks,
106
+ renderScopedTemplate
82
107
  });
83
108
 
84
109
  const output = Component.call(context, props);
85
- const html = renderTemplate(output);
110
+ const html = renderScopedTemplate(output);
86
111
 
87
112
  return {
88
113
  html,
89
- mount(target) {
90
- for (const hook of mountHooks) {
114
+ attach(target) {
115
+ for (const hook of attachHooks) {
91
116
  const cleanup = hook(target);
92
117
  if (typeof cleanup === "function") {
93
118
  cleanups.push(cleanup);
94
119
  }
95
120
  }
96
121
  },
122
+ mount(target) {
123
+ this.attach(target);
124
+ },
97
125
  visible(target, observeVisible) {
98
126
  for (const hook of visibleHooks) {
99
127
  const cleanup = observeVisible(target, hook);
@@ -103,15 +131,24 @@ export function renderComponent(Component, props = {}, runtime, parentScope = "c
103
131
  }
104
132
  },
105
133
  cleanup() {
134
+ while (destroyHooks.length > 0) {
135
+ destroyHooks.pop()?.();
136
+ }
106
137
  while (cleanups.length > 0) {
107
138
  cleanups.pop()?.();
108
139
  }
140
+ while (bindingIds.length > 0) {
141
+ runtime.loader?._releaseBinding?.(bindingIds.pop());
142
+ }
109
143
  }
110
144
  };
111
145
  }
112
146
 
113
- function createComponentContext({ runtime, scope, cleanups, mountHooks, visibleHooks }) {
147
+ function createComponentContext({ runtime, scope, cleanups, attachHooks, visibleHooks, destroyHooks, renderScopedTemplate }) {
114
148
  const { signals, handlers, loader, server, router, cache } = runtime;
149
+ const generatedHandlers = new WeakMap();
150
+ let generatedHandlerCounter = 0;
151
+ let generatedSignalCounter = 0;
115
152
  const context = {
116
153
  scope,
117
154
  signals,
@@ -122,12 +159,28 @@ function createComponentContext({ runtime, scope, cleanups, mountHooks, visibleH
122
159
  cache,
123
160
 
124
161
  signal(name, initial) {
125
- return signals.ensure(scoped(scope, name), initial);
162
+ if (arguments.length === 1) {
163
+ const id = scoped(scope, `signal.${++generatedSignalCounter}`);
164
+ const ref = signals.ensure(id, name);
165
+ cleanups.push(() => signals.unregister?.(id));
166
+ return ref;
167
+ }
168
+ const id = scoped(scope, name);
169
+ const created = !signals.has(id);
170
+ const ref = signals.ensure(id, initial);
171
+ if (created) {
172
+ cleanups.push(() => signals.unregister?.(id));
173
+ }
174
+ return ref;
126
175
  },
127
176
 
128
177
  computed(name, fn) {
129
178
  const id = scoped(scope, name);
179
+ const created = !signals.has(id);
130
180
  const ref = signals.ensure(id, undefined);
181
+ if (created) {
182
+ cleanups.push(() => signals.unregister?.(id));
183
+ }
131
184
  const cleanup = signals.effect(() => {
132
185
  signals.set(id, fn.call(context));
133
186
  });
@@ -137,9 +190,13 @@ function createComponentContext({ runtime, scope, cleanups, mountHooks, visibleH
137
190
 
138
191
  asyncSignal(name, fn) {
139
192
  const id = scoped(scope, name);
193
+ const created = !signals.has(id);
140
194
  if (!signals.has(id)) {
141
195
  signals.asyncSignal(id, fn);
142
196
  }
197
+ if (created) {
198
+ cleanups.push(() => signals.unregister?.(id));
199
+ }
143
200
  return signals.ref(id);
144
201
  },
145
202
 
@@ -150,31 +207,91 @@ function createComponentContext({ runtime, scope, cleanups, mountHooks, visibleH
150
207
  },
151
208
 
152
209
  handler(name, fn) {
153
- const id = scoped(scope, name);
154
- handlers.register(id, function runComponentHandler(handlerContext) {
155
- return fn.call({ ...context, ...handlerContext }, handlerContext);
156
- });
157
- return id;
210
+ if (typeof name === "function" && fn === undefined) {
211
+ const inlineFn = name;
212
+ if (generatedHandlers.has(inlineFn)) {
213
+ return generatedHandlers.get(inlineFn);
214
+ }
215
+ const id = registerScopedHandler(`handler.${++generatedHandlerCounter}`, inlineFn);
216
+ generatedHandlers.set(inlineFn, id);
217
+ return id;
218
+ }
219
+ if (typeof fn !== "function") {
220
+ throw new TypeError("this.handler(name, fn) or this.handler(fn) requires a function.");
221
+ }
222
+ return registerScopedHandler(name, fn);
158
223
  },
159
224
 
160
225
  render(Child, childProps = {}) {
161
226
  const child = renderComponent(Child, childProps, runtime, scope);
162
227
  cleanups.push(child.cleanup);
163
- mountHooks.push((target) => child.mount(target));
228
+ attachHooks.push((target) => child.attach(target));
164
229
  visibleHooks.push((target) => child.visible(target, loader._observeVisible));
165
230
  return rawHtml(child.html);
166
231
  },
167
232
 
233
+ suspense(signalRef, views) {
234
+ const id = signalRef?.id;
235
+ if (!id) {
236
+ throw new TypeError("this.suspense(signalRef, views) requires a signal ref.");
237
+ }
238
+
239
+ const normalized = normalizeSuspenseViews(views);
240
+ const chunks = [];
241
+ for (const state of ["loading", "ready", "error"]) {
242
+ const view = normalized[state];
243
+ if (!view) {
244
+ continue;
245
+ }
246
+ const attr = attributeName(runtime.attributes, "async", state);
247
+ const body = renderScopedTemplate(view.call(context, signalRef));
248
+ chunks.push(`<template ${attr}="${escapeHtml(id)}">${body}</template>`);
249
+ }
250
+ return rawHtml(chunks.join(""));
251
+ },
252
+
253
+ on(eventName, fn) {
254
+ if (typeof eventName !== "string" || eventName.length === 0) {
255
+ throw new TypeError("Component lifecycle event must be a non-empty string.");
256
+ }
257
+ if (typeof fn !== "function") {
258
+ throw new TypeError(`Component lifecycle "${eventName}" requires a function.`);
259
+ }
260
+ const event = eventName === "mount" ? "attach" : eventName;
261
+ if (event === "attach") {
262
+ attachHooks.push((target) => fn.call(context, target));
263
+ return;
264
+ }
265
+ if (event === "visible") {
266
+ visibleHooks.push((target) => fn.call(context, target));
267
+ return;
268
+ }
269
+ if (event === "destroy") {
270
+ destroyHooks.push(() => fn.call(context));
271
+ return;
272
+ }
273
+ throw new Error(`Unsupported component lifecycle event "${eventName}".`);
274
+ },
275
+
168
276
  onMount(fn) {
169
- mountHooks.push((target) => fn.call(context, target));
277
+ context.on("attach", fn);
170
278
  },
171
279
 
172
280
  onVisible(fn) {
173
- visibleHooks.push((target) => fn.call(context, target));
281
+ context.on("visible", fn);
174
282
  }
175
283
  };
176
284
 
177
285
  return context;
286
+
287
+ function registerScopedHandler(name, fn) {
288
+ const id = scoped(scope, name);
289
+ handlers.register(id, function runComponentHandler(handlerContext) {
290
+ return fn.call({ ...context, ...handlerContext }, handlerContext);
291
+ });
292
+ cleanups.push(() => handlers.unregister?.(id));
293
+ return id;
294
+ }
178
295
  }
179
296
 
180
297
  function scoped(scope, name) {
@@ -184,6 +301,21 @@ function scoped(scope, name) {
184
301
  return `${scope}.${name}`;
185
302
  }
186
303
 
304
+ function normalizeSuspenseViews(views) {
305
+ const normalized = typeof views === "function" ? { ready: views } : views;
306
+ if (!normalized || typeof normalized !== "object" || Array.isArray(normalized)) {
307
+ throw new TypeError("this.suspense(signalRef, views) requires views to be a function or object.");
308
+ }
309
+
310
+ for (const state of ["loading", "ready", "error"]) {
311
+ if (Object.hasOwn(normalized, state) && normalized[state] !== undefined && typeof normalized[state] !== "function") {
312
+ throw new TypeError(`this.suspense(signalRef, views) view "${state}" must be a function.`);
313
+ }
314
+ }
315
+
316
+ return normalized;
317
+ }
318
+
187
319
  function componentName(Component) {
188
320
  return Component.displayName || Component.name || "anonymous";
189
321
  }
package/src/handlers.js CHANGED
@@ -6,11 +6,10 @@ import {
6
6
  } from "./server.js";
7
7
  import { attachRegistryInspection, createRegistryStore } from "./registry-store.js";
8
8
 
9
- const builtInTokens = new Set(["preventDefault", "stopPropagation", "stopImmediatePropagation"]);
9
+ const builtInTokens = new Set(["prevent", "preventDefault", "stopPropagation", "stopImmediatePropagation"]);
10
10
  const builtInHandlers = {
11
- preventDefault() {
12
- this.event?.preventDefault?.();
13
- },
11
+ prevent: preventDefault,
12
+ preventDefault,
14
13
  stopPropagation() {
15
14
  this.event?.stopPropagation?.();
16
15
  },
@@ -19,6 +18,10 @@ const builtInHandlers = {
19
18
  }
20
19
  };
21
20
 
21
+ function preventDefault() {
22
+ this.event?.preventDefault?.();
23
+ }
24
+
22
25
  export function createHandlerRegistry(initialMap = {}, options = {}) {
23
26
  const registryStore = options.registry ?? createRegistryStore();
24
27
  const type = options.type ?? "handler";
@@ -44,6 +47,11 @@ export function createHandlerRegistry(initialMap = {}, options = {}) {
44
47
  return registry;
45
48
  },
46
49
 
50
+ unregister(id) {
51
+ assertId(id);
52
+ return handlers.delete(id);
53
+ },
54
+
47
55
  resolve(id) {
48
56
  assertId(id);
49
57
  return handlers.get(id);
package/src/html.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { isSignalRef } from "./signals.js";
2
+ import { attributeName, matchAttribute, normalizeAttributeConfig } from "./attributes.js";
2
3
 
3
4
  const templateKind = Symbol.for("@async/framework.template");
4
5
  const rawKind = Symbol.for("@async/framework.rawHtml");
@@ -22,29 +23,36 @@ export function rawHtml(value) {
22
23
  };
23
24
  }
24
25
 
25
- export function renderTemplate(value) {
26
+ export function renderTemplate(value, options = {}) {
26
27
  if (isTemplateResult(value)) {
28
+ const context = createRenderContext(options);
27
29
  let output = "";
28
30
  for (let index = 0; index < value.strings.length; index += 1) {
29
31
  output += value.strings[index];
30
32
  if (index < value.values.length) {
31
- output += renderValue(value.values[index]);
33
+ output += renderValue(value.values[index], {
34
+ ...context,
35
+ attribute: readAttributeContext(value.strings[index])
36
+ });
32
37
  }
33
38
  }
34
39
  return output;
35
40
  }
36
- return renderValue(value);
41
+ return renderValue(value, createRenderContext(options));
37
42
  }
38
43
 
39
- function renderValue(value) {
44
+ function renderValue(value, context = createRenderContext()) {
40
45
  if (value?.[rawKind]) {
41
46
  return value.html;
42
47
  }
43
48
  if (isTemplateResult(value)) {
44
- return renderTemplate(value);
49
+ return renderTemplate(value, context);
50
+ }
51
+ if (context.attribute) {
52
+ return renderAttributeValue(value, context);
45
53
  }
46
54
  if (Array.isArray(value)) {
47
- return value.map(renderValue).join("");
55
+ return value.map((item) => renderValue(item, context)).join("");
48
56
  }
49
57
  if (isSignalRef(value)) {
50
58
  return escapeHtml(value.value);
@@ -55,6 +63,91 @@ function renderValue(value) {
55
63
  return escapeHtml(value);
56
64
  }
57
65
 
66
+ function renderAttributeValue(value, context) {
67
+ const signalName = matchAttribute(context.attribute.name, context.attributes, "signal");
68
+ const className = matchAttribute(context.attribute.name, context.attributes, "class");
69
+ const signalPath = signalPathFor(value, context);
70
+
71
+ if (context.attribute.name === "value" && signalPath) {
72
+ const currentValue = readSignalValue(value, context);
73
+ const signalValueAttribute = attributeName(context.attributes, "signal", "value");
74
+ return `${escapeHtml(currentValue)}${context.attribute.quote} ${signalValueAttribute}=${context.attribute.quote}${escapeHtml(signalPath)}`;
75
+ }
76
+
77
+ if (signalName != null || className != null) {
78
+ if (signalPath) {
79
+ return escapeHtml(signalPath);
80
+ }
81
+ if (isInlineBindingValue(value)) {
82
+ return escapeHtml(registerInlineBinding(value, context));
83
+ }
84
+ }
85
+
86
+ return renderValueAsAttributeLiteral(value, context);
87
+ }
88
+
89
+ function renderValueAsAttributeLiteral(value, context) {
90
+ if (Array.isArray(value)) {
91
+ return value.map((item) => renderValueAsAttributeLiteral(item, context)).join("");
92
+ }
93
+ if (isSignalRef(value)) {
94
+ return escapeHtml(value.value);
95
+ }
96
+ if (value == null || value === false) {
97
+ return "";
98
+ }
99
+ return escapeHtml(value);
100
+ }
101
+
102
+ function createRenderContext(options = {}) {
103
+ return {
104
+ ...options,
105
+ attributes: normalizeAttributeConfig(options.attributes)
106
+ };
107
+ }
108
+
109
+ function readAttributeContext(source) {
110
+ const match = source.match(/(?:^|[\s<])([^\s"'=<>`]+)\s*=\s*(["'])$/);
111
+ if (!match) {
112
+ return null;
113
+ }
114
+ return {
115
+ name: match[1],
116
+ quote: match[2]
117
+ };
118
+ }
119
+
120
+ function signalPathFor(value, context) {
121
+ if (isSignalRef(value)) {
122
+ return value.id;
123
+ }
124
+ if (typeof value === "string" && context.signals?.has?.(value)) {
125
+ return value;
126
+ }
127
+ return null;
128
+ }
129
+
130
+ function readSignalValue(value, context) {
131
+ if (isSignalRef(value)) {
132
+ return value.value;
133
+ }
134
+ if (typeof value === "string" && context.signals?.has?.(value)) {
135
+ return context.signals.get(value);
136
+ }
137
+ return value;
138
+ }
139
+
140
+ function isInlineBindingValue(value) {
141
+ return Boolean(value && typeof value === "object");
142
+ }
143
+
144
+ function registerInlineBinding(value, context) {
145
+ if (typeof context.bind !== "function") {
146
+ return value;
147
+ }
148
+ return context.bind(value);
149
+ }
150
+
58
151
  export function escapeHtml(value) {
59
152
  return String(value)
60
153
  .replaceAll("&", "&amp;")