@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/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 - 2026-06-17
|
|
4
|
+
|
|
5
|
+
- Reset `@async/framework` to Layer 1 AsyncLoader.
|
|
6
|
+
- Added signals, async signals, delegated handlers, component fragment helpers,
|
|
7
|
+
and out-of-order boundary swaps.
|
|
8
|
+
- Added Layer 2 command events, server calls, route partials, and client router
|
|
9
|
+
primitives.
|
|
10
|
+
- Added `Async.use(...)`, app runtimes, `createSignal`, `defineRoute`,
|
|
11
|
+
`defineComponent`, split browser/server cache registries, and SSR render
|
|
12
|
+
activation helpers.
|
|
13
|
+
- Added no-build static examples and Node test coverage.
|
|
14
|
+
- Added generated `@async/pipeline` verification, GitHub Pages, and release
|
|
15
|
+
workflow support.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 PatrickJS
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
# @async/framework
|
|
2
|
+
|
|
3
|
+
Layer 1 AsyncLoader plus small Layer 2 app, routing, server, cache, and SSR
|
|
4
|
+
primitives for no-build web apps: signals, async signals, delegated command
|
|
5
|
+
events, scoped fragment components, server calls, route partials, and
|
|
6
|
+
out-of-order boundary swaps without a virtual DOM.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pnpm add @async/framework
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```html
|
|
13
|
+
<main data-async-container>
|
|
14
|
+
<button type="button" on:click="decrement">-</button>
|
|
15
|
+
<strong data-async-text="count"></strong>
|
|
16
|
+
<button type="button" on:click="increment">+</button>
|
|
17
|
+
</main>
|
|
18
|
+
<script type="module" src="./main.js"></script>
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
import {
|
|
23
|
+
Async,
|
|
24
|
+
createSignal
|
|
25
|
+
} from "@async/framework";
|
|
26
|
+
|
|
27
|
+
Async.use({
|
|
28
|
+
signal: {
|
|
29
|
+
count: createSignal(0)
|
|
30
|
+
},
|
|
31
|
+
handler: {
|
|
32
|
+
increment() {
|
|
33
|
+
this.signals.update("count", (count) => count + 1);
|
|
34
|
+
},
|
|
35
|
+
decrement() {
|
|
36
|
+
this.signals.update("count", (count) => count - 1);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
Async.start({ root: document });
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## What It Is
|
|
45
|
+
|
|
46
|
+
`@async/framework` is the browser bootloader layer for Async apps. It keeps the
|
|
47
|
+
runtime small and explicit:
|
|
48
|
+
|
|
49
|
+
- No build step for consumers.
|
|
50
|
+
- No virtual DOM, diff path, hydration runtime, or component rerender loop.
|
|
51
|
+
- Signals are the state boundary.
|
|
52
|
+
- `Async.use(...)` registers app declarations before or after startup.
|
|
53
|
+
- Handlers live in a registry and run through delegated DOM events.
|
|
54
|
+
- Async signals use native `AbortSignal` cancellation and suppress stale async
|
|
55
|
+
completions.
|
|
56
|
+
- Browser and server cache declarations are structurally split.
|
|
57
|
+
- Boundaries can be swapped out of order and rescanned, which keeps server
|
|
58
|
+
streaming and partial HTML replacement simple.
|
|
59
|
+
|
|
60
|
+
Higher layers can still add JSX lowering, chunk manifests, server compilation,
|
|
61
|
+
or resumability metadata later. Layer 1 stays plain HTML plus ESM.
|
|
62
|
+
|
|
63
|
+
## Install
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pnpm add @async/framework
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The package is ESM-only and supports Node.js 24 and newer for tests, examples,
|
|
70
|
+
and package lifecycle tooling. Browser consumers import ESM directly.
|
|
71
|
+
|
|
72
|
+
## Core API
|
|
73
|
+
|
|
74
|
+
```js
|
|
75
|
+
import {
|
|
76
|
+
AsyncLoader,
|
|
77
|
+
Async,
|
|
78
|
+
asyncSignal,
|
|
79
|
+
createApp,
|
|
80
|
+
createCacheRegistry,
|
|
81
|
+
createComponentRegistry,
|
|
82
|
+
component,
|
|
83
|
+
computed,
|
|
84
|
+
createSignal,
|
|
85
|
+
createHandlerRegistry,
|
|
86
|
+
createPartialRegistry,
|
|
87
|
+
createRouteRegistry,
|
|
88
|
+
createRouter,
|
|
89
|
+
createServerProxy,
|
|
90
|
+
createServerRegistry,
|
|
91
|
+
createSignalRegistry,
|
|
92
|
+
defineApp,
|
|
93
|
+
defineCache,
|
|
94
|
+
defineComponent,
|
|
95
|
+
defineRoute,
|
|
96
|
+
delay,
|
|
97
|
+
effect,
|
|
98
|
+
html,
|
|
99
|
+
route,
|
|
100
|
+
signal
|
|
101
|
+
} from "@async/framework";
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### App Hub
|
|
105
|
+
|
|
106
|
+
`Async` is an exported app hub singleton. It is not installed on `globalThis`
|
|
107
|
+
unless you assign it there yourself.
|
|
108
|
+
|
|
109
|
+
```js
|
|
110
|
+
import {
|
|
111
|
+
Async,
|
|
112
|
+
createSignal,
|
|
113
|
+
defineCache,
|
|
114
|
+
defineRoute
|
|
115
|
+
} from "@async/framework";
|
|
116
|
+
|
|
117
|
+
Async.use({
|
|
118
|
+
signal: {
|
|
119
|
+
count: createSignal(0)
|
|
120
|
+
},
|
|
121
|
+
handler: {
|
|
122
|
+
increment() {
|
|
123
|
+
this.signals.update("count", (count) => count + 1);
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
server: {
|
|
127
|
+
async "products.get"(id) {
|
|
128
|
+
return this.cache.getOrSet(`products:${id}`, () => db.products.get(id));
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
route: {
|
|
132
|
+
"/products/:id": defineRoute("product.page")
|
|
133
|
+
},
|
|
134
|
+
cache: {
|
|
135
|
+
browser: {
|
|
136
|
+
product: defineCache({ ttl: 60_000 })
|
|
137
|
+
},
|
|
138
|
+
server: {
|
|
139
|
+
"products.get": defineCache({ ttl: 30_000 })
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
Async.start({ root: document });
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
You can also create isolated app hubs and runtimes:
|
|
148
|
+
|
|
149
|
+
```js
|
|
150
|
+
const app = defineApp();
|
|
151
|
+
app.use("signal", { count: createSignal(0) });
|
|
152
|
+
|
|
153
|
+
const runtime = createApp(app, { root: document }).start();
|
|
154
|
+
runtime.use("handler", {
|
|
155
|
+
increment() {
|
|
156
|
+
this.signals.update("count", (count) => count + 1);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Naming rules:
|
|
162
|
+
|
|
163
|
+
| Shape | Meaning |
|
|
164
|
+
| --- | --- |
|
|
165
|
+
| `define*` | Declaration or app shape that can be registered before runtime |
|
|
166
|
+
| `create*` | Runtime instance or mutable runtime primitive |
|
|
167
|
+
| `Async.use(...)` | App-level declaration registration |
|
|
168
|
+
| `registry.register(...)` | Low-level registration on a concrete runtime registry |
|
|
169
|
+
|
|
170
|
+
Singular registry keys are canonical: `signal`, `handler`, `server`,
|
|
171
|
+
`partial`, `route`, `component`, and nested `cache.browser` / `cache.server`.
|
|
172
|
+
|
|
173
|
+
### Signals
|
|
174
|
+
|
|
175
|
+
```js
|
|
176
|
+
const signals = createSignalRegistry();
|
|
177
|
+
|
|
178
|
+
signals.register("count", createSignal(0));
|
|
179
|
+
signals.register("products", createSignal([]));
|
|
180
|
+
|
|
181
|
+
signals.get("count");
|
|
182
|
+
signals.set("count", 1);
|
|
183
|
+
signals.update("count", (count) => count + 1);
|
|
184
|
+
signals.subscribe("count", (count) => console.log(count));
|
|
185
|
+
signals.ref("count").value;
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Initializer maps are supported:
|
|
189
|
+
|
|
190
|
+
```js
|
|
191
|
+
const signals = createSignalRegistry({
|
|
192
|
+
count: createSignal(0),
|
|
193
|
+
products: createSignal([])
|
|
194
|
+
});
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Nested paths read through the first registered signal id:
|
|
198
|
+
|
|
199
|
+
```js
|
|
200
|
+
signals.register("product", createSignal({ title: "Keyboard" }));
|
|
201
|
+
signals.get("product.title");
|
|
202
|
+
signals.set("product.title", "Headphones");
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
`signal(...)` remains a compatibility alias for `createSignal(...)`.
|
|
206
|
+
|
|
207
|
+
### Async Signals
|
|
208
|
+
|
|
209
|
+
Async signals add loading state, error state, versions, refresh, and cancel to a
|
|
210
|
+
normal signal value.
|
|
211
|
+
|
|
212
|
+
```js
|
|
213
|
+
const signals = createSignalRegistry({
|
|
214
|
+
productId: createSignal("sku-1")
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const product = signals.asyncSignal("product", async function () {
|
|
218
|
+
const id = this.signals.get("productId");
|
|
219
|
+
const response = await fetch(`/api/products/${id}`, {
|
|
220
|
+
signal: this.abort
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
return response.json();
|
|
224
|
+
});
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
The async function context includes:
|
|
228
|
+
|
|
229
|
+
| Field | Purpose |
|
|
230
|
+
| --- | --- |
|
|
231
|
+
| `this.signals` | The signal registry |
|
|
232
|
+
| `this.id` | Current async signal id |
|
|
233
|
+
| `this.version` | Run version |
|
|
234
|
+
| `this.abort` | Native `AbortSignal` with non-enumerable `cancel(reason?)` |
|
|
235
|
+
| `this.refresh()` | Start a new run |
|
|
236
|
+
|
|
237
|
+
`this.abort` can be passed directly to `fetch` or to `delay`:
|
|
238
|
+
|
|
239
|
+
```js
|
|
240
|
+
await delay(250, this.abort);
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
If a dependency read through `this.signals.get(...)` changes, the async signal
|
|
244
|
+
reruns and the previous run is aborted.
|
|
245
|
+
|
|
246
|
+
## HTML Protocol
|
|
247
|
+
|
|
248
|
+
AsyncLoader scans regular HTML attributes:
|
|
249
|
+
|
|
250
|
+
| Attribute | Behavior |
|
|
251
|
+
| --- | --- |
|
|
252
|
+
| `data-async-container` | Marks a scannable app root |
|
|
253
|
+
| `on:click="selectProduct"` | Delegated command event |
|
|
254
|
+
| `on:submit="preventDefault; save"` | Sequential command chain |
|
|
255
|
+
| `on:click="server.cart.add(productId)"` | Server command with signal args |
|
|
256
|
+
| `data-async-text="product.title"` | Text binding |
|
|
257
|
+
| `data-async-value="productId"` | Form value binding with writeback |
|
|
258
|
+
| `data-async-attr:disabled="product.$loading"` | Attribute binding |
|
|
259
|
+
| `data-async-class:selected="selected"` | Class toggle |
|
|
260
|
+
| `data-async-boundary="product"` | Async or streamed replacement boundary |
|
|
261
|
+
| `data-async-loading="product"` | Boundary loading template |
|
|
262
|
+
| `data-async-ready="product"` | Boundary ready template |
|
|
263
|
+
| `data-async-error="product"` | Boundary error template |
|
|
264
|
+
|
|
265
|
+
```html
|
|
266
|
+
<section data-async-boundary="product">
|
|
267
|
+
<template data-async-loading="product">
|
|
268
|
+
<p>Loading...</p>
|
|
269
|
+
</template>
|
|
270
|
+
<template data-async-ready="product">
|
|
271
|
+
<h1 data-async-text="product.title"></h1>
|
|
272
|
+
</template>
|
|
273
|
+
<template data-async-error="product">
|
|
274
|
+
<p data-async-text="product.$error.message"></p>
|
|
275
|
+
</template>
|
|
276
|
+
</section>
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### Command Events
|
|
280
|
+
|
|
281
|
+
`on:*` works with any native DOM event name. `on:mount` and `on:visible` are
|
|
282
|
+
reserved pseudo-events with cleanup support.
|
|
283
|
+
|
|
284
|
+
Command chains use semicolons and are awaited sequentially:
|
|
285
|
+
|
|
286
|
+
```html
|
|
287
|
+
<form on:submit="preventDefault; server.products.save(productId, $form)">
|
|
288
|
+
<input name="title">
|
|
289
|
+
<button>Save</button>
|
|
290
|
+
</form>
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Plain commands resolve through the handler registry. Built-ins are registered by
|
|
294
|
+
default:
|
|
295
|
+
|
|
296
|
+
```txt
|
|
297
|
+
preventDefault
|
|
298
|
+
stopPropagation
|
|
299
|
+
stopImmediatePropagation
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
`server.<id>(...)` resolves through the server registry or client proxy. Bare
|
|
303
|
+
arguments read signals. `$*` arguments read event locals:
|
|
304
|
+
|
|
305
|
+
| Argument | Value |
|
|
306
|
+
| --- | --- |
|
|
307
|
+
| `productId` | `signals.get("productId")` |
|
|
308
|
+
| `cart.quantity` | `signals.get("cart.quantity")` |
|
|
309
|
+
| `$value` | Current element value |
|
|
310
|
+
| `$checked` | Current element checked state |
|
|
311
|
+
| `$form` | Current form as a plain object |
|
|
312
|
+
| `$dataset` | Current element dataset as a plain object |
|
|
313
|
+
| `$event` | Raw DOM event, client-only |
|
|
314
|
+
| `$el` | Current element, client-only |
|
|
315
|
+
|
|
316
|
+
`$event` and `$el` are intentionally not serializable and cannot be passed to
|
|
317
|
+
`server.*(...)` commands.
|
|
318
|
+
|
|
319
|
+
Inline commands are not JavaScript. There is no `eval`, assignment, branching,
|
|
320
|
+
arithmetic, or inline `await`. Complex logic belongs in a registered handler:
|
|
321
|
+
|
|
322
|
+
```js
|
|
323
|
+
handlers.register("addToCart", async function () {
|
|
324
|
+
const productId = this.signals.get("productId");
|
|
325
|
+
const result = await this.server.cart.add(productId);
|
|
326
|
+
this.signals.set("cart", result.cart);
|
|
327
|
+
});
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
### Server Calls
|
|
331
|
+
|
|
332
|
+
Server registries run locally on the server and proxies call an HTTP endpoint
|
|
333
|
+
from the browser. Both expose the same dotted call shape.
|
|
334
|
+
|
|
335
|
+
```js
|
|
336
|
+
const server = createServerRegistry({
|
|
337
|
+
"cart.add"(productId, quantity) {
|
|
338
|
+
return {
|
|
339
|
+
value: { ok: true },
|
|
340
|
+
signals: {
|
|
341
|
+
cartCount: 3
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
Client proxy:
|
|
349
|
+
|
|
350
|
+
```js
|
|
351
|
+
const server = createServerProxy({
|
|
352
|
+
endpoint: "/__async/server",
|
|
353
|
+
signals,
|
|
354
|
+
loader,
|
|
355
|
+
router
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
await server.cart.add("sku-1", 2);
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
Server responses can include `value`, `signals`, `boundary`, `html`, `redirect`,
|
|
362
|
+
or `error`. Signal patches are applied before boundary swaps and redirects.
|
|
363
|
+
|
|
364
|
+
### Router And Partials
|
|
365
|
+
|
|
366
|
+
Partials are server-rendered fragment functions. They return HTML, `html`
|
|
367
|
+
templates, DOM fragments, or a response envelope.
|
|
368
|
+
|
|
369
|
+
```js
|
|
370
|
+
const partials = createPartialRegistry({
|
|
371
|
+
"product.page": async function ({ id }) {
|
|
372
|
+
const product = await this.server.products.get(id);
|
|
373
|
+
return html`<h1>${product.title}</h1>`;
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
The router swaps route partials into a boundary:
|
|
379
|
+
|
|
380
|
+
```js
|
|
381
|
+
const router = createRouter({
|
|
382
|
+
mode: "ssr-spa",
|
|
383
|
+
root: document,
|
|
384
|
+
boundary: "route",
|
|
385
|
+
routes: createRouteRegistry({
|
|
386
|
+
"/": defineRoute("home"),
|
|
387
|
+
"/products/:id": defineRoute("product.page")
|
|
388
|
+
}),
|
|
389
|
+
loader,
|
|
390
|
+
signals,
|
|
391
|
+
server,
|
|
392
|
+
partials
|
|
393
|
+
}).start();
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
`route(...)` remains a compatibility alias for `defineRoute(...)`.
|
|
397
|
+
|
|
398
|
+
Router modes:
|
|
399
|
+
|
|
400
|
+
| Mode | Behavior |
|
|
401
|
+
| --- | --- |
|
|
402
|
+
| `spa` | Intercepts same-origin links and GET forms, then swaps route HTML |
|
|
403
|
+
| `ssr-spa` | Starts from server HTML, then uses SPA navigation |
|
|
404
|
+
| `mpa` | Does not intercept navigation |
|
|
405
|
+
| `ssr` | Leaves navigation to the server document flow |
|
|
406
|
+
|
|
407
|
+
### Cache
|
|
408
|
+
|
|
409
|
+
Cache declarations are split by runtime target:
|
|
410
|
+
|
|
411
|
+
```js
|
|
412
|
+
Async.use({
|
|
413
|
+
cache: {
|
|
414
|
+
browser: {
|
|
415
|
+
product: defineCache({ ttl: 60_000 })
|
|
416
|
+
},
|
|
417
|
+
server: {
|
|
418
|
+
"products.get": defineCache({ ttl: 30_000 })
|
|
419
|
+
}
|
|
420
|
+
},
|
|
421
|
+
server: {
|
|
422
|
+
async "products.get"(id) {
|
|
423
|
+
return this.cache.getOrSet(`products:${id}`, () => db.products.get(id));
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
Browser handlers and browser async signals receive `runtime.browser.cache`.
|
|
430
|
+
Server functions and server partials receive `runtime.server.cache`. Server
|
|
431
|
+
cache config and contents are never serialized to the browser. Browser cache is
|
|
432
|
+
seeded only by explicit SSR response data.
|
|
433
|
+
|
|
434
|
+
Runtime cache registries support:
|
|
435
|
+
|
|
436
|
+
```js
|
|
437
|
+
cache.register("product", defineCache({ ttl: 60_000 }));
|
|
438
|
+
cache.get("product:sku-1");
|
|
439
|
+
cache.set("product:sku-1", product);
|
|
440
|
+
await cache.getOrSet("product:sku-1", () => loadProduct());
|
|
441
|
+
cache.delete("product:sku-1");
|
|
442
|
+
cache.clear("product:");
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
### SSR Flow
|
|
446
|
+
|
|
447
|
+
SSR uses related app definitions: a server runtime with server functions,
|
|
448
|
+
server cache, partials, and route rendering; and a browser runtime with DOM
|
|
449
|
+
handlers, browser cache, signals, and usually a server proxy.
|
|
450
|
+
|
|
451
|
+
```js
|
|
452
|
+
const serverRuntime = createApp(serverApp, {
|
|
453
|
+
target: "server",
|
|
454
|
+
request
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
const response = await serverRuntime.render("/products/123");
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
`runtime.render(url)` returns:
|
|
461
|
+
|
|
462
|
+
```js
|
|
463
|
+
{
|
|
464
|
+
html,
|
|
465
|
+
status,
|
|
466
|
+
signals,
|
|
467
|
+
cache: {
|
|
468
|
+
browser: {}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
The returned HTML includes a route boundary plus a JSON snapshot:
|
|
474
|
+
|
|
475
|
+
```html
|
|
476
|
+
<section data-async-boundary="route">
|
|
477
|
+
<!-- server-rendered route partial -->
|
|
478
|
+
</section>
|
|
479
|
+
<script type="application/json" data-async-snapshot>{}</script>
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
Browser activation scans the existing HTML and attaches events. It does not
|
|
483
|
+
hydrate, diff, patch, or rerender:
|
|
484
|
+
|
|
485
|
+
```js
|
|
486
|
+
createApp(browserApp, {
|
|
487
|
+
root: document,
|
|
488
|
+
snapshot,
|
|
489
|
+
server: createServerProxy({ endpoint: "/__async/server" })
|
|
490
|
+
}).start();
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
## Components
|
|
494
|
+
|
|
495
|
+
Components are scoped fragment functions. They return strings or `html`
|
|
496
|
+
templates; AsyncLoader inserts and scans the result. There is no virtual node
|
|
497
|
+
type and no rerender loop.
|
|
498
|
+
|
|
499
|
+
```js
|
|
500
|
+
const Toggle = defineComponent(function Toggle() {
|
|
501
|
+
const selected = this.signal("selected", false);
|
|
502
|
+
const toggle = this.handler("toggle", function () {
|
|
503
|
+
selected.update((value) => !value);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
this.onMount((target) => {
|
|
507
|
+
target.dataset.mounted = "true";
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
return html`
|
|
511
|
+
<button
|
|
512
|
+
type="button"
|
|
513
|
+
on:click="${toggle}"
|
|
514
|
+
data-async-class:selected="${selected.id}"
|
|
515
|
+
data-async-attr:aria-pressed="${selected.id}"
|
|
516
|
+
>
|
|
517
|
+
Toggle
|
|
518
|
+
</button>
|
|
519
|
+
`;
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
const loader = AsyncLoader({ root: document });
|
|
523
|
+
loader.mount(document.querySelector("#app"), Toggle);
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
`component(...)` remains a compatibility alias for `defineComponent(...)`.
|
|
527
|
+
|
|
528
|
+
Component helpers:
|
|
529
|
+
|
|
530
|
+
| Helper | Behavior |
|
|
531
|
+
| --- | --- |
|
|
532
|
+
| `this.signal(name, initial)` | Scoped get-or-create signal |
|
|
533
|
+
| `this.computed(name, fn)` | Scoped computed signal |
|
|
534
|
+
| `this.asyncSignal(name, fn)` | Scoped async signal |
|
|
535
|
+
| `this.effect(fn)` | Scoped effect with cleanup |
|
|
536
|
+
| `this.handler(name, fn)` | Scoped handler registry entry |
|
|
537
|
+
| `this.render(Component, props)` | Child fragment rendering |
|
|
538
|
+
| `this.onMount(fn)` | One-shot mount hook |
|
|
539
|
+
| `this.onVisible(fn)` | One-shot visibility hook |
|
|
540
|
+
|
|
541
|
+
`on:mount` and `on:visible` are loader pseudo-events with cleanup support. They
|
|
542
|
+
do not drive component rerenders.
|
|
543
|
+
|
|
544
|
+
## Streaming
|
|
545
|
+
|
|
546
|
+
Out-of-order HTML can target a boundary and keep delegated handlers working:
|
|
547
|
+
|
|
548
|
+
```js
|
|
549
|
+
loader.swap(
|
|
550
|
+
"product",
|
|
551
|
+
`
|
|
552
|
+
<article>
|
|
553
|
+
<h1 data-async-text="product.title"></h1>
|
|
554
|
+
<button type="button" on:click="selectProduct">Select</button>
|
|
555
|
+
</article>
|
|
556
|
+
`
|
|
557
|
+
);
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
`swap(boundaryId, fragmentOrTemplate)` replaces the boundary contents and
|
|
561
|
+
rescans the inserted fragment.
|
|
562
|
+
|
|
563
|
+
## Examples
|
|
564
|
+
|
|
565
|
+
| Example | Shows |
|
|
566
|
+
| --- | --- |
|
|
567
|
+
| [`examples/counter`](./examples/counter) | Signal text binding and delegated handlers |
|
|
568
|
+
| [`examples/product`](./examples/product) | Async signal loading, ready, and error boundaries |
|
|
569
|
+
| [`examples/components`](./examples/components) | Scoped fragment components and lifecycle hooks |
|
|
570
|
+
| [`examples/streaming`](./examples/streaming) | Boundary swaps with rescanned handlers |
|
|
571
|
+
| [`examples/server-call`](./examples/server-call) | Command events calling server functions |
|
|
572
|
+
| [`examples/router`](./examples/router) | SSR-SPA route boundary swaps |
|
|
573
|
+
| [`examples/partials`](./examples/partials) | Server-rendered partial fragments |
|
|
574
|
+
| [`examples/cache`](./examples/cache) | Browser/server cache declarations |
|
|
575
|
+
| [`examples/ssr`](./examples/ssr) | Server render output and browser activation snapshot |
|
|
576
|
+
|
|
577
|
+
## Pipeline
|
|
578
|
+
|
|
579
|
+
`@async/pipeline` owns GitHub Actions, Pages, and release lifecycle automation.
|
|
580
|
+
Edit [`pipeline.ts`](./pipeline.ts), then regenerate:
|
|
581
|
+
|
|
582
|
+
```bash
|
|
583
|
+
pnpm run pipeline:sync:generate
|
|
584
|
+
pnpm run pipeline:sync:check
|
|
585
|
+
pnpm run pipeline:github:check
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
Useful commands:
|
|
589
|
+
|
|
590
|
+
```bash
|
|
591
|
+
pnpm run pipeline:verify
|
|
592
|
+
pnpm run pipeline:pages
|
|
593
|
+
pnpm run pipeline:release:doctor
|
|
594
|
+
pnpm run release:check
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
GitHub Pages builds through the generated `pages` job. This private repository
|
|
598
|
+
needs GitHub Pages support enabled before the generated job can deploy.
|
|
599
|
+
|
|
600
|
+
Stable releases use the generated `publish` job: it verifies the package,
|
|
601
|
+
creates or verifies the tag and GitHub Release, publishes npm with provenance,
|
|
602
|
+
then runs release doctor.
|
|
603
|
+
|
|
604
|
+
## Status
|
|
605
|
+
|
|
606
|
+
The core runtime is intentionally small. Bundling, lazy chunk manifests, JSX
|
|
607
|
+
lowering, TSRX lowering, server resource compilation, and higher-level
|
|
608
|
+
resumability metadata are deferred to later layers.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>AsyncLoader Cache</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<main data-async-container>
|
|
10
|
+
<button type="button" on:click="cacheDemo.loadProduct">Load product</button>
|
|
11
|
+
<p>Product: <strong data-async-text="cacheDemo.title"></strong></p>
|
|
12
|
+
<p>Server calls: <strong data-async-text="cacheDemo.calls"></strong></p>
|
|
13
|
+
</main>
|
|
14
|
+
<script type="module" src="./main.js"></script>
|
|
15
|
+
</body>
|
|
16
|
+
</html>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Async,
|
|
3
|
+
createSignal,
|
|
4
|
+
defineCache
|
|
5
|
+
} from "../../src/index.js";
|
|
6
|
+
|
|
7
|
+
Async.use({
|
|
8
|
+
signal: {
|
|
9
|
+
cacheDemo: createSignal({
|
|
10
|
+
productId: "sku-1",
|
|
11
|
+
title: "",
|
|
12
|
+
calls: 0
|
|
13
|
+
})
|
|
14
|
+
},
|
|
15
|
+
cache: {
|
|
16
|
+
browser: {
|
|
17
|
+
"cacheDemo.product": defineCache({ ttl: 60_000 })
|
|
18
|
+
},
|
|
19
|
+
server: {
|
|
20
|
+
"cacheDemo.products.get": defineCache({ ttl: 30_000 })
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
server: {
|
|
24
|
+
async "cacheDemo.products.get"(id) {
|
|
25
|
+
return this.cache.getOrSet(`cacheDemo.products:${id}`, () => {
|
|
26
|
+
return {
|
|
27
|
+
id,
|
|
28
|
+
title: "Cached Keyboard",
|
|
29
|
+
calls: this.signals.get("cacheDemo.calls") + 1
|
|
30
|
+
};
|
|
31
|
+
}, { cache: "cacheDemo.products.get" });
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
handler: {
|
|
35
|
+
async "cacheDemo.loadProduct"() {
|
|
36
|
+
const id = this.signals.get("cacheDemo.productId");
|
|
37
|
+
const product = await this.cache.getOrSet(`cacheDemo.product:${id}`, () => {
|
|
38
|
+
return this.server.cacheDemo.products.get(id);
|
|
39
|
+
}, { cache: "cacheDemo.product" });
|
|
40
|
+
|
|
41
|
+
this.signals.set("cacheDemo.title", product.title);
|
|
42
|
+
this.signals.set("cacheDemo.calls", product.calls);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
Async.start({ root: document, router: false });
|