@abide/abide 0.39.0 → 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.
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +47 -0
- package/package.json +1 -1
- package/src/lib/server/rpc/parseArgs.ts +10 -1
- package/src/lib/server/rpc/runWithVerbTimeout.ts +7 -9
- package/src/lib/server/rpc/types/VerbHelper.ts +18 -21
- package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +8 -7
- package/src/lib/server/sockets/createSocketDispatcher.ts +12 -3
- package/src/lib/server/sockets/defineSocket.ts +3 -1
- package/src/lib/shared/REF_JSON_HEADER.ts +9 -0
- package/src/lib/shared/REF_JSON_TAGS.ts +31 -0
- package/src/lib/shared/buildRpcRequest.ts +8 -1
- package/src/lib/shared/decodeRefJson.ts +110 -0
- package/src/lib/shared/encodeRefJson.ts +106 -0
- package/src/lib/test/createTestSocketChannel.ts +6 -2
- package/src/lib/ui/compile/compileShadow.ts +14 -7
- package/src/lib/ui/compile/generateBuild.ts +41 -58
- package/src/lib/ui/compile/generateSSR.ts +23 -21
- package/src/lib/ui/compile/isWhitespaceText.ts +11 -0
- package/src/lib/ui/compile/parseTemplate.ts +74 -0
- package/src/lib/ui/compile/renameSignalRefs.ts +12 -0
- package/src/lib/ui/compile/resolveBranches.ts +21 -0
- package/src/lib/ui/compile/types/TemplateNode.ts +12 -2
- package/src/lib/ui/createScope.ts +14 -0
- package/src/lib/ui/dom/appendText.ts +2 -3
- package/src/lib/ui/dom/appendTextAt.ts +2 -3
- package/src/lib/ui/dom/applyResolved.ts +5 -8
- package/src/lib/ui/dom/awaitBlock.ts +14 -1
- package/src/lib/ui/dom/on.ts +7 -0
- package/src/lib/ui/dom/parseRawNodes.ts +17 -0
- package/src/lib/ui/navigate.ts +28 -3
- package/src/lib/ui/renderToStream.ts +17 -9
- package/src/lib/ui/resumeSeedScript.ts +16 -6
- package/src/lib/ui/router.ts +1 -1
- package/src/lib/ui/runtime/RESUME.ts +13 -6
- package/src/lib/ui/runtime/localStoragePersistence.ts +14 -8
- package/src/lib/ui/socketChannel.ts +5 -2
- package/src/lib/ui/tryEncodeResume.ts +20 -0
- package/src/lib/ui/types/Scope.ts +9 -3
package/AGENTS.md
CHANGED
|
@@ -142,7 +142,7 @@ Test suites compile `.abide` and rewrite verbs under `bun test` via `preload = [
|
|
|
142
142
|
|
|
143
143
|
### Reactive state — @documentation reactive-state
|
|
144
144
|
|
|
145
|
-
- `abide/ui/scope` — `scope()`: the sole reactive surface. `.state(initial?, transform?)` (writable cell), `.computed(fn)` (read-only), `.linked(seed, transform?)` (local draft reseeded from upstream). Bare `state`/`computed`/`linked` are a compile error; a writable computed is expressed at the binding (`bind:value={{ get, set }}`). Also carries doc/persistence/undo/broadcast methods.
|
|
145
|
+
- `abide/ui/scope` — `scope()`: the sole reactive surface. `.state(initial?, transform?)` (writable cell), `.computed(fn)` (read-only), `.linked(seed, transform?)` (local draft reseeded from upstream). Bare `state`/`computed`/`linked` are a compile error; a writable computed is expressed at the binding (`bind:value={{ get, set }}`). `.share(key, value)`/`.shared(key)` pass context down the tree (closest-ancestor lookup; the reactive doc itself doesn't inherit, so share a `cell` for reactive context). Also carries doc/persistence/undo/broadcast methods.
|
|
146
146
|
|
|
147
147
|
### Effect — @documentation effect
|
|
148
148
|
|
|
@@ -151,7 +151,7 @@ Test suites compile `.abide` and rewrite verbs under `bun test` via `preload = [
|
|
|
151
151
|
### Tail & navigate — @documentation tail · navigate
|
|
152
152
|
|
|
153
153
|
- `abide/ui/tail` — `tail(subscribable)`: latest frame of a `Socket`/stream (reactive); window form `tail(s, { last })` → array; `tail.error`/`tail.status` probes.
|
|
154
|
-
- `abide/ui/navigate` — `navigate(path,
|
|
154
|
+
- `abide/ui/navigate` — `navigate(path, options?)`: client navigation through the router. `options` is `{ replace?, keepScroll? }` — `replace` swaps the current history entry, `keepScroll` preserves the live scroll offset across an in-page URL swap.
|
|
155
155
|
|
|
156
156
|
### Outbox — @documentation ui
|
|
157
157
|
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,52 @@
|
|
|
1
1
|
# abide
|
|
2
2
|
|
|
3
|
+
## 0.40.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [`05ea7e2`](https://github.com/briancray/abide/commit/05ea7e235b1e7c035d3b4313e12fc83733fa814b) - <template elseif> conditional chains ([`c3237cc`](https://github.com/briancray/abide/commit/c3237ccc0d222c36e8b00ce68595eb92609ef856))
|
|
8
|
+
|
|
9
|
+
- [`15c0ad3`](https://github.com/briancray/abide/commit/15c0ad334f698dfb49a9dcd0e0361925afc4f6c8) - `navigate` takes an options object — `navigate(path, { replace?, keepScroll? })` — replacing the old `navigate(path, replace?)` boolean. **Breaking:** a caller passing a boolean (`navigate(path, true)`) must migrate to `navigate(path, { replace: true })`; a bare `navigate(path)` is unchanged. The new `keepScroll` flag carries the live scroll offset onto the destination entry so an in-page URL swap — e.g. selecting another episode on the same detail page — restores to the current position instead of jumping to top. A push re-buckets the offset under the freshly minted entry id; a `replace` keeps the entry's bucket instead of discarding it.
|
|
10
|
+
|
|
11
|
+
- [`ba56bb2`](https://github.com/briancray/abide/commit/ba56bb28be06fa0a11ab57fb10fa2d6b75ab8656) - Add `share`/`shared` context to `scope()`. The reactive doc does not inherit down the scope tree (each scope owns a separate document), so passing a value from an ancestor to a descendant previously meant threading it through every layer as props. `scope().share(key, value)` now puts a named value on a scope's own side-map, and `scope().shared(key)` reads the closest ancestor (self included) that has the key — an existence-checked, non-tracking upward walk, returning `undefined` when no scope provides it. The value is held by reference and the lookup never subscribes, so reactive context is expressed by sharing a `cell` (or a scope) rather than a plain object — reactivity rides what you share, not the share itself. A shared `undefined` shadows an ancestor (the walk stops on `has`, not truthiness). The shared map is released on `dispose`.
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- [`a5a37f9`](https://github.com/briancray/abide/commit/a5a37f94258a56c4f24eb2b5539a280ce877bdfc) - extract resolveBranches for branch lookup ([`19a28a1`](https://github.com/briancray/abide/commit/19a28a1236e38a78c53a2adff0a981c8ad20b698))
|
|
16
|
+
|
|
17
|
+
- [`a5a37f9`](https://github.com/briancray/abide/commit/a5a37f94258a56c4f24eb2b5539a280ce877bdfc) - sync AGENTS.md surface map for scope share/shared and navigate options ([`3e41255`](https://github.com/briancray/abide/commit/3e4125516164c94cfe9c9e6baa35f9ff9ec9c3e0))
|
|
18
|
+
|
|
19
|
+
- [`a5a37f9`](https://github.com/briancray/abide/commit/a5a37f94258a56c4f24eb2b5539a280ce877bdfc) - use Promise.withResolvers in runWithVerbTimeout ([`504bdca`](https://github.com/briancray/abide/commit/504bdcac285cfd3762c8390fd1b6d640e62029bc))
|
|
20
|
+
|
|
21
|
+
- [`a5a37f9`](https://github.com/briancray/abide/commit/a5a37f94258a56c4f24eb2b5539a280ce877bdfc) - extract VerbBaseOpts for shared verb options ([`d7414ca`](https://github.com/briancray/abide/commit/d7414cab7a557b54395321026d818b9fba82ba34))
|
|
22
|
+
|
|
23
|
+
- [`ccc7f87`](https://github.com/briancray/abide/commit/ccc7f879d0506af084335785026200aeb38fdde4) - `on()` no longer attaches a non-function event handler. A component that omits an optional `on*` prop forwards it as `undefined`, yet the compiler still emits the `on()` call — so the listener fired `undefined(event)` and threw "handler is not a function" on every matching event (a keystroke or input on a search box, a click, …). `on()` now skips attaching when the handler is not a function, so an omitted optional handler is a no-op rather than a per-event crash.
|
|
24
|
+
|
|
25
|
+
- [`c29dcd2`](https://github.com/briancray/abide/commit/c29dcd2796b5caa3ee41857aa612322b1e4220fa) - Parse `html`-branded raw markup in the parent's namespace. The client-side raw-HTML
|
|
26
|
+
binding parsed markup by setting `innerHTML` on a throwaway `<div>`, so the fragment
|
|
27
|
+
parser always used the HTML namespace — markup bound inside an `<svg>` produced
|
|
28
|
+
HTML-namespaced `<path>`/`<circle>` elements that exist in the DOM but never render.
|
|
29
|
+
This only surfaced on client-created or re-rendered nodes; the initial SSR/hydration
|
|
30
|
+
path adopts the server markup verbatim and kept the correct namespace, so an icon
|
|
31
|
+
component (`{html(svgInnerMarkup)}` inside `<svg>`) rendered on first paint but
|
|
32
|
+
vanished the moment its subtree was rebuilt on the client (e.g. a live cache delta).
|
|
33
|
+
A new `parseRawNodes` helper picks an SVG holder when the parent is SVG-namespaced,
|
|
34
|
+
shared by both `appendText` and `appendTextAt`.
|
|
35
|
+
|
|
36
|
+
- [`9a74da6`](https://github.com/briancray/abide/commit/9a74da67c1f65cdb37e7a47af2e7b104cfe7620f) - Stop the signal-ref rewrite from descending into type space. `renameSignalRefs` rewrote every value-position identifier matching a signal name, but never skipped TypeScript type aliases, interfaces, or type annotations — so a component that destructures a prop (`const { option, ...rest } = props<Props>()`) and names that prop in its own type (`type Props = { option?: (value: T) => unknown; … }`) had the type member rewritten into a call (`option(): …`). This only surfaced once a `...rest` binding forced the named prop type to be emitted into the build (a no-rest `props<{…}>()` strips its type argument); with simple property types the mangle landed as accidentally-valid method-signature syntax, but optional/function/union members (`option?: (v) => unknown`, `value?: T | T[] | string`) became invalid TS and failed the page build. The visitor now leaves type aliases, interfaces, and any type node untouched — type space never holds a runtime read, and types erase at build regardless.
|
|
37
|
+
|
|
38
|
+
- [`92597f0`](https://github.com/briancray/abide/commit/92597f0cbabc1e44449c0f0f812048481c38c579) - RPC bodies and socket frames now ship as ref-json (cycles, shared references, and
|
|
39
|
+
the types JSON drops/coerces — `undefined`, `bigint`, `Date`, `Map`, `Set`, `NaN`,
|
|
40
|
+
`±Infinity`, `-0` — survive the wire), and the server still accepts ordinary JSON from
|
|
41
|
+
non-abide clients. abide's own client flags a ref-json body with the `abide-ref-json`
|
|
42
|
+
header so the server decodes it with the matching codec; a request without the header
|
|
43
|
+
(curl, an OpenAPI-generated SDK, a webhook) is read with plain `JSON.parse`, so the
|
|
44
|
+
documented HTTP/OpenAPI body contract keeps working. The header is the discriminator
|
|
45
|
+
rather than the payload shape because ref-json's `[root, slots]` envelope is ambiguous
|
|
46
|
+
with a legitimate plain-JSON two-element array body. Socket frames need no header — a
|
|
47
|
+
frame is always an object, never the envelope array, so a plain-JSON frame falls back
|
|
48
|
+
to `JSON.parse` unambiguously.
|
|
49
|
+
|
|
3
50
|
## 0.39.0
|
|
4
51
|
|
|
5
52
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { carriesBodyArgs } from '../../shared/carriesBodyArgs.ts'
|
|
2
2
|
import { contentTypeOf } from '../../shared/contentTypeOf.ts'
|
|
3
|
+
import { decodeRefJson } from '../../shared/decodeRefJson.ts'
|
|
3
4
|
import { HttpError } from '../../shared/HttpError.ts'
|
|
5
|
+
import { REF_JSON_HEADER } from '../../shared/REF_JSON_HEADER.ts'
|
|
4
6
|
import type { HttpVerb } from '../../shared/types/HttpVerb.ts'
|
|
5
7
|
import { error } from '../error.ts'
|
|
6
8
|
import { requestContext } from '../runtime/requestContext.ts'
|
|
@@ -92,7 +94,14 @@ export async function parseArgs(
|
|
|
92
94
|
if (contentType.includes('application/json')) {
|
|
93
95
|
const text = await bounded.text()
|
|
94
96
|
if (text !== '') {
|
|
95
|
-
|
|
97
|
+
/* abide's own client flags ref-json with REF_JSON_HEADER (restores
|
|
98
|
+
cycles/shared refs JSON can't carry); a non-abide client (curl, an
|
|
99
|
+
OpenAPI SDK) omits it and sends ordinary JSON — read with plain
|
|
100
|
+
JSON.parse, since the ref-json envelope is ambiguous with a 2-element
|
|
101
|
+
array body. */
|
|
102
|
+
body = bounded.headers.has(REF_JSON_HEADER)
|
|
103
|
+
? decodeRefJson(text)
|
|
104
|
+
: JSON.parse(text)
|
|
96
105
|
}
|
|
97
106
|
} else if (
|
|
98
107
|
contentType.includes('application/x-www-form-urlencoded') ||
|
|
@@ -19,18 +19,16 @@ export function runWithVerbTimeout(
|
|
|
19
19
|
ms: number,
|
|
20
20
|
onTimeout: () => void,
|
|
21
21
|
): Promise<Response> {
|
|
22
|
-
let timer: ReturnType<typeof setTimeout> | undefined
|
|
23
22
|
let timedOut = false
|
|
24
|
-
const deadline =
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
})
|
|
23
|
+
const deadline = Promise.withResolvers<Response>()
|
|
24
|
+
const timer = setTimeout(() => {
|
|
25
|
+
timedOut = true
|
|
26
|
+
onTimeout()
|
|
27
|
+
deadline.resolve(error(504, 'handler timeout'))
|
|
28
|
+
}, ms)
|
|
31
29
|
return (async () => {
|
|
32
30
|
try {
|
|
33
|
-
return await Promise.race([work, deadline])
|
|
31
|
+
return await Promise.race([work, deadline.promise])
|
|
34
32
|
} finally {
|
|
35
33
|
clearTimeout(timer)
|
|
36
34
|
if (timedOut) {
|
|
@@ -3,6 +3,21 @@ import type { RemoteFunction } from '../../../shared/types/RemoteFunction.ts'
|
|
|
3
3
|
import type { StandardSchemaV1 } from '../../../shared/types/StandardSchemaV1.ts'
|
|
4
4
|
import type { RemoteHandler } from './RemoteHandler.ts'
|
|
5
5
|
|
|
6
|
+
/*
|
|
7
|
+
Options every verb overload accepts: the OpenAPI 200 `outputSchema`, the
|
|
8
|
+
`clients` surface flags, the same-origin CSRF exemption (`crossOrigin`), the
|
|
9
|
+
pre-parse body-byte ceiling (`maxBodySize`), and the per-surface handler
|
|
10
|
+
`timeout` (ms). The schema-bearing overloads intersect this with their own
|
|
11
|
+
`inputSchema`/`filesSchema` members.
|
|
12
|
+
*/
|
|
13
|
+
type VerbBaseOpts = {
|
|
14
|
+
outputSchema?: StandardSchemaV1
|
|
15
|
+
clients?: Partial<ClientFlags>
|
|
16
|
+
crossOrigin?: boolean
|
|
17
|
+
maxBodySize?: number
|
|
18
|
+
timeout?: number
|
|
19
|
+
}
|
|
20
|
+
|
|
6
21
|
/*
|
|
7
22
|
Shared signature for every verb helper (GET / POST / …). Three overloads:
|
|
8
23
|
|
|
@@ -50,36 +65,18 @@ export type VerbHelper = {
|
|
|
50
65
|
StandardSchemaV1.InferOutput<InputSchema> & StandardSchemaV1.InferOutput<FilesSchema>,
|
|
51
66
|
Return
|
|
52
67
|
>,
|
|
53
|
-
opts: {
|
|
68
|
+
opts: VerbBaseOpts & {
|
|
54
69
|
inputSchema: InputSchema
|
|
55
70
|
filesSchema: FilesSchema
|
|
56
|
-
outputSchema?: StandardSchemaV1
|
|
57
|
-
clients?: Partial<ClientFlags>
|
|
58
|
-
crossOrigin?: boolean
|
|
59
|
-
maxBodySize?: number
|
|
60
|
-
timeout?: number
|
|
61
71
|
},
|
|
62
72
|
): RemoteFunction<StandardSchemaV1.InferInput<InputSchema>, Return>
|
|
63
73
|
<Return = unknown, InputSchema extends StandardSchemaV1 = StandardSchemaV1>(
|
|
64
74
|
fn: RemoteHandler<StandardSchemaV1.InferOutput<InputSchema>, Return>,
|
|
65
|
-
opts: {
|
|
66
|
-
inputSchema: InputSchema
|
|
67
|
-
outputSchema?: StandardSchemaV1
|
|
68
|
-
clients?: Partial<ClientFlags>
|
|
69
|
-
crossOrigin?: boolean
|
|
70
|
-
maxBodySize?: number
|
|
71
|
-
timeout?: number
|
|
72
|
-
},
|
|
75
|
+
opts: VerbBaseOpts & { inputSchema: InputSchema },
|
|
73
76
|
): RemoteFunction<StandardSchemaV1.InferInput<InputSchema>, Return>
|
|
74
77
|
<Args = undefined, Return = unknown>(
|
|
75
78
|
fn: RemoteHandler<Args, Return>,
|
|
76
|
-
opts:
|
|
77
|
-
outputSchema?: StandardSchemaV1
|
|
78
|
-
clients?: Partial<ClientFlags>
|
|
79
|
-
crossOrigin?: boolean
|
|
80
|
-
maxBodySize?: number
|
|
81
|
-
timeout?: number
|
|
82
|
-
},
|
|
79
|
+
opts: VerbBaseOpts,
|
|
83
80
|
): RemoteFunction<Args, Return>
|
|
84
81
|
<Args = undefined, Return = unknown>(
|
|
85
82
|
fn: RemoteHandler<Args, Return>,
|
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
/*
|
|
2
2
|
The tiny inline script the abide-ui SSR stream ships in <head>. For each streamed
|
|
3
3
|
`<abide-resolve data-id>` frame it reads the leading `<script type=application/json>`,
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
the
|
|
9
|
-
|
|
4
|
+
stores its raw ref-json text into `window.__abideResume` (the resume manifest hydration
|
|
5
|
+
reads, decoding each entry at read time) and swaps the resolved markup into the matching
|
|
6
|
+
`<!--abide:await:ID-->…<!--/abide:await:ID-->` boundary — so the pending shell paints
|
|
7
|
+
instantly and each value lands as it arrives, before the client bundle even loads. It
|
|
8
|
+
stores the text undecoded precisely because it's vanilla and self-contained — no framework
|
|
9
|
+
runtime, hence no ref-json decoder; the bundle decodes on read. Minified to one line so it
|
|
10
|
+
inlines cheaply ahead of the document body.
|
|
10
11
|
*/
|
|
11
12
|
export const SSR_SWAP_SCRIPT =
|
|
12
13
|
"function __abideSwap(){var f=document.querySelector('abide-resolve');while(f){" +
|
|
13
14
|
"var id=f.getAttribute('data-id'),p=f.firstChild,w=document.createTreeWalker(document.body,NodeFilter.SHOW_COMMENT),o=null,c;" +
|
|
14
|
-
"if(p&&p.nodeName==='SCRIPT'){
|
|
15
|
+
"if(p&&p.nodeName==='SCRIPT'){(window.__abideResume=window.__abideResume||{})[id]=p.textContent||'';p.remove();}" +
|
|
15
16
|
"while((c=w.nextNode())){if(c.data==='abide:await:'+id){o=c;break;}}" +
|
|
16
17
|
"if(o){var n=o.nextSibling;while(n&&!(n.nodeType===8&&n.data==='/abide:await:'+id)){var x=n.nextSibling;n.remove();n=x;}" +
|
|
17
18
|
"while(f.firstChild){o.parentNode.insertBefore(f.firstChild,n);}}f.remove();f=document.querySelector('abide-resolve');}}"
|
|
@@ -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(
|
|
109
|
+
ws.send(encodeRefJson(frame))
|
|
108
110
|
}
|
|
109
111
|
|
|
110
112
|
function addSub(state: ConnectionState, name: string, sub: string): boolean {
|
|
@@ -312,11 +314,18 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
|
|
|
312
314
|
const text = typeof data === 'string' ? data : textDecoder.decode(data)
|
|
313
315
|
let frame: SocketClientFrame
|
|
314
316
|
try {
|
|
315
|
-
|
|
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
|
+
}
|
|
316
325
|
} catch {
|
|
317
326
|
return
|
|
318
327
|
}
|
|
319
|
-
//
|
|
328
|
+
// both codecs yield a value for `null`/`42`/`true` too; ignore anything that isn't a frame object.
|
|
320
329
|
if (typeof frame !== 'object' || frame === null) {
|
|
321
330
|
return
|
|
322
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
|
-
|
|
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:
|
|
63
|
+
body: encodeRefJson(args),
|
|
57
64
|
})
|
|
58
65
|
}
|
|
59
66
|
|
|
@@ -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
|
+
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -381,19 +381,26 @@ function emitNode(node: TemplateNode, builder: Builder): void {
|
|
|
381
381
|
narrowing — emitting it inside the `if` block (as a plain child) instead
|
|
382
382
|
gave it the positive narrowing, so a literal-union compare read as a
|
|
383
383
|
"no overlap" and a typeof-narrowed branch saw the wrong member. */
|
|
384
|
-
const
|
|
385
|
-
(child): child is Extract<TemplateNode, { kind: 'case' }> =>
|
|
386
|
-
child.kind === 'case' && child.match === undefined,
|
|
384
|
+
const branches = node.children.filter(
|
|
385
|
+
(child): child is Extract<TemplateNode, { kind: 'case' }> => child.kind === 'case',
|
|
387
386
|
)
|
|
388
|
-
const thenChildren = node.children.filter((child) => child !==
|
|
387
|
+
const thenChildren = node.children.filter((child) => child.kind !== 'case')
|
|
389
388
|
builder.raw('if ')
|
|
390
389
|
builder.expr(node.condition, node.loc)
|
|
391
390
|
builder.raw(' {\n')
|
|
392
391
|
emitNodes(thenChildren, builder)
|
|
393
392
|
builder.raw('}')
|
|
394
|
-
if
|
|
395
|
-
|
|
396
|
-
|
|
393
|
+
/* `elseif` → a real `else if` so its body inherits the prior conditions' negative
|
|
394
|
+
narrowing plus its own positive; `else` → the trailing block. */
|
|
395
|
+
for (const branch of branches) {
|
|
396
|
+
if (branch.condition !== undefined) {
|
|
397
|
+
builder.raw(' else if ')
|
|
398
|
+
builder.expr(branch.condition, branch.loc)
|
|
399
|
+
builder.raw(' {\n')
|
|
400
|
+
} else {
|
|
401
|
+
builder.raw(' else {\n')
|
|
402
|
+
}
|
|
403
|
+
emitNodes(branch.children, builder)
|
|
397
404
|
builder.raw('}')
|
|
398
405
|
}
|
|
399
406
|
builder.raw('\n')
|