@abide/abide 0.38.1 → 0.40.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 (80) hide show
  1. package/AGENTS.md +5 -5
  2. package/CHANGELOG.md +63 -0
  3. package/package.json +9 -1
  4. package/src/build.ts +14 -0
  5. package/src/lib/bundle/exitWithParent.ts +4 -2
  6. package/src/lib/server/rpc/parseArgs.ts +10 -1
  7. package/src/lib/server/rpc/readBodyWithinLimit.ts +3 -0
  8. package/src/lib/server/rpc/runWithVerbTimeout.ts +7 -9
  9. package/src/lib/server/rpc/types/VerbHelper.ts +18 -21
  10. package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +8 -7
  11. package/src/lib/server/runtime/createAppAssetServer.ts +37 -11
  12. package/src/lib/server/runtime/createPublicAssetServer.ts +14 -5
  13. package/src/lib/server/runtime/createUiPageRenderer.ts +53 -42
  14. package/src/lib/server/runtime/internalErrorResponse.ts +5 -2
  15. package/src/lib/server/runtime/snapshotEntryFromCache.ts +4 -1
  16. package/src/lib/server/sockets/createSocketDispatcher.ts +19 -3
  17. package/src/lib/server/sockets/defineSocket.ts +3 -1
  18. package/src/lib/shared/REF_JSON_HEADER.ts +9 -0
  19. package/src/lib/shared/REF_JSON_TAGS.ts +31 -0
  20. package/src/lib/shared/buildRpcRequest.ts +8 -1
  21. package/src/lib/shared/createRemoteFunction.ts +20 -11
  22. package/src/lib/shared/decodeRefJson.ts +110 -0
  23. package/src/lib/shared/encodeRefJson.ts +106 -0
  24. package/src/lib/shared/escapeHtml.ts +15 -0
  25. package/src/lib/shared/markFrameworkSourcesIgnored.ts +47 -0
  26. package/src/lib/shared/streamResponse.ts +8 -1
  27. package/src/lib/shared/types/RemoteCallable.ts +12 -3
  28. package/src/lib/shared/types/RpcOptions.ts +22 -0
  29. package/src/lib/shared/types/SourceMap.ts +14 -0
  30. package/src/lib/test/createTestSocketChannel.ts +6 -2
  31. package/src/lib/ui/compile/SSR_ESCAPE.ts +13 -3
  32. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +5 -0
  33. package/src/lib/ui/compile/compileModule.ts +5 -2
  34. package/src/lib/ui/compile/compileSSR.ts +32 -9
  35. package/src/lib/ui/compile/compileShadow.ts +25 -10
  36. package/src/lib/ui/compile/composeProps.ts +53 -0
  37. package/src/lib/ui/compile/desugarSignals.ts +45 -17
  38. package/src/lib/ui/compile/generateBuild.ts +87 -76
  39. package/src/lib/ui/compile/generateSSR.ts +217 -71
  40. package/src/lib/ui/compile/isWhitespaceText.ts +11 -0
  41. package/src/lib/ui/compile/lowerDocAccess.ts +53 -16
  42. package/src/lib/ui/compile/parseTemplate.ts +118 -1
  43. package/src/lib/ui/compile/renameSignalRefs.ts +12 -0
  44. package/src/lib/ui/compile/resolveBranches.ts +21 -0
  45. package/src/lib/ui/compile/spreadExcludedNames.ts +27 -0
  46. package/src/lib/ui/compile/staticAttr.ts +1 -1
  47. package/src/lib/ui/compile/staticTextPart.ts +1 -1
  48. package/src/lib/ui/compile/types/TemplateAttr.ts +4 -1
  49. package/src/lib/ui/compile/types/TemplateNode.ts +15 -3
  50. package/src/lib/ui/createScope.ts +14 -0
  51. package/src/lib/ui/dom/appendText.ts +2 -3
  52. package/src/lib/ui/dom/appendTextAt.ts +2 -3
  53. package/src/lib/ui/dom/applyResolved.ts +5 -8
  54. package/src/lib/ui/dom/awaitBlock.ts +14 -1
  55. package/src/lib/ui/dom/mergeProps.ts +32 -0
  56. package/src/lib/ui/dom/on.ts +7 -0
  57. package/src/lib/ui/dom/parseRawNodes.ts +17 -0
  58. package/src/lib/ui/dom/readCall.ts +27 -0
  59. package/src/lib/ui/dom/restProps.ts +32 -0
  60. package/src/lib/ui/dom/spreadAttrs.ts +34 -0
  61. package/src/lib/ui/dom/spreadProps.ts +32 -0
  62. package/src/lib/ui/installHotBridge.ts +10 -0
  63. package/src/lib/ui/navigate.ts +28 -3
  64. package/src/lib/ui/remoteProxy.ts +68 -36
  65. package/src/lib/ui/renderChain.ts +39 -37
  66. package/src/lib/ui/renderToStream.ts +84 -68
  67. package/src/lib/ui/resumeSeedScript.ts +27 -0
  68. package/src/lib/ui/router.ts +81 -51
  69. package/src/lib/ui/runtime/RESUME.ts +13 -6
  70. package/src/lib/ui/runtime/createEffectNode.ts +5 -0
  71. package/src/lib/ui/runtime/localStoragePersistence.ts +21 -8
  72. package/src/lib/ui/runtime/toTeardown.ts +10 -5
  73. package/src/lib/ui/runtime/types/RenderContext.ts +8 -0
  74. package/src/lib/ui/runtime/types/SsrRender.ts +16 -11
  75. package/src/lib/ui/runtime/types/UiComponent.ts +7 -1
  76. package/src/lib/ui/socketChannel.ts +5 -2
  77. package/src/lib/ui/tryEncodeResume.ts +20 -0
  78. package/src/lib/ui/types/Scope.ts +9 -3
  79. package/src/lib/ui/compile/escapeHtml.ts +0 -15
  80. /package/src/lib/{server/runtime → shared}/safeJsonForScript.ts +0 -0
@@ -1,4 +1,5 @@
1
1
  import { NO_STORE } from '../../shared/CACHE_CONTROL_VALUES.ts'
2
+ import { escapeHtml } from '../../shared/escapeHtml.ts'
2
3
 
3
4
  /*
4
5
  The framework's default 500 response. Shared by the per-request scope's catch
@@ -8,12 +9,14 @@ drift. Only reached when the app supplies no `handleError` hook.
8
9
  Secure by default: a bare `Internal Server Error` so paths, library versions,
9
10
  and message contents never leak to clients in production. The full stack is
10
11
  shown only under `abide dev` (ABIDE_DEV=1, the orchestrator's signal); the
11
- cause is logged server-side regardless.
12
+ cause is logged server-side regardless. The stack is HTML-escaped because a
13
+ thrown Error built from request-influenced input can carry markup that would
14
+ otherwise execute in the dev browser when embedded in the `<pre>`.
12
15
  */
13
16
  export function internalErrorResponse(error: unknown): Response {
14
17
  const body =
15
18
  Bun.env.ABIDE_DEV === '1'
16
- ? `<pre>${String((error as Error)?.stack ?? error)}</pre>`
19
+ ? `<pre>${escapeHtml(String((error as Error)?.stack ?? error))}</pre>`
17
20
  : 'Internal Server Error'
18
21
  return new Response(body, {
19
22
  status: 500,
@@ -52,7 +52,10 @@ export async function snapshotEntryFromCache(
52
52
  if (!isTextual(contentType)) {
53
53
  return undefined
54
54
  }
55
- const body = await response.text()
55
+ /* Read a CLONE, not the original: a reader that captured this same `entry.promise`
56
+ before the replacement below still holds `response` and may `.clone()` it — reading
57
+ the original here would lock its body and throw "Body already used" for that reader. */
58
+ const body = await response.clone().text()
56
59
  entry.promise = Promise.resolve(
57
60
  new Response(body, {
58
61
  status: response.status,
@@ -1,5 +1,7 @@
1
1
  import type { ServerWebSocket } from 'bun'
2
2
  import { abideLog } from '../../shared/abideLog.ts'
3
+ import { decodeRefJson } from '../../shared/decodeRefJson.ts'
4
+ import { encodeRefJson } from '../../shared/encodeRefJson.ts'
3
5
  import { memoizeByKey } from '../../shared/memoizeByKey.ts'
4
6
  import { messageFromError } from '../../shared/messageFromError.ts'
5
7
  import { error } from '../error.ts'
@@ -104,7 +106,7 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
104
106
  if (ws.readyState !== WebSocket.OPEN) {
105
107
  return
106
108
  }
107
- ws.send(JSON.stringify(frame))
109
+ ws.send(encodeRefJson(frame))
108
110
  }
109
111
 
110
112
  function addSub(state: ConnectionState, name: string, sub: string): boolean {
@@ -152,6 +154,13 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
152
154
  return fail(resolution.message)
153
155
  }
154
156
  const { entry } = resolution
157
+ /* A client reusing a live `sub` id rebinds it: drop the prior binding first (and
158
+ unsubscribe its topic if that was the last local sub) so the old socket's Set
159
+ doesn't retain the id forever, leaking the topic subscription. */
160
+ const rebound = removeSub(state, frame.sub)
161
+ if (rebound) {
162
+ ws.unsubscribe(`socket:${rebound}`)
163
+ }
155
164
  const isFirstLocalSub = addSub(state, frame.socket, frame.sub)
156
165
  if (isFirstLocalSub) {
157
166
  ws.subscribe(`socket:${frame.socket}`)
@@ -305,11 +314,18 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
305
314
  const text = typeof data === 'string' ? data : textDecoder.decode(data)
306
315
  let frame: SocketClientFrame
307
316
  try {
308
- frame = JSON.parse(text) as SocketClientFrame
317
+ /* ref-json from abide's own client; a non-abide client sends a plain-JSON
318
+ frame, which decodeRefJson rejects (a frame is always an object, never the
319
+ `[root, slots]` envelope) — fall back to JSON.parse so raw WS clients work. */
320
+ try {
321
+ frame = decodeRefJson(text) as SocketClientFrame
322
+ } catch {
323
+ frame = JSON.parse(text) as SocketClientFrame
324
+ }
309
325
  } catch {
310
326
  return
311
327
  }
312
- // JSON.parse succeeds on `null`/`42`/`true`; ignore anything that isn't a frame object.
328
+ // both codecs yield a value for `null`/`42`/`true` too; ignore anything that isn't a frame object.
313
329
  if (typeof frame !== 'object' || frame === null) {
314
330
  return
315
331
  }
@@ -1,4 +1,5 @@
1
1
  import { createPushIterator } from '../../shared/createPushIterator.ts'
2
+ import { encodeRefJson } from '../../shared/encodeRefJson.ts'
2
3
  import { resolveClientFlags } from '../../shared/resolveClientFlags.ts'
3
4
  import { socketTapSlot } from '../../shared/socketTapSlot.ts'
4
5
  import type { TailHooks } from '../../shared/types/TailHooks.ts'
@@ -123,7 +124,8 @@ export function defineSocket<T>(name: string, opts: SocketOptions = {}): Socket<
123
124
  silently drift from this construction site (the dispatcher's `send` is already
124
125
  typed; this was the last `msg` frame built through an unchecked JSON.stringify). */
125
126
  const frame: SocketServerFrame = { type: 'msg', socket: name, message: validated }
126
- server.publish(topic, JSON.stringify(frame))
127
+ // ref-json (matching the dispatcher + client) so a message graph with cycles/shared refs fans out intact.
128
+ server.publish(topic, encodeRefJson(frame))
127
129
  }
128
130
  }
129
131
 
@@ -0,0 +1,9 @@
1
+ /*
2
+ Request header the RPC client stamps (value '1') when an `application/json` body is
3
+ ref-json-encoded (encodeRefJson), so the server decodes with the matching codec.
4
+ Its presence is the unambiguous discriminator: ref-json's `[rootValue, slots]`
5
+ envelope collides with a legitimate plain-JSON 2-element array body, so the shape
6
+ alone can't be trusted. Absence means an ordinary JSON body from a non-abide client
7
+ (curl, an OpenAPI-generated SDK, a webhook) — parseArgs reads it with plain JSON.parse.
8
+ */
9
+ export const REF_JSON_HEADER = 'abide-ref-json'
@@ -0,0 +1,31 @@
1
+ /*
2
+ Value tags for the ref-json codec (encodeRefJson / decodeRefJson). The output is
3
+ a `[rootValue, slots]` pair: `slots` holds only the hoisted CONTAINERS (objects,
4
+ arrays, Maps, Sets) addressed by index; every other value is encoded INLINE at its
5
+ position — a bare JSON primitive, or one of these tagged arrays. Primitives never
6
+ get their own slot, so a primitive-heavy payload stays close to plain-JSON size and
7
+ speed. A tag is always the first element of a JSON array at a value position; bare
8
+ JSON values are literal primitives, and plain objects only ever appear AS slots
9
+ (hoisted), so user data — even an object keyed `~r` or an array shaped like a tag —
10
+ can't collide with these. Shared between encoder and decoder so the tokens can't drift.
11
+ */
12
+ export const REF_JSON_TAGS = {
13
+ // ['~r', slotIndex] — reference to a hoisted container; breaks cycles, preserves shared identity.
14
+ REF: '~r',
15
+ // ['~a', ...values]
16
+ ARRAY: '~a',
17
+ // ['~m', [[keyValue, valValue], …]]
18
+ MAP: '~m',
19
+ // ['~s', [value, …]]
20
+ SET: '~s',
21
+ // ['~d', epochMs]
22
+ DATE: '~d',
23
+ // ['~x', source, flags]
24
+ REGEXP: '~x',
25
+ // ['~g', decimalString] — BigInt can't go through JSON natively.
26
+ BIGINT: '~g',
27
+ // ['~u'] — undefined (and functions/symbols, folded to it).
28
+ UNDEFINED: '~u',
29
+ // ['~n', token] — the numbers JSON flattens to null: 'NaN' | 'Infinity' | '-Infinity' | '-0'.
30
+ NUMBER: '~n',
31
+ } as const
@@ -1,5 +1,7 @@
1
1
  import { carriesBodyArgs } from './carriesBodyArgs.ts'
2
+ import { encodeRefJson } from './encodeRefJson.ts'
2
3
  import { queryStringFromArgs } from './queryStringFromArgs.ts'
4
+ import { REF_JSON_HEADER } from './REF_JSON_HEADER.ts'
3
5
  import type { HttpVerb } from './types/HttpVerb.ts'
4
6
 
5
7
  /*
@@ -49,11 +51,16 @@ export function buildRpcRequest({
49
51
  body: args,
50
52
  })
51
53
  }
54
+ /* ref-json (still valid JSON, so the content-type holds) so an arg graph with
55
+ cycles or shared back-references reaches the handler instead of throwing here.
56
+ The REF_JSON_HEADER flags the encoding so parseArgs decodes it with the same
57
+ codec; a non-abide client omits it and parseArgs reads plain JSON.parse. */
52
58
  requestHeaders.set('content-type', 'application/json')
59
+ requestHeaders.set(REF_JSON_HEADER, '1')
53
60
  return new Request(new URL(url, baseUrl).href, {
54
61
  method,
55
62
  headers: requestHeaders,
56
- body: JSON.stringify(args),
63
+ body: encodeRefJson(args),
57
64
  })
58
65
  }
59
66
 
@@ -8,6 +8,7 @@ import type { ClientFlags } from './types/ClientFlags.ts'
8
8
  import type { HttpVerb } from './types/HttpVerb.ts'
9
9
  import type { RawRemoteFunction } from './types/RawRemoteFunction.ts'
10
10
  import type { RemoteFunction } from './types/RemoteFunction.ts'
11
+ import type { RpcOptions } from './types/RpcOptions.ts'
11
12
  import type { Subscribable } from './types/Subscribable.ts'
12
13
 
13
14
  /*
@@ -39,8 +40,12 @@ export function createRemoteFunction<Args, Return>(opts: {
39
40
  clients: ClientFlags
40
41
  /* Server-side only: exempts a mutating verb from the router's same-origin CSRF gate. */
41
42
  crossOrigin?: boolean
42
- buildRequest: (args: Args | undefined) => Request
43
- invoke: (args: Args | undefined, getRequest: () => Request) => Promise<Response>
43
+ buildRequest: (args: Args | undefined, opts?: RpcOptions) => Request
44
+ invoke: (
45
+ args: Args | undefined,
46
+ getRequest: () => Request,
47
+ opts?: RpcOptions,
48
+ ) => Promise<Response>
44
49
  parseArgsForFetch?: (request: Request) => Promise<Args | undefined>
45
50
  }): RemoteFunction<Args, Return> {
46
51
  const { method, url, clients, crossOrigin, buildRequest, invoke, parseArgsForFetch } = opts
@@ -52,15 +57,19 @@ export function createRemoteFunction<Args, Return>(opts: {
52
57
  short-circuits to the prebuilt one — and caches the result so the
53
58
  client invoke + the cache meta reader share a single Request.
54
59
  */
55
- function dispatch(args: Args | undefined, prebuilt?: Request): Promise<Response> {
60
+ function dispatch(
61
+ args: Args | undefined,
62
+ opts?: RpcOptions,
63
+ prebuilt?: Request,
64
+ ): Promise<Response> {
56
65
  let cached = prebuilt
57
66
  function getRequest(): Request {
58
67
  if (cached === undefined) {
59
- cached = buildRequest(args)
68
+ cached = buildRequest(args, opts)
60
69
  }
61
70
  return cached
62
71
  }
63
- const promise = invoke(args, getRequest)
72
+ const promise = invoke(args, getRequest, opts)
64
73
  recordRemoteMeta(promise, getRequest)
65
74
  return promise
66
75
  }
@@ -72,8 +81,8 @@ export function createRemoteFunction<Args, Return>(opts: {
72
81
  contained type lie — buildRpcRequest's `instanceof FormData` branch handles
73
82
  it at runtime.
74
83
  */
75
- function rawCall(args: Args | FormData): Promise<Response> {
76
- return dispatch(args as Args)
84
+ function rawCall(args: Args | FormData, opts?: RpcOptions): Promise<Response> {
85
+ return dispatch(args as Args, opts)
77
86
  }
78
87
  rawCall.method = method
79
88
  rawCall.url = url
@@ -81,8 +90,8 @@ export function createRemoteFunction<Args, Return>(opts: {
81
90
  Object.defineProperty(rawCall, REMOTE_FUNCTION, { value: true })
82
91
  const raw = rawCall as RawRemoteFunction<Args>
83
92
 
84
- function callable(args: Args | FormData): Promise<Return> {
85
- return raw(args).then(decodeResponse) as Promise<Return>
93
+ function callable(args: Args | FormData, opts?: RpcOptions): Promise<Return> {
94
+ return raw(args, opts).then(decodeResponse) as Promise<Return>
86
95
  }
87
96
  callable.method = method
88
97
  callable.url = url
@@ -113,10 +122,10 @@ export function createRemoteFunction<Args, Return>(opts: {
113
122
  }
114
123
  throw error
115
124
  }
116
- return dispatch(args, request)
125
+ return dispatch(args, undefined, request)
117
126
  }
118
127
  : (request: Request): Promise<Response> => {
119
- return dispatch(undefined, request)
128
+ return dispatch(undefined, undefined, request)
120
129
  }
121
130
  return callable as RemoteFunction<Args, Return>
122
131
  }
@@ -0,0 +1,110 @@
1
+ /*
2
+ Inverse of encodeRefJson — rebuild a value graph (cycles, shared references, and the
3
+ JSON-hostile types) from a `[rootValue, slots]` ref-json string. Two passes are
4
+ required: pass one allocates every hoisted container as an empty shell, pass two fills
5
+ them. The shell must exist before its contents are filled so a back-reference to an
6
+ ancestor resolves to the already-allocated object — which is what reconnects a cycle.
7
+ Inline primitives need no shell; they decode directly where they sit.
8
+ */
9
+ import { REF_JSON_TAGS } from './REF_JSON_TAGS'
10
+
11
+ export function decodeRefJson(text: string): unknown {
12
+ const parsed = JSON.parse(text)
13
+ if (!Array.isArray(parsed) || parsed.length !== 2 || !Array.isArray(parsed[1])) {
14
+ // encodeRefJson always emits a `[rootValue, slots]` pair; anything else isn't our format.
15
+ throw new TypeError('decodeRefJson: not a ref-json payload')
16
+ }
17
+ const [rootValue, slots] = parsed as [unknown, unknown[]]
18
+ // Pass 1: an empty container shell per slot. Pass 2 fills them so a ref to an ancestor finds its shell.
19
+ const shells = slots.map(buildShell)
20
+ slots.forEach((slot, index) => {
21
+ fillShell(slot, shells[index], shells)
22
+ })
23
+ return resolveValue(rootValue, shells)
24
+ }
25
+
26
+ // Empty container matching a slot's kind. Every slot is a container (only containers are hoisted).
27
+ function buildShell(slot: unknown): unknown {
28
+ if (Array.isArray(slot)) {
29
+ if (slot[0] === REF_JSON_TAGS.ARRAY) {
30
+ return []
31
+ }
32
+ if (slot[0] === REF_JSON_TAGS.MAP) {
33
+ return new Map()
34
+ }
35
+ if (slot[0] === REF_JSON_TAGS.SET) {
36
+ return new Set()
37
+ }
38
+ }
39
+ return {}
40
+ }
41
+
42
+ // Decode an inline value: bare primitive (itself), a leaf-special, or a reference to a built shell.
43
+ function resolveValue(value: unknown, shells: unknown[]): unknown {
44
+ if (!Array.isArray(value)) {
45
+ return value
46
+ }
47
+ switch (value[0]) {
48
+ case REF_JSON_TAGS.REF:
49
+ return shells[value[1] as number]
50
+ case REF_JSON_TAGS.UNDEFINED:
51
+ return undefined
52
+ case REF_JSON_TAGS.DATE:
53
+ return new Date(value[1] as number)
54
+ case REF_JSON_TAGS.REGEXP:
55
+ return new RegExp(value[1] as string, value[2] as string)
56
+ case REF_JSON_TAGS.BIGINT:
57
+ return BigInt(value[1] as string)
58
+ case REF_JSON_TAGS.NUMBER:
59
+ return decodeNumberToken(value[1] as string)
60
+ default:
61
+ throw new TypeError(`decodeRefJson: unknown value tag ${String(value[0])}`)
62
+ }
63
+ }
64
+
65
+ // Fill a container shell from its slot, resolving each inline child.
66
+ function fillShell(slot: unknown, shell: unknown, shells: unknown[]): void {
67
+ if (!Array.isArray(slot)) {
68
+ const target = shell as Record<string, unknown>
69
+ const record = slot as Record<string, unknown>
70
+ for (const key of Object.keys(record)) {
71
+ target[key] = resolveValue(record[key], shells)
72
+ }
73
+ return
74
+ }
75
+ if (slot[0] === REF_JSON_TAGS.ARRAY) {
76
+ const target = shell as unknown[]
77
+ // Loop (not push(...spread)) so a huge array can't blow the call-stack arg limit.
78
+ for (let index = 1; index < slot.length; index++) {
79
+ target.push(resolveValue(slot[index], shells))
80
+ }
81
+ return
82
+ }
83
+ if (slot[0] === REF_JSON_TAGS.MAP) {
84
+ const target = shell as Map<unknown, unknown>
85
+ for (const [key, val] of slot[1] as [unknown, unknown][]) {
86
+ target.set(resolveValue(key, shells), resolveValue(val, shells))
87
+ }
88
+ return
89
+ }
90
+ if (slot[0] === REF_JSON_TAGS.SET) {
91
+ const target = shell as Set<unknown>
92
+ for (const member of slot[1] as unknown[]) {
93
+ target.add(resolveValue(member, shells))
94
+ }
95
+ }
96
+ }
97
+
98
+ // Reverse of encodeRefJson's numberToken.
99
+ function decodeNumberToken(token: string): number {
100
+ if (token === 'NaN') {
101
+ return Number.NaN
102
+ }
103
+ if (token === 'Infinity') {
104
+ return Number.POSITIVE_INFINITY
105
+ }
106
+ if (token === '-Infinity') {
107
+ return Number.NEGATIVE_INFINITY
108
+ }
109
+ return -0
110
+ }
@@ -0,0 +1,106 @@
1
+ /*
2
+ Serialise an arbitrary value graph to a JSON string that survives cycles and shared
3
+ references — the gap JSON.stringify can't cross. Only mutable CONTAINERS (objects,
4
+ arrays, Maps, Sets) are hoisted into a flat `slots` array and referenced by index, so
5
+ a cycle becomes a back-reference instead of infinite recursion, and two paths to the
6
+ same container decode back to the same object. Primitives are encoded INLINE at their
7
+ position (not hoisted), so a primitive-heavy payload stays near plain-JSON size and
8
+ speed. Beyond plain objects/arrays it round-trips the types JSON drops or coerces:
9
+ undefined, bigint, NaN, ±Infinity, -0, Date, RegExp, Map and Set. Functions and
10
+ symbols can't be serialised and encode as undefined, matching JSON.stringify. Output
11
+ shape is `[rootValue, slots]`. Decode with decodeRefJson. Not streaming — the whole
12
+ graph is walked up front.
13
+ */
14
+ import { REF_JSON_TAGS } from './REF_JSON_TAGS'
15
+
16
+ export function encodeRefJson(value: unknown): string {
17
+ // Hoisted containers only; slots[i] is addressed by ['~r', i]. Primitives stay inline.
18
+ const slots: unknown[] = []
19
+ // Container identity → slot index: a revisited container emits a back-reference instead of recursing.
20
+ const ids = new Map<object, number>()
21
+
22
+ // Hoist a container to a slot (once) and return its index. Reserve the index BEFORE encoding
23
+ // children so a cyclic child resolves back to this slot.
24
+ function intern(container: object): number {
25
+ const seen = ids.get(container)
26
+ if (seen !== undefined) {
27
+ return seen
28
+ }
29
+ const index = slots.length
30
+ slots.push(0)
31
+ ids.set(container, index)
32
+ slots[index] = encodeContainer(container)
33
+ return index
34
+ }
35
+
36
+ // Inline encoding of a value at its position: bare primitive, leaf-special tag, or a container reference.
37
+ function encodeValue(input: unknown): unknown {
38
+ if (input === null) {
39
+ return null
40
+ }
41
+ const type = typeof input
42
+ if (type === 'string' || type === 'boolean') {
43
+ return input
44
+ }
45
+ if (type === 'number') {
46
+ const numeric = input as number
47
+ // -0, NaN, ±Infinity can't round-trip as bare JSON; tag them.
48
+ if (!Number.isFinite(numeric) || Object.is(numeric, -0)) {
49
+ return [REF_JSON_TAGS.NUMBER, numberToken(numeric)]
50
+ }
51
+ return numeric
52
+ }
53
+ if (type === 'bigint') {
54
+ return [REF_JSON_TAGS.BIGINT, (input as bigint).toString()]
55
+ }
56
+ // undefined | function | symbol — not representable; fold to undefined as JSON drops them.
57
+ if (type !== 'object') {
58
+ return [REF_JSON_TAGS.UNDEFINED]
59
+ }
60
+ if (input instanceof Date) {
61
+ return [REF_JSON_TAGS.DATE, input.getTime()]
62
+ }
63
+ if (input instanceof RegExp) {
64
+ return [REF_JSON_TAGS.REGEXP, input.source, input.flags]
65
+ }
66
+ // Mutable container: hoist for identity and reference it — primitives above never reach here.
67
+ return [REF_JSON_TAGS.REF, intern(input as object)]
68
+ }
69
+
70
+ // A hoisted container's slot: a tagged array (or plain object) whose nested values are encoded inline.
71
+ function encodeContainer(container: object): unknown {
72
+ if (Array.isArray(container)) {
73
+ return [REF_JSON_TAGS.ARRAY, ...container.map(encodeValue)]
74
+ }
75
+ if (container instanceof Map) {
76
+ return [
77
+ REF_JSON_TAGS.MAP,
78
+ Array.from(container, ([key, val]) => [encodeValue(key), encodeValue(val)]),
79
+ ]
80
+ }
81
+ if (container instanceof Set) {
82
+ return [REF_JSON_TAGS.SET, Array.from(container, encodeValue)]
83
+ }
84
+ const record = container as Record<string, unknown>
85
+ // Own enumerable keys, built in one loop — ~2× cheaper than Object.fromEntries(keys.map(...))
86
+ // (no intermediate pairs array / closures) on this per-object always-hot path.
87
+ const encoded: Record<string, unknown> = {}
88
+ for (const key of Object.keys(record)) {
89
+ encoded[key] = encodeValue(record[key])
90
+ }
91
+ return encoded
92
+ }
93
+
94
+ return JSON.stringify([encodeValue(value), slots])
95
+ }
96
+
97
+ // Stable token for the numbers JSON can't carry.
98
+ function numberToken(value: number): string {
99
+ if (Object.is(value, -0)) {
100
+ return '-0'
101
+ }
102
+ if (Number.isNaN(value)) {
103
+ return 'NaN'
104
+ }
105
+ return value > 0 ? 'Infinity' : '-Infinity'
106
+ }
@@ -0,0 +1,15 @@
1
+ /*
2
+ HTML-escapes the five HTML-significant characters — the same set the runtime
3
+ `$esc` handles. Isomorphic: shared by the build-time SSR/static-clone generators
4
+ (so server markup and the client clone template can't diverge on escaping) and
5
+ the server runtime's dev error page. Static text reaches the compile callers
6
+ already entity-decoded (see parseTemplate), so escaping round-trips it through
7
+ the HTML parser to the same plain text the client would build directly.
8
+ */
9
+ export function escapeHtml(value: string): string {
10
+ return value.replace(
11
+ /[&<>"']/g,
12
+ (char) =>
13
+ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char] ?? char,
14
+ )
15
+ }
@@ -0,0 +1,47 @@
1
+ import type { SourceMap } from './types/SourceMap.ts'
2
+
3
+ /*
4
+ The path fragment that identifies abide's own framework sources in a source map.
5
+ The mount stack's "wall" — `scope`, `withScope`, `mountRange`, `runNode`,
6
+ `createEffectNode`, … — all live under the package's `src/lib/`; an author's
7
+ `.abide`/`.ts` frames never do. Matched as the contiguous directory + lib path
8
+ (not the npm name `@abide/abide`, which differs from the on-disk dir `abide`), so
9
+ it holds across the monorepo (`packages/abide/src/lib/`) and an install
10
+ (`node_modules/@abide/abide/src/lib/`) alike, while a user app dir like
11
+ `my-abide-app/src/lib/` does not match.
12
+
13
+ DERIVED from THIS module's own location (`…/<pkgDir>/src/lib/shared/…`) rather than
14
+ hardcoded, so it tracks a package-dir rename instead of silently going stale (an
15
+ empty ignore-list = the mount-stack wall reappears, which no test would catch). Falls
16
+ back to the literal if the expected `/src/lib/` layout is ever absent.
17
+ */
18
+ const FRAMEWORK_LIB_PATH = ((): string => {
19
+ const LIB_MARKER = '/src/lib/'
20
+ const here = new URL(import.meta.url).pathname
21
+ const libIndex = here.indexOf(LIB_MARKER)
22
+ if (libIndex === -1) {
23
+ return 'abide/src/lib/'
24
+ }
25
+ const beforeLib = here.slice(0, libIndex)
26
+ const packageDir = beforeLib.slice(beforeLib.lastIndexOf('/') + 1)
27
+ return `${packageDir}${LIB_MARKER}`
28
+ })()
29
+
30
+ /*
31
+ Marks every abide-framework source in a parsed source map as ignore-listed, so a
32
+ debugger collapses framework frames and a stack trace shows only authored ones —
33
+ the fix for the long mount-stack wall, applied without touching the runtime. Sets
34
+ both the standardized `ignoreList` and Chrome's legacy `x_google_ignoreList` for
35
+ the widest debugger support. Mutates and returns the same map.
36
+ */
37
+ export function markFrameworkSourcesIgnored(map: SourceMap): SourceMap {
38
+ const ignored = (map.sources ?? []).reduce<number[]>((indices, source, index) => {
39
+ if (source !== null && source.includes(FRAMEWORK_LIB_PATH)) {
40
+ indices.push(index)
41
+ }
42
+ return indices
43
+ }, [])
44
+ map.ignoreList = ignored
45
+ map.x_google_ignoreList = ignored
46
+ return map
47
+ }
@@ -122,6 +122,11 @@ async function* parseSse<T>(response: Response): AsyncGenerator<T> {
122
122
  if (errorMessage !== undefined) {
123
123
  throw new Error(errorMessage)
124
124
  }
125
+ /* A frame with an empty `data:` value (a keepalive) carries no payload — skip it
126
+ rather than letting JSON.parse('') throw and tear down the whole iteration. */
127
+ if (frame.data === '') {
128
+ continue
129
+ }
125
130
  yield JSON.parse(frame.data) as T
126
131
  }
127
132
  }
@@ -157,7 +162,9 @@ consumer loops can react to mid-stream failure.
157
162
  */
158
163
  async function* parseJsonLines<T>(response: Response): AsyncGenerator<T> {
159
164
  for await (const raw of frameReader(response, '\n')) {
160
- if (raw.length === 0) {
165
+ /* Skip blank lines: a CRLF stream yields a lone `\r` (non-zero length) between
166
+ records, and JSON.parse('\r') would throw and tear down the whole iteration. */
167
+ if (raw.trim().length === 0) {
161
168
  continue
162
169
  }
163
170
  const parsed = JSON.parse(raw)
@@ -1,3 +1,5 @@
1
+ import type { RpcOptions } from './RpcOptions.ts'
2
+
1
3
  /*
2
4
  Call signature shared by RemoteFunction and RawRemoteFunction. The base
3
5
  signature keeps `args` required so a schema'd verb can't silently drop its
@@ -6,7 +8,14 @@ optional-arg signature lets call sites write `fn()` instead of
6
8
  `fn(undefined)`. Intersection rather than a bare conditional so the type
7
9
  stays callable while `Args` is still generic (cache() invokes producers
8
10
  before `Args` resolves). FormData is the multipart upload escape hatch —
9
- see RemoteFunction.
11
+ see RemoteFunction. The optional trailing `opts` carries per-call transport
12
+ options (signal/keepalive/priority/cache/headers); the server ignores them,
13
+ so the callable stays isomorphic.
10
14
  */
11
- export type RemoteCallable<Args, Resolved> = ((args: Args | FormData) => Promise<Resolved>) &
12
- (undefined extends Args ? (args?: Args | FormData) => Promise<Resolved> : unknown)
15
+ export type RemoteCallable<Args, Resolved> = ((
16
+ args: Args | FormData,
17
+ opts?: RpcOptions,
18
+ ) => Promise<Resolved>) &
19
+ (undefined extends Args
20
+ ? (args?: Args | FormData, opts?: RpcOptions) => Promise<Resolved>
21
+ : unknown)
@@ -0,0 +1,22 @@
1
+ /*
2
+ Per-call transport options for a remote function — a curated slice of RequestInit,
3
+ not the whole thing. Only fields the server handler never observes are exposed, so
4
+ the call stays isomorphic (same behaviour both sides) and a caller can't clobber the
5
+ method, body, or framework headers the RPC contract owns.
6
+
7
+ - `signal` merges with the scope abort + client timeout (AbortSignal.any), never
8
+ replacing them; under cache() it's ignored so one reader can't abort a coalesced
9
+ flight the others share.
10
+ - `keepalive` lets a small fire-and-forget write survive page unload (the browser
11
+ caps the total keepalive body at ~64KB).
12
+ - `priority` is a fetch scheduling hint; ignored where unsupported.
13
+ - `cache` is the browser HTTP cache mode (distinct from abide's own cache()).
14
+ - `headers` are MERGED onto the framework headers, which win — a caller adds
15
+ transport metadata (idempotency-key, authorization) but can't overwrite
16
+ traceparent/content-type/offline. Application data still belongs in `args` (the
17
+ only schema-validated channel); headers bypass validation.
18
+ */
19
+ export type RpcOptions = Pick<
20
+ RequestInit,
21
+ 'signal' | 'keepalive' | 'priority' | 'cache' | 'headers'
22
+ >
@@ -0,0 +1,14 @@
1
+ /*
2
+ A source-map JSON object, narrowed to the fields abide reads or writes when
3
+ post-processing emitted maps. `sources` is the list of original files (a null
4
+ entry means an unknown source); `ignoreList` (Source Map v3 / ECMA-426) and its
5
+ legacy Chrome alias `x_google_ignoreList` carry the indices into `sources` a
6
+ debugger should skip. Other standard fields (`version`, `mappings`, …) are passed
7
+ through untouched, so the type stays open.
8
+ */
9
+ export type SourceMap = {
10
+ sources?: (string | null)[]
11
+ ignoreList?: number[]
12
+ x_google_ignoreList?: number[]
13
+ [field: string]: unknown
14
+ }
@@ -3,6 +3,8 @@ import type { SocketClientFrame } from '../server/sockets/types/SocketClientFram
3
3
  import type { SocketServerFrame } from '../server/sockets/types/SocketServerFrame.ts'
4
4
  import { buildSocketOverChannel } from '../shared/buildSocketOverChannel.ts'
5
5
  import { createSocketSubRegistry } from '../shared/createSocketSubRegistry.ts'
6
+ import { decodeRefJson } from '../shared/decodeRefJson.ts'
7
+ import { encodeRefJson } from '../shared/encodeRefJson.ts'
6
8
 
7
9
  /*
8
10
  Test-side substitute for the browser socketChannel: one ws to the booted
@@ -37,7 +39,8 @@ export function createTestSocketChannel(wsUrl: string): {
37
39
  }
38
40
 
39
41
  function send(frame: SocketClientFrame): void {
40
- const message = JSON.stringify(frame)
42
+ // ref-json, matching the browser channel + the server's dispatcher/publish codec.
43
+ const message = encodeRefJson(frame)
41
44
  if (ws.readyState === WebSocket.OPEN) {
42
45
  ws.send(message)
43
46
  return
@@ -53,7 +56,8 @@ export function createTestSocketChannel(wsUrl: string): {
53
56
  ws.addEventListener('message', (event) => {
54
57
  let frame: SocketServerFrame
55
58
  try {
56
- frame = JSON.parse(event.data as string) as SocketServerFrame
59
+ // The server sends ref-json frames (createSocketDispatcher/defineSocket); decode to match.
60
+ frame = decodeRefJson(event.data as string) as SocketServerFrame
57
61
  } catch {
58
62
  return
59
63
  }