@abide/abide 0.44.0 → 0.44.1

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 (67) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/package.json +1 -1
  3. package/src/abideResolverPlugin.ts +6 -1
  4. package/src/devEntry.ts +43 -9
  5. package/src/lib/cli/parseArgvForRpc.ts +21 -24
  6. package/src/lib/cli/runCli.ts +9 -24
  7. package/src/lib/cli/tokenizeArgvFlags.ts +84 -0
  8. package/src/lib/server/rpc/defineRpc.ts +9 -1
  9. package/src/lib/server/runtime/createServer.ts +29 -12
  10. package/src/lib/server/runtime/devHotModuleResponse.ts +1 -1
  11. package/src/lib/server/runtime/gzipResponse.ts +14 -16
  12. package/src/lib/server/runtime/runWithRequestScope.ts +4 -1
  13. package/src/lib/server/runtime/types/RequestStore.ts +16 -0
  14. package/src/lib/server/runtime/withResponseDefaults.ts +8 -3
  15. package/src/lib/server/sockets/defineSocket.ts +5 -1
  16. package/src/lib/shared/RPC_ARGS_TYPE.ts +7 -0
  17. package/src/lib/shared/activeCacheStore.ts +5 -14
  18. package/src/lib/shared/activePage.ts +5 -19
  19. package/src/lib/shared/augmentModule.ts +18 -0
  20. package/src/lib/shared/basePath.ts +6 -5
  21. package/src/lib/shared/baseResolver.ts +10 -0
  22. package/src/lib/shared/baseSlot.ts +6 -12
  23. package/src/lib/shared/cache.ts +21 -1
  24. package/src/lib/shared/cacheStoreResolver.ts +12 -0
  25. package/src/lib/shared/cacheStoreSlot.ts +5 -13
  26. package/src/lib/shared/changeAffectsClient.ts +35 -0
  27. package/src/lib/shared/createResolverSlot.ts +37 -0
  28. package/src/lib/shared/debugGate.ts +90 -0
  29. package/src/lib/shared/emitLogRecord.ts +18 -4
  30. package/src/lib/shared/globalCacheStoreResolver.ts +12 -0
  31. package/src/lib/shared/globalCacheStoreSlot.ts +6 -11
  32. package/src/lib/shared/isDebugEnabled.ts +3 -10
  33. package/src/lib/shared/isDebugNegated.ts +4 -6
  34. package/src/lib/shared/pageResolver.ts +16 -0
  35. package/src/lib/shared/pageSlot.ts +5 -14
  36. package/src/lib/shared/requestScopeResolver.ts +12 -0
  37. package/src/lib/shared/requestScopeSlot.ts +7 -12
  38. package/src/lib/shared/responseBodyKind.ts +35 -0
  39. package/src/lib/shared/setBaseResolver.ts +2 -4
  40. package/src/lib/shared/setCacheStoreResolver.ts +3 -5
  41. package/src/lib/shared/setGlobalCacheStoreResolver.ts +3 -5
  42. package/src/lib/shared/setPageResolver.ts +2 -5
  43. package/src/lib/shared/setRequestScopeResolver.ts +3 -5
  44. package/src/lib/shared/types/DebugGate.ts +10 -0
  45. package/src/lib/shared/types/ResolverSlot.ts +10 -0
  46. package/src/lib/shared/writeDts.ts +8 -1
  47. package/src/lib/shared/writePublicAssetsDts.ts +5 -9
  48. package/src/lib/shared/writeRoutesDts.ts +12 -16
  49. package/src/lib/shared/writeRpcDts.ts +13 -12
  50. package/src/lib/shared/writeTestRpcDts.ts +11 -11
  51. package/src/lib/shared/writeTestSocketsDts.ts +9 -9
  52. package/src/lib/ui/compile/abideUiPlugin.ts +9 -7
  53. package/src/lib/ui/compile/assertRuntimeHelpersBound.ts +24 -12
  54. package/src/lib/ui/compile/compileModule.ts +37 -16
  55. package/src/lib/ui/compile/compileShadow.ts +75 -1
  56. package/src/lib/ui/compile/createShadowLanguageService.ts +29 -3
  57. package/src/lib/ui/compile/generateBuild.ts +7 -1
  58. package/src/lib/ui/compile/parseTemplate.ts +4 -1
  59. package/src/lib/ui/compile/types/TemplateNode.ts +1 -1
  60. package/src/lib/ui/dom/mountSwappableRange.ts +79 -0
  61. package/src/lib/ui/dom/skeleton.ts +10 -1
  62. package/src/lib/ui/dom/switchBlock.ts +15 -63
  63. package/src/lib/ui/dom/when.ts +11 -64
  64. package/src/lib/ui/runtime/createDoc.ts +102 -12
  65. package/src/zodCjsPlugin.ts +16 -1
  66. package/src/lib/shared/matchesDebugPattern.ts +0 -16
  67. package/src/lib/shared/parseDebugPatterns.ts +0 -21
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # abide
2
2
 
3
+ ## 0.44.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 767b415: 02-zod-plugin-memo ([`0e00b21`](https://github.com/briancray/abide/commit/0e00b217f99352a7d3def4841172c5bff287bc5c))
8
+ - 767b415: 06-wakeSubtree-prefix-index ([`23ab920`](https://github.com/briancray/abide/commit/23ab9202fd0cb41e01811e78b76eac2843c9cf8f))
9
+ - 767b415: 09-when-via-switch ([`48f8d50`](https://github.com/briancray/abide/commit/48f8d5063cf74060004ad38691f584abcff0cab2))
10
+ - 767b415: 08-dts-codegen ([`5170542`](https://github.com/briancray/abide/commit/51705426e55a700140cc7bc38f7e21f30759fce3))
11
+ - 767b415: 03-socket-publish-gate ([`56fae49`](https://github.com/briancray/abide/commit/56fae49bf393fdfb3aa35cecc618a39443354ce4))
12
+ - 767b415: 10-cli-tokenizer ([`5e305c1`](https://github.com/briancray/abide/commit/5e305c154efd5f77d0a57e78831b72a30b4d9155))
13
+ - 767b415: 14-resolver-slot-factory-JUDGMENT ([`6e00809`](https://github.com/briancray/abide/commit/6e00809c343e24599d54b2304a50a9cecd90619f))
14
+ - 767b415: catch top-level await in a .abide <script> during abide check ([`76ff64e`](https://github.com/briancray/abide/commit/76ff64e9fb9ffed108ae2fde53f2ded2bf3a0abe))
15
+ - 767b415: 12-warm-clone-PARTIAL ([`8e58e0b`](https://github.com/briancray/abide/commit/8e58e0b2c1891174cc809797f949f01619529b89))
16
+ - 767b415: 05-compiler-double-pass ([`9216dae`](https://github.com/briancray/abide/commit/9216dae4c26e27fce49f39b116bd8b1dcf1dd226))
17
+ - 767b415: 04-lsp-shadow-cache ([`9f9f5fc`](https://github.com/briancray/abide/commit/9f9f5fc84792204259d591fa4aff261b2a19c46d))
18
+ - 767b415: 13-dev-rebuild-scoping-JUDGMENT ([`b5a1d49`](https://github.com/briancray/abide/commit/b5a1d49e6607a8fcf37efe03e9fe6f6b2c31ea45))
19
+ - 767b415: gate skeleton fragment-staging on a connected parent ([`c9380ee`](https://github.com/briancray/abide/commit/c9380eebdd9f6773ccffd446665b1acbc78b1123))
20
+ - 767b415: 07-debug-gate ([`c96466e`](https://github.com/briancray/abide/commit/c96466e73ea1f521d728b11aac44262530c3b09c))
21
+ - 767b415: rename paint's escape var to avoid shadowing the global ([`caf1322`](https://github.com/briancray/abide/commit/caf1322adc6598bc6ea32e0ba9047ba768b6f5e1))
22
+ - 767b415: reflow generateBuild attr-kind union to biome's multiline form ([`f9b3669`](https://github.com/briancray/abide/commit/f9b36690e4add2c955abb8f0127a9af9d513be53))
23
+ - 767b415: 01-bugs-B1-B2 ([`fa8659d`](https://github.com/briancray/abide/commit/fa8659d276265c0fbb4f7ca0740fc74702f00168))
24
+ - 767b415: address code-review findings ([`fb02df5`](https://github.com/briancray/abide/commit/fb02df58c924d7206fce35874cc80309949c9e59))
25
+ - 767b415: 11-server-log-micro ([`fb689bc`](https://github.com/briancray/abide/commit/fb689bc05bee9ba127ddf206e925e3a9e04841fd))
26
+
3
27
  ## 0.44.0
4
28
 
5
29
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.44.0",
3
+ "version": "0.44.1",
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",
@@ -257,10 +257,15 @@ export function abideResolverPlugin({
257
257
  return {
258
258
  name: 'abide-resolver',
259
259
  setup(build) {
260
- /* Fresh edge graph each build (dev watch reuses the plugin instance).
260
+ /* Fresh edge graph + resolution cache each build (dev watch reuses the
261
+ plugin instance). The resolution cache memoises (path → resolved file)
262
+ within a build; clearing it per build keeps the memo but stops a stale
263
+ miss — a path resolved before its file existed — surviving into the next
264
+ build, so a freshly-created file resolves instead of staying "not found".
261
265
  onStart is build-time only — absent in the runtime/preload plugin context. */
262
266
  build.onStart?.(() => {
263
267
  importerOf.clear()
268
+ resolveExtensionCache.clear()
264
269
  })
265
270
 
266
271
  /*
package/src/devEntry.ts CHANGED
@@ -6,6 +6,7 @@ import { DEV_READY_MESSAGE } from './lib/server/runtime/DEV_READY_MESSAGE.ts'
6
6
  import { DEV_REBUILD_MESSAGE } from './lib/server/runtime/DEV_REBUILD_MESSAGE.ts'
7
7
  import { findOpenPort } from './lib/server/runtime/findOpenPort.ts'
8
8
  import { abideLog } from './lib/shared/abideLog.ts'
9
+ import { changeAffectsClient } from './lib/shared/changeAffectsClient.ts'
9
10
 
10
11
  /*
11
12
  Dev orchestrator. Replaces `bun --watch` (which only watches the import graph,
@@ -174,30 +175,51 @@ async function replaceServer(port: number): Promise<void> {
174
175
 
175
176
  let building = false
176
177
  let queued = false
178
+ // True once any change collapsed into the queued run needs the client rebuilt;
179
+ // a client-affecting change can never be downgraded to a server-only restart.
180
+ let queuedNeedsClient = false
177
181
 
178
182
  /*
179
- Rebuild the client, then (on success) swap in a fresh server child. Serialized:
180
- a change arriving mid-build sets `queued` so exactly one more rebuild runs
181
- after, collapsing any further changes in between. A failed build leaves the
182
- current child untouched the error is logged and the last-good server keeps
183
- serving.
183
+ Apply a change: rebuild the client (unless `skipClientBuild` a server/MCP-only
184
+ change leaves the client bundle byte-identical), then on success swap in a fresh
185
+ server child. The worker restart always runs: SSR renders through Bun's module
186
+ cache, so a new process is the only reliable way to reflect any source edit,
187
+ client-affecting or not.
188
+
189
+ Serialized: a change arriving mid-build sets `queued` so exactly one more run
190
+ follows, collapsing further changes in between; `queuedNeedsClient` records
191
+ whether any of them needs the client, so the collapsed run never skips a client
192
+ rebuild a queued change required. A failed build leaves the current child
193
+ untouched — the error is logged and the last-good server keeps serving.
184
194
  */
185
- async function rebuild(port: number): Promise<void> {
195
+ async function rebuild(port: number, skipClientBuild = false): Promise<void> {
186
196
  if (building) {
187
197
  queued = true
198
+ queuedNeedsClient ||= !skipClientBuild
188
199
  return
189
200
  }
190
201
  building = true
191
202
  try {
192
- const succeeded = await build(buildOptions)
203
+ const succeeded = skipClientBuild ? true : await build(buildOptions)
193
204
  if (succeeded) {
194
205
  await replaceServer(port)
195
206
  }
207
+ /* A server/MCP-only change reuses the existing client bundle. That's correct for
208
+ an rpc/socket handler BODY edit, but changing an rpc's method, its export name,
209
+ or `outbox: true` changes the client proxy stub too — the stale bundle then
210
+ calls with the old shape (e.g. a 405). Flag the skip so that case isn't silent. */
211
+ if (skipClientBuild) {
212
+ abideLog.info(
213
+ 'server-only change — kept the existing client bundle; if you changed an rpc method, export name, or outbox, save a client file (or restart dev) for a full rebuild',
214
+ )
215
+ }
196
216
  } finally {
197
217
  building = false
198
218
  if (queued) {
199
219
  queued = false
200
- void rebuild(port)
220
+ const needsClient = queuedNeedsClient
221
+ queuedNeedsClient = false
222
+ void rebuild(port, !needsClient)
201
223
  }
202
224
  }
203
225
  }
@@ -223,14 +245,26 @@ an agent editing the app's own source — isn't yanked mid-run by a save.
223
245
  const manualRebuild = Bun.env.ABIDE_DEV_NO_WATCH === '1'
224
246
 
225
247
  let debounce: ReturnType<typeof setTimeout> | undefined
248
+ /*
249
+ True only while every change collapsed into the pending debounce is server/MCP-
250
+ only; one client-affecting change in the burst flips it false (latches until the
251
+ debounce fires), so a multi-file save that touches the client never skips the
252
+ client rebuild.
253
+ */
254
+ let pendingSkipClientBuild = true
226
255
  const watcher = manualRebuild
227
256
  ? undefined
228
257
  : watch(SOURCE_DIR, { recursive: true }, (_event, filename) => {
229
258
  if (!filename || isGenerated(filename)) {
230
259
  return
231
260
  }
261
+ pendingSkipClientBuild &&= !changeAffectsClient(filename)
232
262
  clearTimeout(debounce)
233
- debounce = setTimeout(() => void rebuild(port), REBUILD_DEBOUNCE_MS)
263
+ debounce = setTimeout(() => {
264
+ const skipClientBuild = pendingSkipClientBuild
265
+ pendingSkipClientBuild = true
266
+ void rebuild(port, skipClientBuild)
267
+ }, REBUILD_DEBOUNCE_MS)
234
268
  })
235
269
  if (manualRebuild) {
236
270
  abideLog.info(
@@ -1,3 +1,5 @@
1
+ import { tokenizeArgvFlags } from './tokenizeArgvFlags.ts'
2
+
1
3
  /*
2
4
  Parses an argv tail into the JSON args bag for an RPC. The JSON Schema
3
5
  on the manifest entry (when present) drives flag typing:
@@ -47,44 +49,39 @@ export async function parseArgvForRpc(
47
49
  }
48
50
  }
49
51
 
50
- for (let index = 0; index < argv.length; index++) {
51
- const token = argv[index] as string
52
- if (token === '--json') {
53
- const next = argv[++index]
54
- if (!next) {
52
+ /* The shared tokenizer owns the flag-consumption grammar (boolean / inline /
53
+ `--json` / `--no-` negation). This loop layers RPC value semantics on each
54
+ yielded token: JSON-blob merge, Number coercion, array accumulation. */
55
+ for (const token of tokenizeArgvFlags(argv, jsonSchema)) {
56
+ if (token.positional !== undefined) {
57
+ throw new Error(`unexpected positional argument: ${token.positional}`)
58
+ }
59
+ if (token.isJson) {
60
+ if (token.missingValue || token.value === undefined) {
55
61
  throw new Error('--json requires a value')
56
62
  }
57
- const parsed = JSON.parse(next)
63
+ const parsed = JSON.parse(token.value)
58
64
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
59
65
  throw new Error('--json value must be a JSON object')
60
66
  }
61
67
  Object.assign(args, parsed)
62
68
  continue
63
69
  }
64
- if (!token.startsWith('--')) {
65
- throw new Error(`unexpected positional argument: ${token}`)
70
+ // The tokenizer treats `--help` / `-h` as a help token; in RPC parsing
71
+ // they are an unrecognised flag, surfaced loudly like any other.
72
+ if (token.isHelp || token.name === undefined) {
73
+ throw new Error('unexpected --help flag')
66
74
  }
67
- const stripped = token.slice('--'.length)
68
- const [literalName, eqValue] = stripped.includes('=')
69
- ? [stripped.slice(0, stripped.indexOf('=')), stripped.slice(stripped.indexOf('=') + 1)]
70
- : [stripped, undefined]
71
- /* `--no-x` negates only a known boolean property x; otherwise the literal
72
- name wins, so a property legitimately named `no-…` stays reachable. */
73
- const negatedName = literalName.startsWith('no-')
74
- ? literalName.slice('no-'.length)
75
- : undefined
76
- const isNegated = negatedName !== undefined && properties[negatedName]?.type === 'boolean'
77
- const name = isNegated ? (negatedName as string) : literalName
78
- const prop = properties[name]
79
- const propType = prop?.type
75
+ const name = token.name
76
+ const propType = properties[name]?.type
80
77
  if (propType === 'boolean') {
81
- args[name] = !isNegated
78
+ args[name] = !token.negated
82
79
  continue
83
80
  }
84
- const value = eqValue ?? argv[++index]
85
- if (value === undefined) {
81
+ if (token.missingValue || token.value === undefined) {
86
82
  throw new Error(`--${name} requires a value`)
87
83
  }
84
+ const value = token.value
88
85
  if (propType === 'number' || propType === 'integer') {
89
86
  // Reject a blank value explicitly — `Number('')` / `Number(' ')` is 0,
90
87
  // not NaN, so the NaN guard alone would silently coerce it to zero.
@@ -9,6 +9,7 @@ import { printTrimmed } from './printTrimmed.ts'
9
9
  import { resolveCliTarget } from './resolveCliTarget.ts'
10
10
  import { runSession } from './runSession.ts'
11
11
  import { startLocalInstance } from './startLocalInstance.ts'
12
+ import { tokenizeArgvFlags } from './tokenizeArgvFlags.ts'
12
13
  import type { CliManifest } from './types/CliManifest.ts'
13
14
  import type { CliTarget } from './types/CliTarget.ts'
14
15
 
@@ -16,37 +17,21 @@ const isHelpFlag = (arg: string): boolean => arg === '--help' || arg === '-h'
16
17
 
17
18
  /*
18
19
  Whether a command's argv tail genuinely requests help, i.e. a help flag appears
19
- at a flag position rather than as the value of a value-expecting flag. Mirrors
20
- parseArgvForRpc's flag rule: boolean props (and `--name=…`) consume no following
21
- token, everything else consumes the next token as its value so `--title --help`
22
- passes `--help` as the title value, not a help request.
20
+ at a flag position rather than as the value of a value-expecting flag. Shares
21
+ tokenizeArgvFlags' grammar with parseArgvForRpc so the two can't drift: the
22
+ tokenizer applies the flag-consumption rule (boolean / `--name=…` / `--json`
23
+ consume their value), so a `--help` that was a flag's value is already swallowed
24
+ and never surfaces as a help token — `--title --help` passes `--help` as the
25
+ title value, not a help request.
23
26
  */
24
27
  function commandArgvRequestsHelp(
25
28
  argvTail: string[],
26
29
  jsonSchema: Record<string, unknown> | undefined,
27
30
  ): boolean {
28
- const properties =
29
- (jsonSchema?.properties as Record<string, { type?: string }> | undefined) ?? {}
30
- for (let index = 0; index < argvTail.length; index += 1) {
31
- const token = argvTail[index] as string
32
- if (isHelpFlag(token)) {
31
+ for (const token of tokenizeArgvFlags(argvTail, jsonSchema)) {
32
+ if (token.isHelp) {
33
33
  return true
34
34
  }
35
- if (!token.startsWith('--')) {
36
- continue
37
- }
38
- if (token === '--json') {
39
- index += 1 // consumes its value
40
- continue
41
- }
42
- const rawName = token.slice('--'.length)
43
- if (rawName.includes('=')) {
44
- continue // inline value, consumes no following token
45
- }
46
- const name = rawName.startsWith('no-') ? rawName.slice('no-'.length) : rawName
47
- if (properties[name]?.type !== 'boolean') {
48
- index += 1 // a value-expecting flag consumes the next token
49
- }
50
35
  }
51
36
  return false
52
37
  }
@@ -0,0 +1,84 @@
1
+ /*
2
+ The single argv-tail flag grammar shared by help-detection (runCli) and RPC arg
3
+ parsing (parseArgvForRpc) so the two can never drift. Walks the tail and yields
4
+ one token per flag occurrence, honouring the schema-driven consumption rule:
5
+
6
+ - `--help` / `-h` → { isHelp: true }, consumes no following token
7
+ - `--json <blob>` → { isJson: true, value }, consumes the next token
8
+ (the value is the verbatim JSON args bag)
9
+ - boolean props (and `--no-x`) → { name, value: undefined }, consumes no token
10
+ - `--name=value` → { name, value }, inline, consumes no token
11
+ - everything else → { name, value }, consumes the next token
12
+
13
+ `--no-x` negates only a known boolean property x; otherwise the literal `no-…`
14
+ name is kept so a property legitimately named `no-…` stays reachable. Reports
15
+ `negated` and `missingValue` so the parser can apply boolean/coercion semantics
16
+ and throw precise errors. A bare positional (no leading `--`) yields
17
+ { positional: token } so each consumer rejects or ignores it as it sees fit.
18
+
19
+ Pure; reads the schema's `properties` only to classify boolean props.
20
+ */
21
+ export function* tokenizeArgvFlags(
22
+ argvTail: string[],
23
+ jsonSchema: Record<string, unknown> | undefined,
24
+ ): Generator<{
25
+ isHelp?: boolean
26
+ isJson?: boolean
27
+ positional?: string
28
+ name?: string
29
+ value?: string
30
+ negated?: boolean
31
+ missingValue?: boolean
32
+ }> {
33
+ const properties =
34
+ (jsonSchema?.properties as Record<string, { type?: string }> | undefined) ?? {}
35
+ for (let index = 0; index < argvTail.length; index += 1) {
36
+ const token = argvTail[index] as string
37
+ if (token === '--help' || token === '-h') {
38
+ yield { isHelp: true }
39
+ continue
40
+ }
41
+ if (!token.startsWith('--')) {
42
+ yield { positional: token }
43
+ continue
44
+ }
45
+ // `--json <blob>`: the next token is the verbatim JSON args bag.
46
+ if (token === '--json') {
47
+ const value = argvTail[index + 1]
48
+ if (value === undefined) {
49
+ yield { isJson: true, missingValue: true }
50
+ } else {
51
+ index += 1
52
+ yield { isJson: true, value }
53
+ }
54
+ continue
55
+ }
56
+ const stripped = token.slice('--'.length)
57
+ const [literalName, inlineValue] = stripped.includes('=')
58
+ ? [stripped.slice(0, stripped.indexOf('=')), stripped.slice(stripped.indexOf('=') + 1)]
59
+ : [stripped, undefined]
60
+ /* `--no-x` negates only a known boolean property x; otherwise the literal
61
+ name wins, so a property legitimately named `no-…` stays reachable. */
62
+ const negatedName = literalName.startsWith('no-')
63
+ ? literalName.slice('no-'.length)
64
+ : undefined
65
+ const negated = negatedName !== undefined && properties[negatedName]?.type === 'boolean'
66
+ const name = negated ? (negatedName as string) : literalName
67
+ // Boolean props and inline `--name=value` consume no following token.
68
+ if (properties[name]?.type === 'boolean') {
69
+ yield { name, negated }
70
+ continue
71
+ }
72
+ if (inlineValue !== undefined) {
73
+ yield { name, value: inlineValue }
74
+ continue
75
+ }
76
+ const value = argvTail[index + 1]
77
+ if (value === undefined) {
78
+ yield { name, missingValue: true }
79
+ } else {
80
+ index += 1
81
+ yield { name, value }
82
+ }
83
+ }
84
+ }
@@ -100,7 +100,15 @@ export function defineRpc<Args, Return>(
100
100
  function buildRequest(args: Args | undefined): Request {
101
101
  const store = requestContext.getStore()
102
102
  const baseUrl = store ? store.url.href : 'http://localhost/'
103
- const headers = store ? forwardHeaders(store.req.headers) : new Headers()
103
+ /* The forwarded allowlist is request-invariant derive it once per scope
104
+ and clone per call (buildRpcRequest mutates the Headers). */
105
+ let headers: Headers
106
+ if (store) {
107
+ store.forwardedHeaders ??= forwardHeaders(store.req.headers)
108
+ headers = new Headers(store.forwardedHeaders)
109
+ } else {
110
+ headers = new Headers()
111
+ }
104
112
  return buildRpcRequest({ method, url, args, baseUrl, headers })
105
113
  }
106
114
 
@@ -17,6 +17,7 @@ import { isDebugNegated } from '../../shared/isDebugNegated.ts'
17
17
  import { logClosingRecord } from '../../shared/logClosingRecord.ts'
18
18
  import { OFFLINE_HEADER } from '../../shared/OFFLINE_HEADER.ts'
19
19
  import { parseBoundedEnvInt } from '../../shared/parseBoundedEnvInt.ts'
20
+ import { responseBodyKind } from '../../shared/responseBodyKind.ts'
20
21
  import { SOCKETS_PATH } from '../../shared/SOCKETS_PATH.ts'
21
22
  import { setAppName } from '../../shared/setAppName.ts'
22
23
  import { setBaseResolver } from '../../shared/setBaseResolver.ts'
@@ -242,8 +243,11 @@ export async function createServer({
242
243
  isn't installed. Resolved at boot so the fetch route below can branch on it.
243
244
  */
244
245
  const inspectorHandler = await maybeMountInspector({ name: appName, version: appVersion })
245
- /* Built on first request, then reused — the rpc registry is frozen after load. */
246
- let openApiSpec: ReturnType<typeof buildOpenApiSpec> | undefined
246
+ /* Built on first request, then reused — the rpc registry is frozen after load.
247
+ Memoised as a promise so two concurrent cold requests share one build instead
248
+ of both building (the second otherwise clobbering the first). A rejected build
249
+ clears the memo so the next request retries rather than caching the failure. */
250
+ let openApiSpec: Promise<ReturnType<typeof buildOpenApiSpec>> | undefined
247
251
  const cliCwd = process.cwd()
248
252
 
249
253
  /* Request closing records are on by default — DEBUG=-abide is the off switch (negation, like the abide channel itself). */
@@ -362,12 +366,19 @@ export async function createServer({
362
366
  const response = app?.handle
363
367
  ? await app.handle(req, (next) => handler(next, pathParams, store))
364
368
  : await handler(req, pathParams, store)
369
+ /* Classify the body once (S2) and thread it into both downstream
370
+ steps — gzip + the closing-record stream monitor — instead of
371
+ each re-deriving from the Content-Type. */
372
+ const kind = responseBodyKind(response)
373
+ store.responseStreaming = kind === 'streaming'
374
+ // Streaming bodies (sse/jsonl, socket tail) opt out of the idle timeout.
375
+ if (kind === 'streaming') {
376
+ server.timeout(req, 0)
377
+ }
365
378
  /* Gzip compressible dynamic bodies (SSR HTML, rpc/json, 404) when the
366
379
  client accepts it; streaming frame protocols and static assets are
367
380
  passed through untouched (see gzipResponse). */
368
- const encoded = gzipResponse(req, response)
369
- // Streaming bodies (sse/jsonl, socket tail) opt out of the idle timeout.
370
- return disableIdleTimeoutForStream(server, req, encoded)
381
+ return gzipResponse(req, response, kind)
371
382
  })
372
383
  }
373
384
 
@@ -583,14 +594,20 @@ export async function createServer({
583
594
  req,
584
595
  {},
585
596
  async () => {
586
- if (!openApiSpec) {
587
- await ensureRegistriesLoaded()
588
- openApiSpec = buildOpenApiSpec({
589
- title: appName,
590
- version: appVersion,
597
+ openApiSpec ??= ensureRegistriesLoaded()
598
+ .then(() =>
599
+ buildOpenApiSpec({
600
+ title: appName,
601
+ version: appVersion,
602
+ }),
603
+ )
604
+ .catch((error) => {
605
+ // Don't cache a failed build — clear the memo so a
606
+ // later request retries instead of 500-ing forever.
607
+ openApiSpec = undefined
608
+ throw error
591
609
  })
592
- }
593
- return Response.json(openApiSpec, {
610
+ return Response.json(await openApiSpec, {
594
611
  headers: { 'Cache-Control': NO_STORE },
595
612
  })
596
613
  },
@@ -26,7 +26,7 @@ export async function devHotModuleResponse(moduleId: string): Promise<Response>
26
26
  if (source === undefined) {
27
27
  return new Response('not found', { status: 404 })
28
28
  }
29
- const code = compileModule(source, {
29
+ const { code } = compileModule(source, {
30
30
  isLayout: moduleId.endsWith('layout.abide'),
31
31
  moduleId,
32
32
  hot: true,
@@ -1,17 +1,9 @@
1
- import { contentTypeOf } from '../../shared/contentTypeOf.ts'
2
- import { isStreamingResponse } from '../../shared/isStreamingResponse.ts'
1
+ import type { ResponseBodyKind } from '../../shared/responseBodyKind.ts'
2
+ import { responseBodyKind } from '../../shared/responseBodyKind.ts'
3
3
  import { acceptsGzip } from './acceptsGzip.ts'
4
4
  import { flushingGzipStream } from './flushingGzipStream.ts'
5
5
  import { STREAMED_HTML_HEADER } from './STREAMED_HTML_HEADER.ts'
6
6
 
7
- /*
8
- Compressible Content-Types — text and structured-text payloads. Binary or
9
- already-compressed bodies (images, fonts, archives, zstd/gzip blobs) gain
10
- nothing from a second pass and only burn CPU.
11
- */
12
- const COMPRESSIBLE_TYPE =
13
- /^(?:text\/|application\/(?:json|javascript|xml|[\w.-]+\+(?:json|xml))|image\/svg)/
14
-
15
7
  /*
16
8
  Gzips a dynamic response (SSR HTML, rpc/json replies, the plain 404) when the
17
9
  client accepts it. Static assets never reach here — they already carry a
@@ -23,6 +15,12 @@ expose a string body's length before send, and measuring would mean buffering th
23
15
  body — the framing overhead on the rare tiny body is negligible against
24
16
  compressing every page and rpc payload.
25
17
 
18
+ `kind` is the body class the dispatch pipeline already computed (S2: classify
19
+ once, thread it in) — only `compressible` bodies gzip; `streaming` and `opaque`
20
+ pass through. Standalone callers (the health probe) omit it and it's derived
21
+ here. The streamed-HTML marker is handled independent of `kind` since it's
22
+ stripped even on a skip path.
23
+
26
24
  Buffered bodies take the web CompressionStream (best ratio, one flush at close).
27
25
  The streamed SSR document self-marks (STREAMED_HTML_HEADER) and takes a
28
26
  per-chunk-flushing gzip instead: the plain CompressionStream buffers the head
@@ -30,7 +28,7 @@ until its deflate window fills, which defeats streaming (the browser can't
30
28
  preload-scan the head or paint the pending shell until the stream nearly closes).
31
29
  The marker is stripped so it never reaches the client.
32
30
  */
33
- export function gzipResponse(req: Request, response: Response): Response {
31
+ export function gzipResponse(req: Request, response: Response, kind?: ResponseBodyKind): Response {
34
32
  const streamedHtml = response.headers.has(STREAMED_HTML_HEADER)
35
33
  if (streamedHtml) {
36
34
  response.headers.delete(STREAMED_HTML_HEADER)
@@ -38,11 +36,11 @@ export function gzipResponse(req: Request, response: Response): Response {
38
36
  if (!response.body || response.headers.has('Content-Encoding')) {
39
37
  return response
40
38
  }
41
- if (!acceptsGzip(req) || isStreamingResponse(response)) {
42
- return response
43
- }
44
- const contentType = contentTypeOf(response.headers)
45
- if (!COMPRESSIBLE_TYPE.test(contentType)) {
39
+ /* A streamed-HTML document classifies as `compressible` (text/html) but the
40
+ marker was already stripped above, so re-derive after the strip when the
41
+ pipeline didn't hand a kind in. */
42
+ const bodyKind = kind ?? responseBodyKind(response)
43
+ if (!acceptsGzip(req) || bodyKind !== 'compressible') {
46
44
  return response
47
45
  }
48
46
  const headers = new Headers(response.headers)
@@ -120,7 +120,10 @@ export function runWithRequestScope(
120
120
  })
121
121
  })
122
122
  }
123
- if (response.body && isStreamingResponse(response)) {
123
+ /* Reuse the dispatch pipeline's single S2 classification when it stashed
124
+ one; callers that don't (mcp, the fetch fallback, tests) classify here. */
125
+ const streaming = store.responseStreaming ?? isStreamingResponse(response)
126
+ if (response.body && streaming) {
124
127
  /*
125
128
  A hand-pumped wrap rather than a TransformStream: streams typically
126
129
  end by client disconnect (an SSE tab closing), which cancels the
@@ -52,4 +52,20 @@ export type RequestStore = {
52
52
  carried no file parts.
53
53
  */
54
54
  files?: Record<string, File[]>
55
+ /*
56
+ The allowlisted headers forwarded onto in-process rpc Requests during this
57
+ request's SSR pass — derived once from the inbound headers (which don't change
58
+ within the scope) and reused by every defineRpc call. Each call clones it,
59
+ since buildRpcRequest mutates the Headers (content-type/ref-json) before
60
+ constructing the Request. Undefined until the first in-process rpc.
61
+ */
62
+ forwardedHeaders?: Headers
63
+ /*
64
+ The body class of the response the dispatch pipeline produced, stashed so
65
+ runWithRequestScope's closing-record stream monitor reuses the single S2
66
+ classification instead of re-deriving from the Content-Type. Undefined for
67
+ callers that don't classify (mcp, the fetch fallback, tests) — those fall
68
+ back to classifying the response themselves.
69
+ */
70
+ responseStreaming?: boolean
55
71
  }
@@ -17,8 +17,13 @@ export function withResponseDefaults(
17
17
  status?: number,
18
18
  ): ResponseInit {
19
19
  const headers = new Headers(defaultHeaders)
20
- new Headers(init?.headers).forEach((value, key) => {
21
- headers.set(key, value)
22
- })
20
+ /* The dominant case carries no overrides — skip the second Headers
21
+ allocation + per-key copy and ship the defaults alone. Only merge when
22
+ the caller actually supplied headers. */
23
+ if (init?.headers !== undefined) {
24
+ new Headers(init.headers).forEach((value, key) => {
25
+ headers.set(key, value)
26
+ })
27
+ }
23
28
  return { ...init, headers, ...(status !== undefined && { status }) }
24
29
  }
@@ -119,7 +119,11 @@ export function defineSocket<T>(name: string, opts: SocketOptions = {}): Socket<
119
119
  cachedServer = getActiveServer()
120
120
  }
121
121
  const server = cachedServer
122
- if (server) {
122
+ /* Remote fan-out is the only consumer of the encoded frame: in-process iterators
123
+ already received the live object via notify() above. Skip the full message-graph
124
+ walk (encodeRefJson) + native publish when zero ws clients are subscribed to the
125
+ topic — subscriberCount is a cheap C-side check, encodeRefJson is not. */
126
+ if (server && server.subscriberCount(topic) > 0) {
123
127
  /* Typed against the shared wire contract so a `SocketServerFrame` change can't
124
128
  silently drift from this construction site (the dispatcher's `send` is already
125
129
  typed; this was the last `msg` frame built through an unchecked JSON.stringify). */
@@ -0,0 +1,7 @@
1
+ /*
2
+ The `RpcArgs<Fn>` type alias both rpc d.ts writers emit verbatim: it lifts the
3
+ args type out of a RemoteFunction, dropping the FormData upload variant so a
4
+ url()/test-client call types against the rpc's plain args.
5
+ */
6
+ export const RPC_ARGS_TYPE =
7
+ 'type RpcArgs<Fn> = Fn extends (args: infer Args) => unknown ? Exclude<Args, FormData> : never'
@@ -1,20 +1,11 @@
1
- import { cacheStoreSlot } from './cacheStoreSlot.ts'
2
- import { createCacheStore } from './createCacheStore.ts'
1
+ import { cacheStoreResolver } from './cacheStoreResolver.ts'
3
2
  import type { CacheStore } from './types/CacheStore.ts'
4
3
 
5
4
  /*
6
- Resolves the active CacheStore. The runtime is registered via
7
- `setCacheStoreResolver` from the server entry (request-scoped via ALS)
8
- or the client entry (module-level singleton). If no resolver is registered,
9
- a single fallback store is created lazily so isolated tests still work.
5
+ Resolves the active CacheStore: the registered resolver's store, or a single
6
+ lazily-created fallback when none is registered (so isolated tests work). The
7
+ fallback creator guarantees a value, hence the non-null assertion.
10
8
  */
11
9
  export function activeCacheStore(): CacheStore {
12
- const fromResolver = cacheStoreSlot.resolver?.()
13
- if (fromResolver) {
14
- return fromResolver
15
- }
16
- if (!cacheStoreSlot.fallback) {
17
- cacheStoreSlot.fallback = createCacheStore()
18
- }
19
- return cacheStoreSlot.fallback
10
+ return cacheStoreResolver.get()!
20
11
  }