@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.
Files changed (46) hide show
  1. package/AGENTS.md +196 -215
  2. package/CHANGELOG.md +46 -0
  3. package/README.md +75 -69
  4. package/package.json +2 -1
  5. package/src/abideResolverPlugin.ts +53 -9
  6. package/src/lib/server/runtime/STREAMED_HTML_HEADER.ts +13 -0
  7. package/src/lib/server/runtime/buildPreloadManifest.ts +151 -0
  8. package/src/lib/server/runtime/createServer.ts +4 -1
  9. package/src/lib/server/runtime/createUiPageRenderer.ts +104 -9
  10. package/src/lib/server/runtime/flushingGzipStream.ts +62 -0
  11. package/src/lib/server/runtime/gzipResponse.ts +23 -11
  12. package/src/lib/server/runtime/serializeCacheSnapshot.ts +22 -33
  13. package/src/lib/shared/cache.ts +10 -3
  14. package/src/lib/shared/cacheManagedSlot.ts +10 -0
  15. package/src/lib/shared/withCacheManaged.ts +18 -0
  16. package/src/lib/ui/README.md +1 -1
  17. package/src/lib/ui/compile/REACTIVE_CALLEES.ts +6 -6
  18. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
  19. package/src/lib/ui/compile/compileShadow.ts +60 -55
  20. package/src/lib/ui/compile/desugarSignals.ts +93 -38
  21. package/src/lib/ui/compile/lowerDocAccess.ts +26 -11
  22. package/src/lib/ui/compile/parseTemplate.ts +8 -5
  23. package/src/lib/ui/compile/prepareNestedScript.ts +2 -2
  24. package/src/lib/ui/compile/renameSignalRefs.ts +153 -23
  25. package/src/lib/ui/compile/types/TemplateAttr.ts +4 -2
  26. package/src/lib/ui/dom/applyResolved.ts +27 -7
  27. package/src/lib/ui/dom/awaitBlock.ts +7 -3
  28. package/src/lib/ui/dom/each.ts +8 -15
  29. package/src/lib/ui/dom/eachAsync.ts +8 -9
  30. package/src/lib/ui/dom/switchBlock.ts +7 -3
  31. package/src/lib/ui/dom/tryBlock.ts +16 -5
  32. package/src/lib/ui/dom/when.ts +7 -3
  33. package/src/lib/ui/installHotBridge.ts +2 -0
  34. package/src/lib/ui/remoteProxy.ts +32 -6
  35. package/src/lib/ui/router.ts +26 -10
  36. package/src/lib/ui/runtime/REQUEST_SUPERSEDED.ts +8 -0
  37. package/src/lib/ui/runtime/abortNode.ts +22 -0
  38. package/src/lib/ui/runtime/currentAbortSignal.ts +26 -0
  39. package/src/lib/ui/runtime/escapeKey.ts +10 -4
  40. package/src/lib/ui/runtime/reactiveAbortState.ts +15 -0
  41. package/src/lib/ui/runtime/runNode.ts +9 -0
  42. package/src/lib/ui/runtime/scopeGroup.ts +40 -0
  43. package/src/lib/ui/runtime/unlinkDeps.ts +8 -0
  44. package/src/lib/ui/seedStreamedResolution.ts +22 -0
  45. package/src/lib/ui/startClient.ts +17 -3
  46. package/src/lib/shared/types/CacheSnapshot.ts +0 -16
package/README.md CHANGED
@@ -1,153 +1,159 @@
1
1
  # abide
2
2
 
3
- **Write one function. Get a typed HTTP endpoint, a CLI, an MCP tool, and an OpenAPI operation — from the same line of code.**
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 a type-safe isomorphic framework built on web standards and Bun. You declare a verb once; the bundler swaps the runtime per side and projects that one declaration onto every surface humans hit it over HTTP or the CLI, machines hit it over MCP, and you control which surfaces each verb exposes. It's a framework built for humans *and* machines.
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
- - Zero runtime dependencies
8
- - A single runtime (Bun) in every mode dev, build, and compiled binary
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 the dev server
13
+ bunx abide scaffold my-app # scaffolds, installs deps, and (in a TTY) starts dev
14
14
  ```
15
15
 
16
- To see every surface live, clone the repo and run the kitchen-sink example — each page is runnable and documented:
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 run dev
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 a handler wrapped by its HTTP method — one export per file under `src/server/rpc/`. The file path is the URL; the schema validates args and projects the MCP tool, CLI flags, and OpenAPI operation. Standard Schema is the contract: zod, valibot, and arktype work unadapted.
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
- // query args arrive as strings — coerce in the schema
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
- One declared verb fans out to every surface — this is the whole premise:
42
+ That one declaration fans out:
44
43
 
45
44
  ```text
46
- export const getMessages = GET(fn, { inputSchema })
47
-
48
- ┌───────────────┬────────────┼──────────────┬────────────────┐
49
- SSR call browser fetch MCP tool CLI subcommand OpenAPI op
50
- cache(fn)() fetch /rpc/... getMessages app get-messages /openapi.json
51
- (in-process) (typed proxy) (read-only+schema) (schema→flags) (described)
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 same callable is consumed differently per side `cache(getMessages)({ room })` in-process, the swapped `fetch` in the browser, `.raw(args)` for a `Response`, `.stream(args)` for `tail()`. A schema gates the machine surfaces: it unlocks the CLI and (for read-only verbs) MCP; a mutating verb never auto-exposes to MCP it needs an explicit `clients: { mcp: true }`. Per-verb `timeout` (504, on every surface) is distinct from the client-side `ABIDE_CLIENT_TIMEOUT`.
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.*` so numbers and booleans validate.
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
- One broadcast topic per file under `src/server/sockets/`. A `Socket<T>` is an isomorphic `AsyncIterable<T>` — every socket multiplexes onto one ws at `/__abide/sockets`. A `schema` validates publishes, infers the frame type, and flips on the MCP/CLI read faces.
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/chat.ts
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({ id: z.string(), from: z.string(), text: z.string(), at: z.number() })
68
-
69
- // retain the last 100 frames, evict any older than an hour
70
- export const chat = socket({ schema, tail: 100, ttl: 3_600_000 })
71
- export type ChatMessage = z.infer<typeof schema>
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/<name>` `GET` returns the retained tail, `POST` publishes (gated by `clientPublish`, off by default).
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
- Components are `.abide` files valid HTML with `<script>`, native `<template>` control flow, `{expr}` bindings, and a component-scoped `<style>`. Reactive state is reached through `scope()` (`scope().state(v)`, `scope().computed(fn)`); `prop` and `effect` are in scope without import. This page reads the verb above through `cache()`, tails the socket live, and exercises most of the template grammar:
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 { publishChat } from '$server/rpc/publishChat.ts'
86
- import { chat } from '$server/sockets/chat.ts'
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
- let room = prop('room') // typed via src/.abide/routes.d.ts
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 text = scope().state('')
95
- let filter = scope().state('all') // all | mine | others
96
- let onlyUnread = scope().state(false)
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 (text.trim() === '') return
101
- await publishChat({ from, text }) // server validates, then broadcasts
102
- text = ''
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
- <template name="message" args={m}> <!-- snippet: a reusable builder -->
107
- <li class="row"><Avatar name={m.from} /> <strong>{m.from}</strong>: {m.text}</li>
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} placeholder="you" />
112
- <input bind:value={text} placeholder="say something…" />
113
- <label><input type="checkbox" bind:checked={onlyUnread} /> unread only</label>
114
- <fieldset>
115
- <label><input type="radio" bind:group={filter} value="all" /> all</label>
116
- <label><input type="radio" bind:group={filter} value="mine" /> mine</label>
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={latest}>
122
- <p class="ping">latest from <strong>{latest.from}</strong></p>
126
+ <template if={live}>
127
+ <p class="live">latest: {live.text}</p>
123
128
  <template else>
124
- <p class="ping muted">no live messages yet</p>
129
+ <p>no messages yet</p>
125
130
  </template>
126
131
  </template>
127
132
 
128
133
  <template switch={filter}>
129
- <template case={'mine'}><p>showing your messages</p></template>
130
- <template default><p>showing every message</p></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={history}>
134
- <p>loading history…</p>
135
- <template then="messages">
139
+ <template await={cache(getMessages)({ room })}>
140
+ <p>loading…</p>
141
+ <template then="history">
136
142
  <ul>
137
- <template each={messages} as="m" key="m.id">
138
- {message(m)} <!-- render the snippet -->
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="err">
143
- <p class="error">{err.message}</p>
148
+ <template catch="error">
149
+ <p>failed: {error.message}</p>
144
150
  </template>
145
151
  </template>
146
152
 
147
153
  <style>
148
- .row { display: flex; gap: 0.5rem; }
149
- .muted { color: #94a3b8; }
150
- .error { color: #dc2626; }
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.34.2",
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> lands before </head>, the module <script> before </body>. Each is
852
- skipped when the shell already carries that reference, so a custom app.html that
853
- still spells the tags out (or one already processed) doesn't get a duplicate.
854
- rewriteHashedClientEntries then swaps both for the hashed entry filenames.
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('/_app/client.js')) {
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. When the directory is missing (someone running the server before a
902
- build) the shell is returned unchanged so the existing broken-asset
903
- behaviour is preserved.
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.replace('/_app/client.js', `/_app/${jsEntry}`)
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
  })