@abide/abide 0.34.2 → 0.36.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/AGENTS.md +196 -215
- package/CHANGELOG.md +46 -0
- package/README.md +75 -69
- package/package.json +2 -1
- package/src/abideResolverPlugin.ts +53 -9
- package/src/lib/server/runtime/STREAMED_HTML_HEADER.ts +13 -0
- package/src/lib/server/runtime/buildPreloadManifest.ts +151 -0
- package/src/lib/server/runtime/createServer.ts +4 -1
- package/src/lib/server/runtime/createUiPageRenderer.ts +104 -9
- package/src/lib/server/runtime/flushingGzipStream.ts +62 -0
- package/src/lib/server/runtime/gzipResponse.ts +23 -11
- package/src/lib/server/runtime/serializeCacheSnapshot.ts +22 -33
- package/src/lib/shared/cache.ts +10 -3
- package/src/lib/shared/cacheManagedSlot.ts +10 -0
- package/src/lib/shared/withCacheManaged.ts +18 -0
- package/src/lib/ui/README.md +1 -1
- package/src/lib/ui/compile/REACTIVE_CALLEES.ts +6 -6
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
- package/src/lib/ui/compile/compileShadow.ts +60 -55
- package/src/lib/ui/compile/desugarSignals.ts +93 -38
- package/src/lib/ui/compile/lowerDocAccess.ts +26 -11
- package/src/lib/ui/compile/parseTemplate.ts +8 -5
- package/src/lib/ui/compile/prepareNestedScript.ts +2 -2
- package/src/lib/ui/compile/renameSignalRefs.ts +153 -23
- package/src/lib/ui/compile/types/TemplateAttr.ts +4 -2
- package/src/lib/ui/dom/applyResolved.ts +27 -7
- package/src/lib/ui/dom/awaitBlock.ts +7 -3
- package/src/lib/ui/dom/each.ts +8 -15
- package/src/lib/ui/dom/eachAsync.ts +8 -9
- package/src/lib/ui/dom/switchBlock.ts +7 -3
- package/src/lib/ui/dom/tryBlock.ts +16 -5
- package/src/lib/ui/dom/when.ts +7 -3
- package/src/lib/ui/installHotBridge.ts +2 -0
- package/src/lib/ui/remoteProxy.ts +32 -6
- package/src/lib/ui/router.ts +26 -10
- package/src/lib/ui/runtime/REQUEST_SUPERSEDED.ts +8 -0
- package/src/lib/ui/runtime/abortNode.ts +22 -0
- package/src/lib/ui/runtime/currentAbortSignal.ts +26 -0
- package/src/lib/ui/runtime/escapeKey.ts +10 -4
- package/src/lib/ui/runtime/reactiveAbortState.ts +15 -0
- package/src/lib/ui/runtime/runNode.ts +9 -0
- package/src/lib/ui/runtime/scopeGroup.ts +40 -0
- package/src/lib/ui/runtime/unlinkDeps.ts +8 -0
- package/src/lib/ui/seedStreamedResolution.ts +22 -0
- package/src/lib/ui/startClient.ts +17 -3
- package/src/lib/shared/types/CacheSnapshot.ts +0 -16
package/README.md
CHANGED
|
@@ -1,153 +1,159 @@
|
|
|
1
1
|
# abide
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**One typed RPC declaration fans out to an HTTP route, a typed browser proxy, a CLI subcommand, an MCP tool, and an OpenAPI operation — built for humans and machines.**
|
|
4
4
|
|
|
5
|
-
abide is
|
|
5
|
+
abide is an isomorphic framework on Bun: the same callable has the same name and behaviour on both sides, and the bundler swaps the runtime per side — a server handler in `src/server`, a `fetch` proxy in the browser. Server-render and hydrate the same `.abide` components, broadcast over multiplexed sockets, and expose every read to an agent — all in a single Bun runtime.
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
-
|
|
7
|
+
- One dependency (`typescript`, for the type-checking shadow); `tailwindcss` + `bun-plugin-tailwind` are optional peers.
|
|
8
|
+
- Runs on Bun `>= 1.3.0`. No bundler config, no server framework, no client router to wire up.
|
|
9
9
|
|
|
10
10
|
## Quick start
|
|
11
11
|
|
|
12
12
|
```sh
|
|
13
|
-
bunx abide scaffold my-app # scaffolds, installs, and starts
|
|
13
|
+
bunx abide scaffold my-app # scaffolds, installs deps, and (in a TTY) starts dev
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
Or read the exhaustive demo:
|
|
17
17
|
|
|
18
18
|
```sh
|
|
19
19
|
git clone https://github.com/briancray/abide
|
|
20
20
|
cd abide && bun install
|
|
21
|
-
cd examples/kitchen-sink && bun
|
|
21
|
+
cd examples/kitchen-sink && bun dev
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
+
The whole public surface — every export, CLI command, route, env var, and the `.abide` grammar — is mapped in [`AGENTS.md`](./AGENTS.md).
|
|
25
|
+
|
|
24
26
|
## RPCs
|
|
25
27
|
|
|
26
|
-
An RPC is
|
|
28
|
+
An RPC is one export per file under `src/server/rpc/`. The file path is the URL (`getMessages.ts` → `/rpc/getMessages`), and a [Standard Schema](https://standardschema.dev) (zod / valibot / arktype, unadapted) validates the args and projects every other surface.
|
|
27
29
|
|
|
28
30
|
```ts
|
|
29
31
|
// src/server/rpc/getMessages.ts
|
|
30
32
|
import { GET } from '@abide/abide/server/GET'
|
|
31
33
|
import { json } from '@abide/abide/server/json'
|
|
32
34
|
import { z } from 'zod'
|
|
35
|
+
import { history } from '../../store.ts'
|
|
33
36
|
|
|
34
|
-
|
|
35
|
-
const inputSchema = z.object({ room: z.string(), limit: z.coerce.number().default(50) })
|
|
37
|
+
const inputSchema = z.object({ room: z.string() })
|
|
36
38
|
|
|
37
|
-
export const getMessages = GET(
|
|
38
|
-
async ({ room, limit }) => json(await db.recentMessages(room, limit)),
|
|
39
|
-
{ inputSchema },
|
|
40
|
-
)
|
|
39
|
+
export const getMessages = GET(({ room }) => json({ messages: history(room) }), { inputSchema })
|
|
41
40
|
```
|
|
42
41
|
|
|
43
|
-
|
|
42
|
+
That one declaration fans out:
|
|
44
43
|
|
|
45
44
|
```text
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
(
|
|
45
|
+
getMessages (one declaration)
|
|
46
|
+
│
|
|
47
|
+
┌──────────┬─────────┼─────────┬──────────────┐
|
|
48
|
+
▼ ▼ ▼ ▼ ▼
|
|
49
|
+
SSR call browser MCP tool CLI sub- OpenAPI
|
|
50
|
+
cache(fn)() fetch (read-only) command operation
|
|
52
51
|
```
|
|
53
52
|
|
|
54
|
-
The
|
|
53
|
+
The `inputSchema` unlocks the CLI and — because a `GET`/`HEAD` is read-only — an MCP tool. A mutating verb (`POST`/`PUT`/`PATCH`/`DELETE`) never auto-exposes to an agent; opt in with `clients: { mcp: true }`. Consume the verb four ways, all typed against the declaration: `cache(getMessages)({ room })` for an SSR-memoized in-process read, the bundler-swapped `getMessages({ room })` `fetch` proxy in the browser, `.raw(args)` for the untouched `Response`, and `.stream(args)` for a frame-by-frame `jsonl`/`sse` body.
|
|
55
54
|
|
|
56
|
-
> Query args travel as strings — use `z.coerce.*`
|
|
55
|
+
> Query args travel as strings — use `z.coerce.*` for numbers and booleans. The per-verb `timeout` option fires a `504` on every surface and is distinct from the client-wide `ABIDE_CLIENT_TIMEOUT`.
|
|
57
56
|
|
|
58
57
|
## Sockets
|
|
59
58
|
|
|
60
|
-
|
|
59
|
+
A socket is one broadcast topic per file under `src/server/sockets/`. A `Socket<T>` is an isomorphic `AsyncIterable<T>` — `for await` it on the server, or read it reactively with `tail()` in a component — and every socket multiplexes onto one WebSocket at `/__abide/sockets`.
|
|
61
60
|
|
|
62
61
|
```ts
|
|
63
|
-
// src/server/sockets/
|
|
62
|
+
// src/server/sockets/messages.ts
|
|
64
63
|
import { socket } from '@abide/abide/server/socket'
|
|
65
64
|
import { z } from 'zod'
|
|
66
65
|
|
|
67
|
-
const schema = z.object({
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
66
|
+
const schema = z.object({
|
|
67
|
+
id: z.string(),
|
|
68
|
+
room: z.string(),
|
|
69
|
+
from: z.string(),
|
|
70
|
+
text: z.string(),
|
|
71
|
+
at: z.number(),
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// retain the last 100 frames; lazily evict any older than an hour
|
|
75
|
+
export const messages = socket({ schema, tail: 100, ttl: 3_600_000 })
|
|
76
|
+
export type Message = z.infer<typeof schema>
|
|
72
77
|
```
|
|
73
78
|
|
|
74
|
-
For clients that can't speak the ws multiplex, each socket has an HTTP face at `/__abide/sockets
|
|
79
|
+
The schema validates every publish and flips on the read surfaces (a `messages-tail` MCP tool / CLI command). For clients that can't speak the ws multiplex, each socket has an HTTP face at `/__abide/sockets/messages`: `GET` returns the retained tail, `POST` publishes — gated by `clientPublish` (default `false`, so browsers publish through a validating verb instead).
|
|
75
80
|
|
|
76
81
|
## Components
|
|
77
82
|
|
|
78
|
-
|
|
83
|
+
A component is an `.abide` file: valid HTML with a `<script>`, native `<template>` control flow, `{expr}` bindings, and a component-scoped `<style>`. `scope()` is the sole reactive surface; `props`, `effect`, and `html` are in scope without import. This one imports the verb and socket above:
|
|
79
84
|
|
|
80
85
|
```html
|
|
81
86
|
<script>
|
|
82
87
|
import { cache } from '@abide/abide/shared/cache'
|
|
83
88
|
import { tail } from '@abide/abide/ui/tail'
|
|
84
89
|
import { getMessages } from '$server/rpc/getMessages.ts'
|
|
85
|
-
import {
|
|
86
|
-
import {
|
|
90
|
+
import { sendMessage } from '$server/rpc/sendMessage.ts'
|
|
91
|
+
import { messages } from '$server/sockets/messages.ts'
|
|
87
92
|
import Avatar from '$ui/Avatar.abide'
|
|
88
93
|
|
|
89
|
-
|
|
90
|
-
const history = scope().computed(() => cache(getMessages)({ room })) // SSR snapshot + reactive refetch
|
|
91
|
-
const latest = scope().computed(() => tail(chat)) // re-renders on every new frame
|
|
94
|
+
const { room } = props<{ room: string }>()
|
|
92
95
|
|
|
93
|
-
let from = scope().state('alice')
|
|
94
|
-
let
|
|
95
|
-
let
|
|
96
|
-
let
|
|
96
|
+
let from = scope().state('alice') // a writable cell — read/assign as a plain var
|
|
97
|
+
let draft = scope().state('')
|
|
98
|
+
let pinned = scope().state(false)
|
|
99
|
+
let filter = scope().state('all')
|
|
100
|
+
const live = scope().computed(() => tail(messages)) // re-renders on every new frame
|
|
97
101
|
|
|
98
102
|
async function send(event: SubmitEvent) {
|
|
99
103
|
event.preventDefault()
|
|
100
|
-
if (
|
|
101
|
-
await
|
|
102
|
-
|
|
104
|
+
if (draft.trim() === '') return
|
|
105
|
+
await sendMessage({ room, from, text: draft }) // a mutating verb
|
|
106
|
+
draft = ''
|
|
103
107
|
}
|
|
104
108
|
</script>
|
|
105
109
|
|
|
106
|
-
|
|
107
|
-
|
|
110
|
+
<!-- a named snippet: a reusable builder, rendered like a function -->
|
|
111
|
+
<template name="bubble" args={msg}>
|
|
112
|
+
<li><Avatar name={msg.from} /> <b>{msg.from}</b>: {msg.text}</li>
|
|
108
113
|
</template>
|
|
109
114
|
|
|
115
|
+
<h1>#{room}</h1>
|
|
116
|
+
|
|
110
117
|
<form onsubmit={send}>
|
|
111
|
-
<input bind:value={from}
|
|
112
|
-
<input bind:value={
|
|
113
|
-
<label><input type="checkbox" bind:checked={
|
|
114
|
-
<
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
</fieldset>
|
|
118
|
-
<button type="submit" disabled={text.trim() === ''}>send</button>
|
|
118
|
+
<input bind:value={from} />
|
|
119
|
+
<input bind:value={draft} placeholder="message" />
|
|
120
|
+
<label><input type="checkbox" bind:checked={pinned} /> pin</label>
|
|
121
|
+
<label><input type="radio" bind:group={filter} value="all" /> all</label>
|
|
122
|
+
<label><input type="radio" bind:group={filter} value="mine" /> mine</label>
|
|
123
|
+
<button type="submit" disabled={draft.trim() === ''}>send</button>
|
|
119
124
|
</form>
|
|
120
125
|
|
|
121
|
-
<template if={
|
|
122
|
-
<p class="
|
|
126
|
+
<template if={live}>
|
|
127
|
+
<p class="live">latest: {live.text}</p>
|
|
123
128
|
<template else>
|
|
124
|
-
<p
|
|
129
|
+
<p>no messages yet</p>
|
|
125
130
|
</template>
|
|
126
131
|
</template>
|
|
127
132
|
|
|
128
133
|
<template switch={filter}>
|
|
129
|
-
<template case={'
|
|
130
|
-
<template
|
|
134
|
+
<template case={'all'}><p>showing everyone</p></template>
|
|
135
|
+
<template case={'mine'}><p>showing {from}</p></template>
|
|
136
|
+
<template default><p>—</p></template>
|
|
131
137
|
</template>
|
|
132
138
|
|
|
133
|
-
<template await={
|
|
134
|
-
<p>loading
|
|
135
|
-
<template then="
|
|
139
|
+
<template await={cache(getMessages)({ room })}>
|
|
140
|
+
<p>loading…</p>
|
|
141
|
+
<template then="history">
|
|
136
142
|
<ul>
|
|
137
|
-
<template each={messages} as="
|
|
138
|
-
{
|
|
143
|
+
<template each={history.messages} as="msg" key="msg.id">
|
|
144
|
+
{bubble(msg)}
|
|
139
145
|
</template>
|
|
140
146
|
</ul>
|
|
141
147
|
</template>
|
|
142
|
-
<template catch="
|
|
143
|
-
<p
|
|
148
|
+
<template catch="error">
|
|
149
|
+
<p>failed: {error.message}</p>
|
|
144
150
|
</template>
|
|
145
151
|
</template>
|
|
146
152
|
|
|
147
153
|
<style>
|
|
148
|
-
.
|
|
149
|
-
|
|
150
|
-
|
|
154
|
+
.live {
|
|
155
|
+
font-weight: 600;
|
|
156
|
+
}
|
|
151
157
|
</style>
|
|
152
158
|
```
|
|
153
159
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abide/abide",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"description": "Isomorphic multimodal HTTP framework built for humans and machines in a single Bun runtime",
|
|
@@ -99,6 +99,7 @@
|
|
|
99
99
|
"./ui/dom/tryBlock": "./src/lib/ui/dom/tryBlock.ts",
|
|
100
100
|
"./ui/dom/switchBlock": "./src/lib/ui/dom/switchBlock.ts",
|
|
101
101
|
"./ui/dom/applyResolved": "./src/lib/ui/dom/applyResolved.ts",
|
|
102
|
+
"./ui/runtime/escapeKey": "./src/lib/ui/runtime/escapeKey.ts",
|
|
102
103
|
"./ui/runtime/nextBlockId": "./src/lib/ui/runtime/nextBlockId.ts",
|
|
103
104
|
"./ui/runtime/enterRenderPass": "./src/lib/ui/runtime/enterRenderPass.ts",
|
|
104
105
|
"./ui/runtime/exitRenderPass": "./src/lib/ui/runtime/exitRenderPass.ts",
|
|
@@ -848,10 +848,17 @@ async function loadShell(cwd: string): Promise<string> {
|
|
|
848
848
|
/*
|
|
849
849
|
Injects the framework's client entry references so app.html stays a clean
|
|
850
850
|
template — page structure plus the SSR markers, no framework asset bookkeeping.
|
|
851
|
-
The css <link>
|
|
852
|
-
|
|
853
|
-
still spells the tags out (or
|
|
854
|
-
|
|
851
|
+
The css <link> and the entry's <link rel="modulepreload"> land before </head>,
|
|
852
|
+
the module <script> before </body>. Each is skipped when the shell already
|
|
853
|
+
carries that reference, so a custom app.html that still spells the tags out (or
|
|
854
|
+
one already processed) doesn't get a duplicate. rewriteHashedClientEntries then
|
|
855
|
+
swaps every reference for the hashed entry filenames.
|
|
856
|
+
|
|
857
|
+
The modulepreload makes the browser fetch the entry during a streamed render —
|
|
858
|
+
the closing </body> <script> tag arrives only after the whole stream, so without
|
|
859
|
+
it the entry download is serialized after the body. Execution still defers to
|
|
860
|
+
parse-end (module script), so hydration ordering is unchanged; only the transfer
|
|
861
|
+
overlaps the stream.
|
|
855
862
|
*/
|
|
856
863
|
function injectShellAssets(shell: string): string {
|
|
857
864
|
let result = shell
|
|
@@ -863,7 +870,16 @@ function injectShellAssets(shell: string): string {
|
|
|
863
870
|
'src/ui/app.html has no </head>',
|
|
864
871
|
)
|
|
865
872
|
}
|
|
866
|
-
if (!result.includes('
|
|
873
|
+
if (!result.includes('rel="modulepreload"')) {
|
|
874
|
+
result = injectBeforeTag(
|
|
875
|
+
result,
|
|
876
|
+
'<link rel="modulepreload" href="/_app/client.js" />',
|
|
877
|
+
'head',
|
|
878
|
+
'src/ui/app.html has no </head>',
|
|
879
|
+
)
|
|
880
|
+
}
|
|
881
|
+
// Guard on `src=` so the modulepreload's matching href doesn't mask a missing <script>.
|
|
882
|
+
if (!result.includes('src="/_app/client.js"')) {
|
|
867
883
|
result = injectBeforeTag(
|
|
868
884
|
result,
|
|
869
885
|
'<script type="module" src="/_app/client.js"></script>',
|
|
@@ -898,9 +914,10 @@ function injectBeforeTag(
|
|
|
898
914
|
Scans `dist/_app/` for the hashed client entry filenames produced by
|
|
899
915
|
build.ts (e.g. `client-abc12345.js`, `client-abc12345.css`) and swaps the
|
|
900
916
|
shell's literal `/_app/client.js` and `/_app/client.css` references for
|
|
901
|
-
them.
|
|
902
|
-
|
|
903
|
-
|
|
917
|
+
them. The js reference appears twice (the modulepreload <link> and the entry
|
|
918
|
+
<script>), so replaceAll covers both. When the directory is missing (someone
|
|
919
|
+
running the server before a build) the shell is returned unchanged so the
|
|
920
|
+
existing broken-asset behaviour is preserved.
|
|
904
921
|
*/
|
|
905
922
|
async function rewriteHashedClientEntries(shell: string, cwd: string): Promise<string> {
|
|
906
923
|
const appDir = `${cwd}/dist/_app`
|
|
@@ -914,10 +931,37 @@ async function rewriteHashedClientEntries(shell: string, cwd: string): Promise<s
|
|
|
914
931
|
const cssEntry = entries.find((file) => /^client-[a-z0-9]+\.css$/i.test(file))
|
|
915
932
|
let result = shell
|
|
916
933
|
if (jsEntry) {
|
|
917
|
-
result = result.
|
|
934
|
+
result = result.replaceAll('/_app/client.js', `/_app/${jsEntry}`)
|
|
935
|
+
result = await injectEntryDepPreloads(result, `${appDir}/${jsEntry}`)
|
|
918
936
|
}
|
|
919
937
|
if (cssEntry) {
|
|
920
938
|
result = result.replace('/_app/client.css', `/_app/${cssEntry}`)
|
|
921
939
|
}
|
|
922
940
|
return result
|
|
923
941
|
}
|
|
942
|
+
|
|
943
|
+
/* Static-import specifiers in the built entry: `import {…} from "./chunk.js"` and
|
|
944
|
+
side-effect `import "./chunk.js"`, minified (no spaces) or not. The leading
|
|
945
|
+
`import` plus the `[^"'()]` guard excludes dynamic `import("./page-….js")`, so
|
|
946
|
+
only the runtime graph matches — route chunks stay lazy. */
|
|
947
|
+
const ENTRY_STATIC_IMPORT = /import\s*(?:[^"'()]*?\bfrom\s*)?["']\.\/([\w.-]+\.js)["']/g
|
|
948
|
+
|
|
949
|
+
/*
|
|
950
|
+
SPIKE: preload the entry's static dependency chunks. The entry <script> statically
|
|
951
|
+
imports the abide-ui runtime as ~60 one-export `clientEntry-<hash>.js` chunks; the
|
|
952
|
+
browser can't discover them until it has downloaded and PARSED the entry, which on a
|
|
953
|
+
streamed page is ~stream-close — so the runtime waterfalls in a second wave after the
|
|
954
|
+
body finishes. Emitting a <link rel="modulepreload"> for each static dep into <head>
|
|
955
|
+
lets the whole graph transfer DURING the stream alongside the entry, so hydration is
|
|
956
|
+
network-ready at stream-close instead of after it. Route chunks (`import("./page-…")`)
|
|
957
|
+
are dynamic, so they don't match and stay lazy — code-splitting still pays off.
|
|
958
|
+
*/
|
|
959
|
+
async function injectEntryDepPreloads(shell: string, entryPath: string): Promise<string> {
|
|
960
|
+
const source = await Bun.file(entryPath).text()
|
|
961
|
+
const deps = [...new Set([...source.matchAll(ENTRY_STATIC_IMPORT)].map((match) => match[1]))]
|
|
962
|
+
if (deps.length === 0) {
|
|
963
|
+
return shell
|
|
964
|
+
}
|
|
965
|
+
const links = deps.map((dep) => `<link rel="modulepreload" href="/_app/${dep}" />`).join('\n')
|
|
966
|
+
return injectBeforeTag(shell, links, 'head', 'src/ui/app.html has no </head>')
|
|
967
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Internal response-header marker the streaming SSR renderer stamps on its
|
|
3
|
+
progressively-flushed `text/html` document. `gzipResponse` reads it to pick a
|
|
4
|
+
per-chunk-flushing gzip over the buffering web CompressionStream (then strips it
|
|
5
|
+
before send): a plain CompressionStream holds the head until its deflate window
|
|
6
|
+
flushes, defeating progressive delivery — the browser can't preload-scan the head
|
|
7
|
+
or paint the pending shell until the stream nearly closes. A streamed `text/html`
|
|
8
|
+
body is otherwise indistinguishable from a buffered one (both expose a
|
|
9
|
+
ReadableStream), and `isStreamingResponse` can't be widened to cover it without
|
|
10
|
+
also opting these pages out of the idle-timeout cap they rely on (see
|
|
11
|
+
disableIdleTimeoutForStream).
|
|
12
|
+
*/
|
|
13
|
+
export const STREAMED_HTML_HEADER = 'abide-streamed-html'
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { Glob, gunzipSync } from 'bun'
|
|
3
|
+
import { layoutChainForRoute } from '../../shared/layoutChainForRoute.ts'
|
|
4
|
+
import type { Assets } from './types/Assets.ts'
|
|
5
|
+
|
|
6
|
+
/* A static-import specifier in a built chunk: `import {…} from "./chunk.js"` and
|
|
7
|
+
side-effect `import "./chunk.js"`, minified or not. The leading `import` plus the
|
|
8
|
+
`[^"'()]` guard excludes dynamic `import("./page-….js")` — so only the runtime graph
|
|
9
|
+
matches, never a lazy route chunk. Mirrors the shell injector in abideResolverPlugin. */
|
|
10
|
+
const STATIC_IMPORT = /import\s*(?:[^"'()]*?\bfrom\s*)?["']\.\/([\w.-]+\.js)["']/g
|
|
11
|
+
|
|
12
|
+
/* A pages/layouts manifest entry in the entry chunk:
|
|
13
|
+
`"<route>": () => import("./page-<hash>.js")` (page chunk) or the layout form.
|
|
14
|
+
The route/layout key plus its lazily-loaded chunk — the only place route → chunk is
|
|
15
|
+
recorded, since every page chunk shares the `page-` stem and can't be told apart by name. */
|
|
16
|
+
const ROUTE_CHUNK = /"(\/[^"]*)"\s*:\s*\(\)\s*=>\s*import\("\.\/((?:page|layout)-[\w.-]+\.js)"\)/g
|
|
17
|
+
|
|
18
|
+
/* Reads a `_app` chunk's source by filename — gunzipped from the embedded asset map
|
|
19
|
+
(standalone compile) or off disk (dev + `abide start`). Undefined on a miss. */
|
|
20
|
+
function chunkReader(
|
|
21
|
+
distDir: string,
|
|
22
|
+
assets?: Assets,
|
|
23
|
+
): (name: string) => Promise<string | undefined> {
|
|
24
|
+
if (assets) {
|
|
25
|
+
return async (name) => {
|
|
26
|
+
const bytes = assets[`/_app/${name}`]
|
|
27
|
+
return bytes ? new TextDecoder().decode(gunzipSync(bytes)) : undefined
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return async (name) => {
|
|
31
|
+
const file = Bun.file(`${distDir}/_app/${name}`)
|
|
32
|
+
return (await file.exists()) ? file.text() : undefined
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* The hashed client entry filename (`client-<hash>.js`), from the asset map or disk. */
|
|
37
|
+
async function findEntry(distDir: string, assets?: Assets): Promise<string | undefined> {
|
|
38
|
+
const isEntry = (name: string): boolean => /^client-[a-z0-9]+\.js$/i.test(name)
|
|
39
|
+
if (assets) {
|
|
40
|
+
const key = Object.keys(assets).find((path) => isEntry(path.replace('/_app/', '')))
|
|
41
|
+
return key?.replace('/_app/', '')
|
|
42
|
+
}
|
|
43
|
+
if (!existsSync(`${distDir}/_app`)) {
|
|
44
|
+
return undefined
|
|
45
|
+
}
|
|
46
|
+
const names = await Array.fromAsync(
|
|
47
|
+
new Glob('client-*.js').scan({ cwd: `${distDir}/_app`, onlyFiles: true }),
|
|
48
|
+
)
|
|
49
|
+
return names.find(isEntry)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/*
|
|
53
|
+
Maps each page route to the extra `_app` chunks worth preloading for it: the route's
|
|
54
|
+
page chunk, its layout-chain chunks, and the transitive static-import closure of each
|
|
55
|
+
— minus the entry's own static graph, which the shell already preloads. Built once at
|
|
56
|
+
server boot from the built bundle (the route → chunk mapping lives in the entry's
|
|
57
|
+
compiled pages/layouts manifest; the static graph is parsed from each chunk's source).
|
|
58
|
+
|
|
59
|
+
Those route chunks are dynamically imported by the entry, so the browser can't discover
|
|
60
|
+
them until it has downloaded, parsed, and RUN the entry — on a streamed page that's
|
|
61
|
+
~stream-close. Preloading them per render (the renderer knows the matched route) lets
|
|
62
|
+
the route's whole chain transfer DURING the stream, so hydration is network-ready at
|
|
63
|
+
stream-close instead of waterfalling after it. Returns an empty map when the bundle is
|
|
64
|
+
absent or unparseable, so rendering degrades to the entry-only preload.
|
|
65
|
+
*/
|
|
66
|
+
export async function buildPreloadManifest({
|
|
67
|
+
distDir,
|
|
68
|
+
assets,
|
|
69
|
+
}: {
|
|
70
|
+
distDir: string
|
|
71
|
+
assets?: Assets
|
|
72
|
+
}): Promise<Record<string, string[]>> {
|
|
73
|
+
const read = chunkReader(distDir, assets)
|
|
74
|
+
const entryName = await findEntry(distDir, assets)
|
|
75
|
+
const entrySource = entryName ? await read(entryName) : undefined
|
|
76
|
+
if (entryName === undefined || entrySource === undefined) {
|
|
77
|
+
return {}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* route/layout key → its lazy chunk, split by the page-/layout- stem. */
|
|
81
|
+
const pageChunk: Record<string, string> = {}
|
|
82
|
+
const layoutChunk: Record<string, string> = {}
|
|
83
|
+
for (const match of entrySource.matchAll(ROUTE_CHUNK)) {
|
|
84
|
+
/* Both capture groups are present whenever the regex matches. */
|
|
85
|
+
const key = match[1] as string
|
|
86
|
+
const chunk = match[2] as string
|
|
87
|
+
if (chunk.startsWith('page-')) {
|
|
88
|
+
pageChunk[key] = chunk
|
|
89
|
+
} else {
|
|
90
|
+
layoutChunk[key] = chunk
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* Memoised direct static imports per chunk; closure walks them to a fixpoint. */
|
|
95
|
+
const directCache = new Map<string, Promise<string[]>>()
|
|
96
|
+
const direct = (name: string): Promise<string[]> => {
|
|
97
|
+
const cached = directCache.get(name)
|
|
98
|
+
if (cached) {
|
|
99
|
+
return cached
|
|
100
|
+
}
|
|
101
|
+
const deps = read(name).then((source) =>
|
|
102
|
+
source
|
|
103
|
+
? [
|
|
104
|
+
...new Set(
|
|
105
|
+
[...source.matchAll(STATIC_IMPORT)].map((match) => match[1] as string),
|
|
106
|
+
),
|
|
107
|
+
]
|
|
108
|
+
: [],
|
|
109
|
+
)
|
|
110
|
+
directCache.set(name, deps)
|
|
111
|
+
return deps
|
|
112
|
+
}
|
|
113
|
+
const closure = async (name: string): Promise<Set<string>> => {
|
|
114
|
+
const seen = new Set<string>()
|
|
115
|
+
const queue = [name]
|
|
116
|
+
while (queue.length > 0) {
|
|
117
|
+
const current = queue.shift() as string
|
|
118
|
+
if (seen.has(current)) {
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
seen.add(current)
|
|
122
|
+
for (const dependency of await direct(current)) {
|
|
123
|
+
if (!seen.has(dependency)) {
|
|
124
|
+
queue.push(dependency)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return seen
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* The entry + its static runtime — already preloaded by the shell, so excluded. */
|
|
132
|
+
const excluded = await closure(entryName)
|
|
133
|
+
const layoutKeys = Object.keys(layoutChunk)
|
|
134
|
+
const manifest: Record<string, string[]> = {}
|
|
135
|
+
for (const route of Object.keys(pageChunk)) {
|
|
136
|
+
const chunks = new Set<string>(await closure(pageChunk[route] as string))
|
|
137
|
+
for (const key of layoutChainForRoute(route, layoutKeys)) {
|
|
138
|
+
const chunk = layoutChunk[key]
|
|
139
|
+
if (chunk) {
|
|
140
|
+
for (const dependency of await closure(chunk)) {
|
|
141
|
+
chunks.add(dependency)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const preload = [...chunks].filter((chunk) => !excluded.has(chunk))
|
|
146
|
+
if (preload.length > 0) {
|
|
147
|
+
manifest[route] = preload
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return manifest
|
|
151
|
+
}
|
|
@@ -34,6 +34,7 @@ import { createSocketDispatcher } from '../sockets/createSocketDispatcher.ts'
|
|
|
34
34
|
import type { SocketRoutes } from '../sockets/types/SocketRoutes.ts'
|
|
35
35
|
import { buildHealthPayload } from './buildHealthPayload.ts'
|
|
36
36
|
import { buildOpenApiSpec } from './buildOpenApiSpec.ts'
|
|
37
|
+
import { buildPreloadManifest } from './buildPreloadManifest.ts'
|
|
37
38
|
import { createAppAssetServer } from './createAppAssetServer.ts'
|
|
38
39
|
import { createPublicAssetServer } from './createPublicAssetServer.ts'
|
|
39
40
|
import { createRouteDispatcher } from './createRouteDispatcher.ts'
|
|
@@ -205,7 +206,7 @@ export async function createServer({
|
|
|
205
206
|
browser would render; the asset servers glob public/ and the build tree
|
|
206
207
|
(embedded gzip map in a compiled binary, dist/ on disk).
|
|
207
208
|
*/
|
|
208
|
-
const [clientFingerprint, servePublicAsset, serveAppAsset] = await Promise.all([
|
|
209
|
+
const [clientFingerprint, servePublicAsset, serveAppAsset, routePreloads] = await Promise.all([
|
|
209
210
|
dev
|
|
210
211
|
? devClientFingerprint({
|
|
211
212
|
srcDir: `${process.cwd()}/src`,
|
|
@@ -216,6 +217,7 @@ export async function createServer({
|
|
|
216
217
|
: undefined,
|
|
217
218
|
createPublicAssetServer({ publicDir, publicAssets }),
|
|
218
219
|
createAppAssetServer({ distDir, assets }),
|
|
220
|
+
buildPreloadManifest({ distDir, assets }),
|
|
219
221
|
])
|
|
220
222
|
setRegistryManifests({ rpc, sockets, prompts })
|
|
221
223
|
setMcpResourceServer(createMcpResourceServer({ resourcesDir, mcpResources }))
|
|
@@ -279,6 +281,7 @@ export async function createServer({
|
|
|
279
281
|
clientTimeout: parseBoundedEnvInt(process.env.ABIDE_CLIENT_TIMEOUT, 1, 600_000),
|
|
280
282
|
pages,
|
|
281
283
|
layouts,
|
|
284
|
+
routePreloads,
|
|
282
285
|
/* The wire payload, rebuilt per marked render — the __SSR__ health seed must match what /__abide/health serves. */
|
|
283
286
|
healthPayload: (request) => buildHealthPayload(request, { app, appName, appVersion }),
|
|
284
287
|
})
|