@abide/abide 0.44.0 → 0.45.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 (119) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +64 -0
  3. package/README.md +171 -181
  4. package/package.json +2 -1
  5. package/src/abideResolverPlugin.ts +6 -1
  6. package/src/devEntry.ts +43 -9
  7. package/src/lib/cli/parseArgvForRpc.ts +21 -24
  8. package/src/lib/cli/runCli.ts +9 -24
  9. package/src/lib/cli/tokenizeArgvFlags.ts +84 -0
  10. package/src/lib/server/error.ts +48 -53
  11. package/src/lib/server/json.ts +4 -3
  12. package/src/lib/server/jsonl.ts +1 -1
  13. package/src/lib/server/rpc/defineRpc.ts +11 -9
  14. package/src/lib/server/rpc/types/RemoteHandler.ts +5 -7
  15. package/src/lib/server/rpc/types/RpcHelper.ts +125 -35
  16. package/src/lib/server/rpc/types/TypedError.ts +18 -0
  17. package/src/lib/server/rpc/validationError.ts +3 -3
  18. package/src/lib/server/runtime/STATUS_TEXT.ts +22 -0
  19. package/src/lib/server/runtime/createServer.ts +36 -12
  20. package/src/lib/server/runtime/devHotModuleResponse.ts +1 -1
  21. package/src/lib/server/runtime/gzipResponse.ts +14 -16
  22. package/src/lib/server/runtime/installAmbientScopeStore.ts +33 -0
  23. package/src/lib/server/runtime/runWithRequestScope.ts +4 -1
  24. package/src/lib/server/runtime/snapshotEntryFromCache.ts +2 -0
  25. package/src/lib/server/runtime/typedErrorResponse.ts +26 -0
  26. package/src/lib/server/runtime/types/RequestStore.ts +25 -0
  27. package/src/lib/server/runtime/withResponseDefaults.ts +8 -3
  28. package/src/lib/server/sockets/defineSocket.ts +5 -1
  29. package/src/lib/server/sse.ts +1 -1
  30. package/src/lib/shared/HttpError.ts +1 -1
  31. package/src/lib/shared/RPC_ARGS_TYPE.ts +7 -0
  32. package/src/lib/shared/activeCacheStore.ts +5 -14
  33. package/src/lib/shared/activePage.ts +5 -19
  34. package/src/lib/shared/augmentModule.ts +18 -0
  35. package/src/lib/shared/basePath.ts +6 -5
  36. package/src/lib/shared/baseResolver.ts +10 -0
  37. package/src/lib/shared/baseSlot.ts +6 -12
  38. package/src/lib/shared/cache.ts +46 -4
  39. package/src/lib/shared/cacheEntryFromSnapshot.ts +21 -1
  40. package/src/lib/shared/cacheKeyOf.ts +7 -0
  41. package/src/lib/shared/cacheKeyStore.ts +8 -0
  42. package/src/lib/shared/cacheStoreResolver.ts +12 -0
  43. package/src/lib/shared/cacheStoreSlot.ts +5 -13
  44. package/src/lib/shared/changeAffectsClient.ts +35 -0
  45. package/src/lib/shared/createResolverSlot.ts +37 -0
  46. package/src/lib/shared/debugGate.ts +90 -0
  47. package/src/lib/shared/emitLogRecord.ts +18 -4
  48. package/src/lib/shared/globalCacheStoreResolver.ts +12 -0
  49. package/src/lib/shared/globalCacheStoreSlot.ts +6 -11
  50. package/src/lib/shared/httpErrorFor.ts +1 -1
  51. package/src/lib/shared/isDebugEnabled.ts +3 -10
  52. package/src/lib/shared/isDebugNegated.ts +4 -6
  53. package/src/lib/shared/pageResolver.ts +16 -0
  54. package/src/lib/shared/pageSlot.ts +5 -14
  55. package/src/lib/shared/recordCacheKey.ts +6 -0
  56. package/src/lib/shared/requestScopeResolver.ts +12 -0
  57. package/src/lib/shared/requestScopeSlot.ts +7 -12
  58. package/src/lib/shared/responseBodyKind.ts +35 -0
  59. package/src/lib/shared/setBaseResolver.ts +2 -4
  60. package/src/lib/shared/setCacheStoreResolver.ts +3 -5
  61. package/src/lib/shared/setGlobalCacheStoreResolver.ts +3 -5
  62. package/src/lib/shared/setPageResolver.ts +2 -5
  63. package/src/lib/shared/setRequestScopeResolver.ts +3 -5
  64. package/src/lib/shared/toTagSet.ts +3 -3
  65. package/src/lib/shared/types/CacheEntry.ts +15 -0
  66. package/src/lib/shared/types/CacheOptions.ts +5 -5
  67. package/src/lib/shared/types/CacheSnapshotEntry.ts +5 -0
  68. package/src/lib/shared/types/DebugGate.ts +10 -0
  69. package/src/lib/shared/types/ErrorSpec.ts +6 -6
  70. package/src/lib/shared/types/RemoteFunction.ts +8 -4
  71. package/src/lib/shared/types/ResolverSlot.ts +10 -0
  72. package/src/lib/shared/writeDts.ts +8 -1
  73. package/src/lib/shared/writePublicAssetsDts.ts +5 -9
  74. package/src/lib/shared/writeRoutesDts.ts +12 -16
  75. package/src/lib/shared/writeRpcDts.ts +13 -12
  76. package/src/lib/shared/writeTestRpcDts.ts +11 -11
  77. package/src/lib/shared/writeTestSocketsDts.ts +9 -9
  78. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
  79. package/src/lib/ui/compile/abideUiPlugin.ts +9 -7
  80. package/src/lib/ui/compile/assertRuntimeHelpersBound.ts +24 -12
  81. package/src/lib/ui/compile/compileModule.ts +37 -16
  82. package/src/lib/ui/compile/compileShadow.ts +75 -1
  83. package/src/lib/ui/compile/createShadowLanguageService.ts +29 -3
  84. package/src/lib/ui/compile/generateBuild.ts +7 -1
  85. package/src/lib/ui/compile/generateSSR.ts +10 -3
  86. package/src/lib/ui/compile/parseTemplate.ts +4 -1
  87. package/src/lib/ui/compile/types/TemplateNode.ts +1 -1
  88. package/src/lib/ui/createScope.ts +11 -4
  89. package/src/lib/ui/deferResume.ts +29 -0
  90. package/src/lib/ui/dom/appendText.ts +19 -4
  91. package/src/lib/ui/dom/awaitBlock.ts +74 -26
  92. package/src/lib/ui/dom/eachAsync.ts +11 -18
  93. package/src/lib/ui/dom/mountSwappableRange.ts +79 -0
  94. package/src/lib/ui/dom/skeleton.ts +10 -1
  95. package/src/lib/ui/dom/switchBlock.ts +15 -63
  96. package/src/lib/ui/dom/when.ts +11 -64
  97. package/src/lib/ui/installHotBridge.ts +2 -0
  98. package/src/lib/ui/matchRoute.ts +18 -1
  99. package/src/lib/ui/remoteProxy.ts +11 -1
  100. package/src/lib/ui/renderToStream.ts +13 -8
  101. package/src/lib/ui/resumeSeedScript.ts +2 -2
  102. package/src/lib/ui/runtime/CURRENT_SCOPE.ts +14 -1
  103. package/src/lib/ui/runtime/RESUME.ts +6 -0
  104. package/src/lib/ui/runtime/ambientScopeBacking.ts +26 -0
  105. package/src/lib/ui/runtime/createDoc.ts +102 -12
  106. package/src/lib/ui/runtime/createEffectNode.ts +7 -1
  107. package/src/lib/ui/runtime/flushEffects.ts +18 -0
  108. package/src/lib/ui/runtime/generationGuard.ts +40 -0
  109. package/src/lib/ui/runtime/runNode.ts +9 -1
  110. package/src/lib/ui/runtime/types/SsrRender.ts +4 -3
  111. package/src/lib/ui/scope.ts +6 -8
  112. package/src/lib/ui/tryEncodeResume.ts +5 -2
  113. package/src/lib/ui/types/Scope.ts +3 -4
  114. package/src/zodCjsPlugin.ts +16 -1
  115. package/src/lib/server/rpc/buildErrorConstructors.ts +0 -19
  116. package/src/lib/shared/matchesDebugPattern.ts +0 -16
  117. package/src/lib/shared/parseDebugPatterns.ts +0 -21
  118. package/src/lib/shared/types/ErrorConstructors.ts +0 -17
  119. package/src/lib/shared/types/ErrorDescriptor.ts +0 -10
@@ -2,7 +2,61 @@ import type { ClientFlags } from '../../../shared/types/ClientFlags.ts'
2
2
  import type { ErrorSpec } from '../../../shared/types/ErrorSpec.ts'
3
3
  import type { RemoteFunction } from '../../../shared/types/RemoteFunction.ts'
4
4
  import type { StandardSchemaV1 } from '../../../shared/types/StandardSchemaV1.ts'
5
- import type { RemoteHandler } from './RemoteHandler.ts'
5
+ import type { TypedError } from './TypedError.ts'
6
+ import type { TypedResponse } from './TypedResponse.ts'
7
+
8
+ /*
9
+ The success body carried by a handler's return type `R`. Error branches
10
+ (`TypedError`, checked first since they're also Responses) drop to `never` and
11
+ union away, so `Return` is the body of the success `TypedResponse` members alone
12
+ — an untagged `Response` falls back to `unknown`, matching hand-built responses.
13
+ */
14
+ type SuccessBody<R> =
15
+ R extends TypedError<string, ErrorSpec[string]>
16
+ ? never
17
+ : R extends TypedResponse<infer Body>
18
+ ? Body
19
+ : unknown
20
+
21
+ /*
22
+ The error spec a handler's return type `R` declares — rebuilt name→entry from the
23
+ `TypedError` brands among its branches (distributes over the union; no error
24
+ branches → `{}`). This is what gives `rpc.isError` its typed surface with no
25
+ `errors:` option: the errors a handler RETURNS are the errors it can raise.
26
+ */
27
+ type ErrorBrand<R> =
28
+ R extends TypedError<infer Name, infer Entry> ? { name: Name; entry: Entry } : never
29
+ type InferredErrors<R> = { [Brand in ErrorBrand<R> as Brand['name']]: Brand['entry'] }
30
+
31
+ /*
32
+ The handler every bare (schemaless) overload accepts: any args in, a Response (or a
33
+ Promise of one) out. Collapsing the old `<Args, R>` pair into this single inferred
34
+ function type is deliberate. TypeScript has no partial type-argument inference, so
35
+ `GET<Args>(fn)` used to drop `R` to its `Response` default and silently erase the body
36
+ to `unknown`. With one generic constrained to `RpcFn`, that same call is instead a loud
37
+ `does not satisfy the constraint 'RpcFn'`, and `Args`/`Return`/`Errors` all read
38
+ structurally off the handler — args from its parameter, body + errors from its return —
39
+ which is where a normal TS function declares them anyway (no mainstream rpc framework
40
+ parameterises the call with generics). `any` is load-bearing: `never`/`unknown` in the
41
+ parameter position break return inference by contextually widening the handler's body.
42
+ */
43
+ type RpcFn = (args: any) => Response | Promise<Response>
44
+
45
+ /* The handler's declared args: its first parameter, or `undefined` for a nullary handler. */
46
+ type RpcArgs<F extends RpcFn> = Parameters<F> extends [infer Args, ...unknown[]] ? Args : undefined
47
+
48
+ /*
49
+ The RemoteFunction a bare overload produces from handler `F`. `SuccessBody`/`InferredErrors`
50
+ run over `Awaited<ReturnType<F>>` INLINE (not via an intermediate `extends RpcFn`-constrained
51
+ alias) — such an alias resolves `ReturnType` against the constraint bound and degrades the
52
+ body to `any`. `Durable` threads the outbox bit through for the durable overloads.
53
+ */
54
+ type RpcOf<F extends RpcFn, Durable extends boolean = false> = RemoteFunction<
55
+ RpcArgs<F>,
56
+ SuccessBody<Awaited<ReturnType<F>>>,
57
+ InferredErrors<Awaited<ReturnType<F>>>,
58
+ Durable
59
+ >
6
60
 
7
61
  /*
8
62
  Options every rpc overload accepts: the OpenAPI 200 `outputSchema`, the
@@ -34,14 +88,17 @@ type MutatingRpcOpts = RpcBaseOpts & {
34
88
  }
35
89
 
36
90
  /*
37
- Shared signature for every rpc helper (GET / POST / …). Four overloads:
91
+ Shared signature for every rpc helper (GET / POST / …). The handler's return
92
+ type is inferred whole (`Awaited<ReturnType<F>>`), then split: `SuccessBody`
93
+ becomes the caller's `Return`, `InferredErrors` becomes the rpc's `Errors`
94
+ (driving `isError`). Typed errors are raised by returning an
95
+ `error.typed(name, status, schema?)` constructor — there is no `errors:` opt.
96
+ Four overloads by argument source:
38
97
 
39
98
  - `Rpc(fn, { inputSchema, outputSchema?, clients? })` — `Args` infers
40
99
  from `InferInput<InputSchema>`, the handler receives
41
- `InferOutput<InputSchema>`. Generic order is `<Return, InputSchema>` so
42
- users can override `Return` while letting `InputSchema` infer from
43
- `opts.inputSchema`. `outputSchema` is an optional Standard Schema for
44
- the success body — it feeds the OpenAPI 200 response and the MCP tool
100
+ `InferOutput<InputSchema>`. `outputSchema` is an optional Standard Schema
101
+ for the success body it feeds the OpenAPI 200 response and the MCP tool
45
102
  `outputSchema`. JSON Schema is projected from each schema's own
46
103
  `toJSONSchema()` (wrap with withJsonSchema if the library lacks one).
47
104
  `clients` controls which surfaces (browser / mcp / cli) expose this rpc.
@@ -57,9 +114,13 @@ Shared signature for every rpc helper (GET / POST / …). Four overloads:
57
114
  just abandoned.
58
115
  - `Rpc(fn, { clients })` — schemaless but with explicit client
59
116
  targeting (e.g. server-internal RPC with `clients: { browser: false }`).
60
- - `Rpc(fn)` — bare handler. `Args` and `Return` come from the handler
61
- type; `Return` is usually inferred via the `TypedResponse<T>` brand on
62
- `json`/`error`/`redirect`/`jsonl`/`sse`.
117
+ - `Rpc(fn)` — bare handler, a single `F extends RpcFn` generic. Everything
118
+ is read off the handler: `Args` from its parameter (annotate it
119
+ `POST((a: { id: string }) => …)` — or leave it nullary for `undefined`),
120
+ `Return` from the `TypedResponse<T>` brand on
121
+ `json`/`error`/`redirect`/`jsonl`/`sse`, `Errors` from any
122
+ `error.typed(...)` branches. You never pass `<Args, Return>` generics; a
123
+ stray one is a loud constraint error, not a silent `unknown` body.
63
124
  */
64
125
  type RpcHelperOf<Opts> = {
65
126
  /*
@@ -72,47 +133,76 @@ type RpcHelperOf<Opts> = {
72
133
  jsonSchemaForSchema) — so only inputSchema feeds MCP/CLI/OpenAPI.
73
134
  */
74
135
  <
75
- Return = unknown,
136
+ R extends Response,
76
137
  InputSchema extends StandardSchemaV1 = StandardSchemaV1,
77
138
  FilesSchema extends StandardSchemaV1 = StandardSchemaV1,
78
- Errors extends ErrorSpec = Record<string, never>,
79
139
  >(
80
- fn: RemoteHandler<
81
- StandardSchemaV1.InferOutput<InputSchema> & StandardSchemaV1.InferOutput<FilesSchema>,
82
- Return,
83
- Errors
84
- >,
140
+ fn: (
141
+ args: StandardSchemaV1.InferOutput<InputSchema> &
142
+ StandardSchemaV1.InferOutput<FilesSchema>,
143
+ ) => R | Promise<R>,
85
144
  opts: Opts & {
86
145
  inputSchema: InputSchema
87
146
  filesSchema: FilesSchema
88
- errors?: Errors
89
147
  },
90
- ): RemoteFunction<StandardSchemaV1.InferInput<InputSchema>, Return, Errors>
91
- <
92
- Return = unknown,
93
- InputSchema extends StandardSchemaV1 = StandardSchemaV1,
94
- Errors extends ErrorSpec = Record<string, never>,
95
- >(
96
- fn: RemoteHandler<StandardSchemaV1.InferOutput<InputSchema>, Return, Errors>,
97
- opts: Opts & { inputSchema: InputSchema; errors?: Errors },
98
- ): RemoteFunction<StandardSchemaV1.InferInput<InputSchema>, Return, Errors>
99
- <Args = undefined, Return = unknown, Errors extends ErrorSpec = Record<never, never>>(
100
- fn: RemoteHandler<Args, Return, Errors>,
101
- opts: Opts & { errors?: Errors },
102
- ): RemoteFunction<Args, Return, Errors>
103
- <Args = undefined, Return = unknown>(
104
- fn: RemoteHandler<Args, Return>,
105
- ): RemoteFunction<Args, Return>
148
+ ): RemoteFunction<StandardSchemaV1.InferInput<InputSchema>, SuccessBody<R>, InferredErrors<R>>
149
+ <R extends Response, InputSchema extends StandardSchemaV1 = StandardSchemaV1>(
150
+ fn: (args: StandardSchemaV1.InferOutput<InputSchema>) => R | Promise<R>,
151
+ opts: Opts & { inputSchema: InputSchema },
152
+ ): RemoteFunction<StandardSchemaV1.InferInput<InputSchema>, SuccessBody<R>, InferredErrors<R>>
153
+ <F extends RpcFn>(fn: F, opts: Opts): RpcOf<F>
154
+ <F extends RpcFn>(fn: F): RpcOf<F>
106
155
  }
107
156
 
108
157
  /* The read helpers (GET/HEAD): no `outbox` — a read has nothing to durably deliver. */
109
158
  export type RpcHelper = RpcHelperOf<RpcBaseOpts>
110
159
 
160
+ /*
161
+ Durable-call overloads: an `outbox: true` opt returns a RemoteFunction whose `Durable` bit
162
+ is set, so `rpc.outbox` is the queue face rather than optionally-undefined. Mirrors the
163
+ opts-bearing base overloads (multipart-upload, schema'd, schemaless); the bare `Rpc(fn)`
164
+ form has no opts to carry `outbox`, so it stays non-durable. Intersected AHEAD of the base
165
+ set in MutatingRpcHelper so an `outbox: true` literal resolves here first.
166
+ */
167
+ type DurableMutatingRpcHelper = {
168
+ <
169
+ R extends Response,
170
+ InputSchema extends StandardSchemaV1 = StandardSchemaV1,
171
+ FilesSchema extends StandardSchemaV1 = StandardSchemaV1,
172
+ >(
173
+ fn: (
174
+ args: StandardSchemaV1.InferOutput<InputSchema> &
175
+ StandardSchemaV1.InferOutput<FilesSchema>,
176
+ ) => R | Promise<R>,
177
+ opts: MutatingRpcOpts & {
178
+ inputSchema: InputSchema
179
+ filesSchema: FilesSchema
180
+ outbox: true
181
+ },
182
+ ): RemoteFunction<
183
+ StandardSchemaV1.InferInput<InputSchema>,
184
+ SuccessBody<R>,
185
+ InferredErrors<R>,
186
+ true
187
+ >
188
+ <R extends Response, InputSchema extends StandardSchemaV1 = StandardSchemaV1>(
189
+ fn: (args: StandardSchemaV1.InferOutput<InputSchema>) => R | Promise<R>,
190
+ opts: MutatingRpcOpts & { inputSchema: InputSchema; outbox: true },
191
+ ): RemoteFunction<
192
+ StandardSchemaV1.InferInput<InputSchema>,
193
+ SuccessBody<R>,
194
+ InferredErrors<R>,
195
+ true
196
+ >
197
+ <F extends RpcFn>(fn: F, opts: MutatingRpcOpts & { outbox: true }): RpcOf<F, true>
198
+ }
199
+
111
200
  /*
112
201
  The mutating helpers (POST/PUT/PATCH/DELETE). A durable (`outbox`) call is a normal
113
202
  RemoteFunction — it throws exactly like a non-durable one and only parks the request as a
114
203
  side-effect on an unreachable server — so there is no separate return shape; `outbox` rides
115
204
  MutatingRpcOpts and `rpc.outbox` exposes the queue. The distinct opts base is what makes
116
- `outbox` legal here and a compile error on the read helpers.
205
+ `outbox` legal here and a compile error on the read helpers; the durable overloads then set
206
+ the return type's `Durable` bit so `rpc.outbox` is present without an optional chain.
117
207
  */
118
- export type MutatingRpcHelper = RpcHelperOf<MutatingRpcOpts>
208
+ export type MutatingRpcHelper = DurableMutatingRpcHelper & RpcHelperOf<MutatingRpcOpts>
@@ -0,0 +1,18 @@
1
+ import type { ErrorSpec } from '../../../shared/types/ErrorSpec.ts'
2
+
3
+ /*
4
+ A `Response` branded with the typed error a handler RETURNED — the name it
5
+ travels as (`$abideError`) plus its spec entry (`{ status, data? }`). The rpc
6
+ helper reads this brand off the handler's inferred return union to build the
7
+ function's `Errors` surface, so `rpc.isError(e, 'name')` narrows `.kind` / `.data`
8
+ with no `errors:` option to declare.
9
+
10
+ Unlike `TypedResponse`'s `__body?` (optional, so a bare `new Response()` stays
11
+ assignable), the brand here is REQUIRED: it's the discriminant that tells the
12
+ helper an error branch from a success branch (the success body is extracted from
13
+ the non-error members, the error spec from these). The runtime value is a plain
14
+ serialized Response cast to this type — the brand is phantom, never assigned.
15
+ */
16
+ export type TypedError<Name extends string, Entry extends ErrorSpec[string]> = Response & {
17
+ readonly __abideError: { name: Name; entry: Entry }
18
+ }
@@ -1,17 +1,17 @@
1
1
  import type { StandardSchemaV1 } from '../../shared/types/StandardSchemaV1.ts'
2
2
  import type { ValidationErrorData } from '../../shared/types/ValidationErrorData.ts'
3
- import { error } from '../error.ts'
3
+ import { typedErrorResponse } from '../runtime/typedErrorResponse.ts'
4
4
  import { fieldErrorsFromIssues } from './fieldErrorsFromIssues.ts'
5
5
 
6
6
  /*
7
7
  The framework-reserved `validation` typed error a 422 carries: the raw Standard
8
8
  Schema `issues` plus the form-friendly field → first-message map. Routed through
9
- the single `error()` funnel (descriptor form) so it serializes as the same
9
+ the shared `typedErrorResponse` serializer so it emits the same
10
10
  `{ $abideError, data }` body every typed error uses, and so the 422's reason
11
11
  phrase reaches `HttpError.statusText`. The client parses it back onto
12
12
  `HttpError.kind = 'validation'` / `.data: ValidationErrorData`.
13
13
  */
14
14
  export function validationError(issues: readonly StandardSchemaV1.Issue[]): Response {
15
15
  const data: ValidationErrorData = { issues, fields: fieldErrorsFromIssues(issues) }
16
- return error({ $abideError: 'validation', status: 422, data })
16
+ return typedErrorResponse('validation', 422, data)
17
17
  }
@@ -0,0 +1,22 @@
1
+ /*
2
+ Standard reason phrases for the statuses abide sets explicitly. Bun's `Response`
3
+ does not populate `statusText` from the status code, so there's no platform table
4
+ to read. Unlisted codes fall back to `HTTP <status>` at the call site.
5
+ */
6
+ export const STATUS_TEXT: Record<number, string> = {
7
+ 400: 'Bad Request',
8
+ 401: 'Unauthorized',
9
+ 403: 'Forbidden',
10
+ 404: 'Not Found',
11
+ 405: 'Method Not Allowed',
12
+ 409: 'Conflict',
13
+ 410: 'Gone',
14
+ 413: 'Content Too Large',
15
+ 422: 'Unprocessable Content',
16
+ 429: 'Too Many Requests',
17
+ 500: 'Internal Server Error',
18
+ 501: 'Not Implemented',
19
+ 502: 'Bad Gateway',
20
+ 503: 'Service Unavailable',
21
+ 504: 'Gateway Timeout',
22
+ }
@@ -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'
@@ -49,6 +50,7 @@ import { devHotModuleResponse } from './devHotModuleResponse.ts'
49
50
  import { devReloadResponse } from './devReloadResponse.ts'
50
51
  import { disableIdleTimeoutForStream } from './disableIdleTimeoutForStream.ts'
51
52
  import { gzipResponse } from './gzipResponse.ts'
53
+ import { installAmbientScopeStore } from './installAmbientScopeStore.ts'
52
54
  import { internalErrorResponse } from './internalErrorResponse.ts'
53
55
  import { listenOnOpenPort } from './listenOnOpenPort.ts'
54
56
  import { logExposedSurfaces } from './logExposedSurfaces.ts'
@@ -158,6 +160,12 @@ export async function createServer({
158
160
  HTTP test harness gets the same behaviour as a real boot. elapsedMs is
159
161
  computed at read time so every log line carries a current value.
160
162
  */
163
+ /*
164
+ Key the ambient lexical scope off the per-request ALS store, so concurrent
165
+ async SSR renders don't clobber one shared module global across the inline
166
+ `await`s they suspend on (see installAmbientScopeStore / CURRENT_SCOPE).
167
+ */
168
+ installAmbientScopeStore()
161
169
  setRequestScopeResolver(() => {
162
170
  const store = requestContext.getStore()
163
171
  if (!store) {
@@ -242,8 +250,11 @@ export async function createServer({
242
250
  isn't installed. Resolved at boot so the fetch route below can branch on it.
243
251
  */
244
252
  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
253
+ /* Built on first request, then reused — the rpc registry is frozen after load.
254
+ Memoised as a promise so two concurrent cold requests share one build instead
255
+ of both building (the second otherwise clobbering the first). A rejected build
256
+ clears the memo so the next request retries rather than caching the failure. */
257
+ let openApiSpec: Promise<ReturnType<typeof buildOpenApiSpec>> | undefined
247
258
  const cliCwd = process.cwd()
248
259
 
249
260
  /* Request closing records are on by default — DEBUG=-abide is the off switch (negation, like the abide channel itself). */
@@ -362,12 +373,19 @@ export async function createServer({
362
373
  const response = app?.handle
363
374
  ? await app.handle(req, (next) => handler(next, pathParams, store))
364
375
  : await handler(req, pathParams, store)
376
+ /* Classify the body once (S2) and thread it into both downstream
377
+ steps — gzip + the closing-record stream monitor — instead of
378
+ each re-deriving from the Content-Type. */
379
+ const kind = responseBodyKind(response)
380
+ store.responseStreaming = kind === 'streaming'
381
+ // Streaming bodies (sse/jsonl, socket tail) opt out of the idle timeout.
382
+ if (kind === 'streaming') {
383
+ server.timeout(req, 0)
384
+ }
365
385
  /* Gzip compressible dynamic bodies (SSR HTML, rpc/json, 404) when the
366
386
  client accepts it; streaming frame protocols and static assets are
367
387
  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)
388
+ return gzipResponse(req, response, kind)
371
389
  })
372
390
  }
373
391
 
@@ -583,14 +601,20 @@ export async function createServer({
583
601
  req,
584
602
  {},
585
603
  async () => {
586
- if (!openApiSpec) {
587
- await ensureRegistriesLoaded()
588
- openApiSpec = buildOpenApiSpec({
589
- title: appName,
590
- version: appVersion,
604
+ openApiSpec ??= ensureRegistriesLoaded()
605
+ .then(() =>
606
+ buildOpenApiSpec({
607
+ title: appName,
608
+ version: appVersion,
609
+ }),
610
+ )
611
+ .catch((error) => {
612
+ // Don't cache a failed build — clear the memo so a
613
+ // later request retries instead of 500-ing forever.
614
+ openApiSpec = undefined
615
+ throw error
591
616
  })
592
- }
593
- return Response.json(openApiSpec, {
617
+ return Response.json(await openApiSpec, {
594
618
  headers: { 'Cache-Control': NO_STORE },
595
619
  })
596
620
  },
@@ -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)
@@ -0,0 +1,33 @@
1
+ import { ambientScopeBacking } from '../../ui/runtime/ambientScopeBacking.ts'
2
+ import type { Scope } from '../../ui/types/Scope.ts'
3
+ import { requestContext } from './requestContext.ts'
4
+
5
+ /* Ambient scope when no request is in flight (server boot, a standalone render). */
6
+ let outsideRequest: Scope | undefined
7
+
8
+ /*
9
+ Swaps the ambient-scope backing to one keyed off the per-request AsyncLocalStorage
10
+ store, so `CURRENT_SCOPE.current` isolates per request instead of living in one
11
+ module global (see CURRENT_SCOPE). SSR is partly async — a render `await`s inline
12
+ between its `enterScope`/`exitScope` bracket — and the async context propagates the
13
+ right store across those awaits, so a resumed render reads ITS OWN scope even while
14
+ another request renders concurrently. Called once at server boot (createServer),
15
+ alongside the request-scope resolver install. Reads/writes outside any request fall
16
+ back to a module variable (unchanged behaviour for standalone renders).
17
+ */
18
+ export function installAmbientScopeStore(): void {
19
+ ambientScopeBacking.active = {
20
+ get: () => {
21
+ const store = requestContext.getStore()
22
+ return store ? store.currentScope : outsideRequest
23
+ },
24
+ set: (value) => {
25
+ const store = requestContext.getStore()
26
+ if (store) {
27
+ store.currentScope = value
28
+ } else {
29
+ outsideRequest = value
30
+ }
31
+ },
32
+ }
33
+ }
@@ -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
@@ -74,6 +74,8 @@ export async function snapshotEntryFromCache(
74
74
  statusText: response.statusText,
75
75
  headers: Array.from(response.headers.entries()),
76
76
  body,
77
+ /* Deferred by the SSR resume path → seed the shipped body lazily (no hydration decode). */
78
+ lazy: entry.deferred === true ? true : undefined,
77
79
  }
78
80
  }
79
81
 
@@ -0,0 +1,26 @@
1
+ import { NO_STORE } from '../../shared/CACHE_CONTROL_VALUES.ts'
2
+ import type { TypedResponse } from '../rpc/types/TypedResponse.ts'
3
+ import { STATUS_TEXT } from './STATUS_TEXT.ts'
4
+ import { withResponseDefaults } from './withResponseDefaults.ts'
5
+
6
+ /*
7
+ Serializes a typed error as a `{ $abideError, data }` JSON body at `status`, with
8
+ the status reason phrase as statusText so it reaches `HttpError.statusText` on the
9
+ client (which parses the body back onto `HttpError.kind` / `.data`). `data` of
10
+ `undefined` drops the key (nullary errors). The single serializer shared by the
11
+ `error.typed(...)` constructors and the framework-reserved `validation` error.
12
+ */
13
+ export function typedErrorResponse(
14
+ name: string,
15
+ status: number,
16
+ data: unknown,
17
+ ): TypedResponse<never> {
18
+ return new Response(
19
+ JSON.stringify({ $abideError: name, data }),
20
+ withResponseDefaults(
21
+ { statusText: STATUS_TEXT[status] ?? `HTTP ${status}` },
22
+ { 'Content-Type': 'application/json', 'Cache-Control': NO_STORE },
23
+ status,
24
+ ),
25
+ ) as TypedResponse<never>
26
+ }
@@ -1,5 +1,6 @@
1
1
  import type { CacheStore } from '../../../shared/types/CacheStore.ts'
2
2
  import type { TraceContext } from '../../../shared/types/TraceContext.ts'
3
+ import type { Scope } from '../../../ui/types/Scope.ts'
3
4
 
4
5
  /*
5
6
  Per-request state propagated through AsyncLocalStorage. Every field is
@@ -52,4 +53,28 @@ export type RequestStore = {
52
53
  carried no file parts.
53
54
  */
54
55
  files?: Record<string, File[]>
56
+ /*
57
+ The allowlisted headers forwarded onto in-process rpc Requests during this
58
+ request's SSR pass — derived once from the inbound headers (which don't change
59
+ within the scope) and reused by every defineRpc call. Each call clones it,
60
+ since buildRpcRequest mutates the Headers (content-type/ref-json) before
61
+ constructing the Request. Undefined until the first in-process rpc.
62
+ */
63
+ forwardedHeaders?: Headers
64
+ /*
65
+ The body class of the response the dispatch pipeline produced, stashed so
66
+ runWithRequestScope's closing-record stream monitor reuses the single S2
67
+ classification instead of re-deriving from the Content-Type. Undefined for
68
+ callers that don't classify (mcp, the fetch fallback, tests) — those fall
69
+ back to classifying the response themselves.
70
+ */
71
+ responseStreaming?: boolean
72
+ /*
73
+ The request's ambient lexical scope during its SSR pass — the backing for
74
+ `CURRENT_SCOPE.current` under the server's ALS-backed holder
75
+ (installAmbientScopeStore). Keeping it per-request isolates the ambient across
76
+ the inline `await`s a render suspends on, so concurrent renders don't clobber
77
+ one shared module global. Undefined until the render enters its first scope.
78
+ */
79
+ currentScope?: Scope
55
80
  }
@@ -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). */
@@ -5,7 +5,7 @@ Server-Sent Events (text/event-stream) — each frame becomes one
5
5
  generator over plain HTTP so EventSource (or `tail(fn.stream(args))`
6
6
  on the client) can consume it frame-by-frame.
7
7
 
8
- export const orderFeed = GET<Args>((args) =>
8
+ export const orderFeed = GET((args: Args) =>
9
9
  sse(async function* () {
10
10
  for await (const order of db.watchOrders(args)) yield order
11
11
  }())
@@ -9,7 +9,7 @@ export class HttpError extends Error {
9
9
  readonly status: number
10
10
  readonly statusText: string
11
11
  readonly response: Response
12
- /* Set when the handler returned a typed error (`error(errors.x(...))`) or a
12
+ /* Set when the handler returned a typed error (`error.typed(...)` constructor) or a
13
13
  validation 422: `kind` is the declared error name (or 'validation'), `data` the
14
14
  payload it carried — parsed off the `{ $abideError, data }` body by decodeResponse.
15
15
  `data` is typed `unknown` (a throw can't carry the rpc's per-kind type to the
@@ -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
  }