@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.
- package/AGENTS.md +5 -5
- package/CHANGELOG.md +63 -0
- package/package.json +9 -1
- package/src/build.ts +14 -0
- package/src/lib/bundle/exitWithParent.ts +4 -2
- package/src/lib/server/rpc/parseArgs.ts +10 -1
- package/src/lib/server/rpc/readBodyWithinLimit.ts +3 -0
- 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/runtime/createAppAssetServer.ts +37 -11
- package/src/lib/server/runtime/createPublicAssetServer.ts +14 -5
- package/src/lib/server/runtime/createUiPageRenderer.ts +53 -42
- package/src/lib/server/runtime/internalErrorResponse.ts +5 -2
- package/src/lib/server/runtime/snapshotEntryFromCache.ts +4 -1
- package/src/lib/server/sockets/createSocketDispatcher.ts +19 -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/createRemoteFunction.ts +20 -11
- package/src/lib/shared/decodeRefJson.ts +110 -0
- package/src/lib/shared/encodeRefJson.ts +106 -0
- package/src/lib/shared/escapeHtml.ts +15 -0
- package/src/lib/shared/markFrameworkSourcesIgnored.ts +47 -0
- package/src/lib/shared/streamResponse.ts +8 -1
- package/src/lib/shared/types/RemoteCallable.ts +12 -3
- package/src/lib/shared/types/RpcOptions.ts +22 -0
- package/src/lib/shared/types/SourceMap.ts +14 -0
- package/src/lib/test/createTestSocketChannel.ts +6 -2
- package/src/lib/ui/compile/SSR_ESCAPE.ts +13 -3
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +5 -0
- package/src/lib/ui/compile/compileModule.ts +5 -2
- package/src/lib/ui/compile/compileSSR.ts +32 -9
- package/src/lib/ui/compile/compileShadow.ts +25 -10
- package/src/lib/ui/compile/composeProps.ts +53 -0
- package/src/lib/ui/compile/desugarSignals.ts +45 -17
- package/src/lib/ui/compile/generateBuild.ts +87 -76
- package/src/lib/ui/compile/generateSSR.ts +217 -71
- package/src/lib/ui/compile/isWhitespaceText.ts +11 -0
- package/src/lib/ui/compile/lowerDocAccess.ts +53 -16
- package/src/lib/ui/compile/parseTemplate.ts +118 -1
- package/src/lib/ui/compile/renameSignalRefs.ts +12 -0
- package/src/lib/ui/compile/resolveBranches.ts +21 -0
- package/src/lib/ui/compile/spreadExcludedNames.ts +27 -0
- package/src/lib/ui/compile/staticAttr.ts +1 -1
- package/src/lib/ui/compile/staticTextPart.ts +1 -1
- package/src/lib/ui/compile/types/TemplateAttr.ts +4 -1
- package/src/lib/ui/compile/types/TemplateNode.ts +15 -3
- 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/mergeProps.ts +32 -0
- package/src/lib/ui/dom/on.ts +7 -0
- package/src/lib/ui/dom/parseRawNodes.ts +17 -0
- package/src/lib/ui/dom/readCall.ts +27 -0
- package/src/lib/ui/dom/restProps.ts +32 -0
- package/src/lib/ui/dom/spreadAttrs.ts +34 -0
- package/src/lib/ui/dom/spreadProps.ts +32 -0
- package/src/lib/ui/installHotBridge.ts +10 -0
- package/src/lib/ui/navigate.ts +28 -3
- package/src/lib/ui/remoteProxy.ts +68 -36
- package/src/lib/ui/renderChain.ts +39 -37
- package/src/lib/ui/renderToStream.ts +84 -68
- package/src/lib/ui/resumeSeedScript.ts +27 -0
- package/src/lib/ui/router.ts +81 -51
- package/src/lib/ui/runtime/RESUME.ts +13 -6
- package/src/lib/ui/runtime/createEffectNode.ts +5 -0
- package/src/lib/ui/runtime/localStoragePersistence.ts +21 -8
- package/src/lib/ui/runtime/toTeardown.ts +10 -5
- package/src/lib/ui/runtime/types/RenderContext.ts +8 -0
- package/src/lib/ui/runtime/types/SsrRender.ts +16 -11
- package/src/lib/ui/runtime/types/UiComponent.ts +7 -1
- 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/src/lib/ui/compile/escapeHtml.ts +0 -15
- /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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
|
@@ -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: (
|
|
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(
|
|
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
|
+
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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
|
-
|
|
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> = ((
|
|
12
|
-
|
|
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
|
-
|
|
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
|
}
|