@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
package/src/lib/ui/router.ts
CHANGED
|
@@ -224,8 +224,10 @@ export function router(
|
|
|
224
224
|
return
|
|
225
225
|
}
|
|
226
226
|
const target = event.target as Element
|
|
227
|
+
/* `closest?.` is undefined when the target is a non-Element (text node, document)
|
|
228
|
+
that has no `closest`; `== null` catches both that and a genuine no-match null. */
|
|
227
229
|
const link = target.closest?.('a[href]') as HTMLAnchorElement | null
|
|
228
|
-
if (link
|
|
230
|
+
if (link == null) {
|
|
229
231
|
return
|
|
230
232
|
}
|
|
231
233
|
/* Defer to the browser for links it should own: a new-tab target, a
|
|
@@ -341,76 +343,104 @@ export function router(
|
|
|
341
343
|
resolvePage(key),
|
|
342
344
|
Promise.all(chainKeys.map((layoutKey) => resolveLayout(layoutKey))),
|
|
343
345
|
verdict,
|
|
344
|
-
])
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
346
|
+
])
|
|
347
|
+
.then(([pageView, resolvedLayouts, decision]) => {
|
|
348
|
+
if (token !== sequence || disposed) {
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
/* handle() redirected: go where it pointed, replacing the blocked
|
|
349
352
|
URL so back doesn't trap on it. The router re-probes the target. */
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
353
|
+
if (decision.kind === 'redirect') {
|
|
354
|
+
navigate(decision.path, { replace: true })
|
|
355
|
+
return
|
|
356
|
+
}
|
|
357
|
+
/* handle() blocked it / redirected off-origin / the probe failed:
|
|
355
358
|
let the browser load the server's real response. */
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
+
if (decision.kind === 'reload') {
|
|
360
|
+
if (typeof location !== 'undefined') {
|
|
361
|
+
location.href = decision.url
|
|
362
|
+
}
|
|
363
|
+
return
|
|
359
364
|
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
(
|
|
364
|
-
)
|
|
365
|
-
/* The shared prefix of layouts (same route key at the same depth) stays
|
|
365
|
+
const layoutViews = resolvedLayouts.filter(
|
|
366
|
+
(view): view is Route => view !== undefined,
|
|
367
|
+
)
|
|
368
|
+
/* The shared prefix of layouts (same route key at the same depth) stays
|
|
366
369
|
mounted; the first divergence and everything inward is rebuilt. */
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
370
|
+
let divergence = 0
|
|
371
|
+
while (
|
|
372
|
+
divergence < mountedLayouts.length &&
|
|
373
|
+
divergence < chainKeys.length &&
|
|
374
|
+
mountedLayouts[divergence]?.key === chainKeys[divergence]
|
|
375
|
+
) {
|
|
376
|
+
divergence += 1
|
|
377
|
+
}
|
|
378
|
+
const hydrating = first && pageView?.hydratable === true
|
|
379
|
+
first = false
|
|
380
|
+
/* The DOM mutation a navigation makes: tear the divergent chain down
|
|
378
381
|
(clearing its content from its boundary) and rebuild into the same
|
|
379
382
|
boundary (hydration adopts in place). */
|
|
380
|
-
|
|
381
|
-
|
|
383
|
+
const swap = (): void => {
|
|
384
|
+
/* `startViewTransition` runs this callback in a later frame, so a newer
|
|
385
|
+
navigation may have superseded this one since the token guard above —
|
|
386
|
+
re-check before mutating, or a stale swap clobbers the newer page. */
|
|
387
|
+
if (token !== sequence || disposed) {
|
|
388
|
+
return
|
|
389
|
+
}
|
|
390
|
+
/* Tear the outgoing page + divergent layouts down BEFORE publishing the
|
|
382
391
|
new snapshot. Publishing first would re-run the doomed leaf page's
|
|
383
392
|
computeds against the new route's params (a missing `[id]` reads back
|
|
384
393
|
`undefined`, e.g. `Number(page.params.id)` → NaN → a bogus request)
|
|
385
394
|
while it's still mounted. Disposing first kills that scope; surviving
|
|
386
395
|
prefix layouts then update in place on publish. */
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
396
|
+
disposeFrom(divergence)
|
|
397
|
+
const url = resolveUrl(path)
|
|
398
|
+
clientPage.value = { route: chainRoute, params, url, navigating: false }
|
|
399
|
+
buildFrom(
|
|
400
|
+
divergence,
|
|
401
|
+
chainKeys,
|
|
402
|
+
layoutViews,
|
|
403
|
+
pageView,
|
|
404
|
+
key,
|
|
405
|
+
params,
|
|
406
|
+
hydrating,
|
|
407
|
+
)
|
|
408
|
+
/* Reapply the destination entry's scroll once its DOM exists — a
|
|
392
409
|
back/forward restores its offset, a fresh nav scrolls to the `#hash`
|
|
393
410
|
anchor (now built) or the top. Runs on the initial paint too: with
|
|
394
411
|
`scrollRestoration='manual'` the browser does NOT restore a reload's
|
|
395
412
|
offset, so first paint recovers it from the persisted `history.state`
|
|
396
413
|
(a fresh load with no persisted offset falls through to hash/top). */
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
414
|
+
historyEntries.restore(url.hash)
|
|
415
|
+
}
|
|
416
|
+
/* Wrap the swap in a View Transition where the browser supports it, so
|
|
400
417
|
the page change cross-fades (and shared `view-transition-name` elements
|
|
401
418
|
morph) — the synchronous swap is exactly the mutation the API snapshots
|
|
402
419
|
around. Skipped while hydrating: the first paint adopts SSR DOM in place,
|
|
403
420
|
not animate. CSS owns opting out (e.g. prefers-reduced-motion). */
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
421
|
+
if (
|
|
422
|
+
!hydrating &&
|
|
423
|
+
typeof document !== 'undefined' &&
|
|
424
|
+
'startViewTransition' in document
|
|
425
|
+
) {
|
|
426
|
+
document.startViewTransition(swap)
|
|
427
|
+
} else {
|
|
428
|
+
swap()
|
|
429
|
+
}
|
|
430
|
+
})
|
|
431
|
+
.catch(() => {
|
|
432
|
+
/* A page/layout chunk import (or the probe) rejected — offline, a hashed
|
|
433
|
+
chunk filename rotated by a deploy, or a transient asset 5xx. Without
|
|
434
|
+
this the navigating:true latched above never clears (a bound spinner
|
|
435
|
+
spins forever) and the rejection surfaces as an unhandledrejection.
|
|
436
|
+
Fall back to a full browser load so the server serves the target. */
|
|
437
|
+
if (token !== sequence || disposed) {
|
|
438
|
+
return
|
|
439
|
+
}
|
|
440
|
+
if (typeof location !== 'undefined') {
|
|
441
|
+
location.href = resolveUrl(path).href
|
|
442
|
+
}
|
|
443
|
+
})
|
|
414
444
|
})
|
|
415
445
|
})
|
|
416
446
|
|
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
/* The await-resume manifest: the resolved value (or error) of each streamed
|
|
2
|
-
`await` block, keyed by its boundary id. The SSR stream serializes each
|
|
2
|
+
`await` block, keyed by its boundary id. The SSR stream serializes each entry
|
|
3
3
|
alongside its fragment; the client registers it (via `applyResolved` or the
|
|
4
4
|
inline swap script), and hydration reads it so an `await` block adopts the
|
|
5
5
|
resolved branch with the real value instead of re-running the promise.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
Each value is the ref-json-encoded ResumeEntry STRING, not the decoded object:
|
|
8
|
+
the entry is encoded with the ref-json codec (so a resolved value carrying cycles
|
|
9
|
+
or shared back-references survives, where JSON would drop it) and decoded lazily
|
|
10
|
+
at the read site in `awaitBlock`. Storing the raw string keeps the inline
|
|
11
|
+
stream-swap script — vanilla, running before the bundle and the codec load — able
|
|
12
|
+
to register an entry without the decoder.
|
|
13
|
+
|
|
14
|
+
Backed by `globalThis.__abideResume` so the inline stream-swap script and the
|
|
15
|
+
framework share one store: whoever runs first creates it, the other adopts the
|
|
16
|
+
same reference. */
|
|
10
17
|
export type ResumeEntry = { ok: true; value: unknown } | { ok: false; error: unknown }
|
|
11
18
|
|
|
12
|
-
const globalScope = globalThis as { __abideResume?: Record<number,
|
|
19
|
+
const globalScope = globalThis as { __abideResume?: Record<number, string> }
|
|
13
20
|
globalScope.__abideResume ??= {}
|
|
14
21
|
|
|
15
22
|
// @documentation plumbing
|
|
16
|
-
export const RESUME: Record<number,
|
|
23
|
+
export const RESUME: Record<number, string> = globalScope.__abideResume
|
|
@@ -49,6 +49,11 @@ export function createEffectNode(fn: () => EffectResult): () => void {
|
|
|
49
49
|
const dispose = () => {
|
|
50
50
|
runCleanup()
|
|
51
51
|
unlinkDeps(node)
|
|
52
|
+
/* Clearing compute makes runNode a no-op: an effect disposed mid-flush (by an
|
|
53
|
+
earlier effect in the same batch) is still in flushEffects' snapshot array
|
|
54
|
+
after pendingEffects.delete, so it would otherwise re-run its body and
|
|
55
|
+
re-link into the graph — a disposed effect resurrected. */
|
|
56
|
+
node.compute = undefined
|
|
52
57
|
REACTIVE_CONTEXT.pendingEffects.delete(node)
|
|
53
58
|
}
|
|
54
59
|
if (OWNER.current !== undefined) {
|
|
@@ -1,12 +1,18 @@
|
|
|
1
|
+
import { decodeRefJson } from '../../shared/decodeRefJson.ts'
|
|
2
|
+
import { encodeRefJson } from '../../shared/encodeRefJson.ts'
|
|
1
3
|
import type { PersistenceStore } from '../types/PersistenceStore.ts'
|
|
2
4
|
|
|
3
5
|
/*
|
|
4
|
-
The default `persist` backend: `localStorage` keyed by the persistence key,
|
|
5
|
-
|
|
6
|
-
live tree).
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
The default `persist` backend: `localStorage` keyed by the persistence key, using
|
|
7
|
+
the ref-json codec as the wire form (which also clones on decode, so a stored
|
|
8
|
+
snapshot can't alias the live tree). ref-json over plain JSON because a doc
|
|
9
|
+
snapshot can hold the types JSON silently coerces (Date), drops (undefined) or
|
|
10
|
+
throws on (bigint, cycles, shared references) — a throw here is a swallowed save,
|
|
11
|
+
i.e. silent persistence loss. Returns `undefined` where there is no `localStorage`
|
|
12
|
+
— the server, or a browser with storage disabled — which `persist` reads as "stay
|
|
13
|
+
inert". A corrupt or unreadable entry (including one written by an older JSON
|
|
14
|
+
build) loads as `undefined` rather than throwing, so one bad write can't wedge
|
|
15
|
+
boot; the next save rewrites it in ref-json form.
|
|
10
16
|
*/
|
|
11
17
|
export function localStoragePersistence(): PersistenceStore | undefined {
|
|
12
18
|
if (typeof localStorage === 'undefined') {
|
|
@@ -19,13 +25,20 @@ export function localStoragePersistence(): PersistenceStore | undefined {
|
|
|
19
25
|
return undefined
|
|
20
26
|
}
|
|
21
27
|
try {
|
|
22
|
-
return
|
|
28
|
+
return decodeRefJson(raw)
|
|
23
29
|
} catch {
|
|
24
30
|
return undefined
|
|
25
31
|
}
|
|
26
32
|
},
|
|
27
33
|
save: (key, snapshot) => {
|
|
28
|
-
|
|
34
|
+
/* Swallow a failed write (QuotaExceededError, storage disabled mid-session) —
|
|
35
|
+
it fires from a debounced flush / pagehide handler with no caller to catch it,
|
|
36
|
+
and a dropped persist must not crash the app. */
|
|
37
|
+
try {
|
|
38
|
+
localStorage.setItem(key, encodeRefJson(snapshot))
|
|
39
|
+
} catch {
|
|
40
|
+
// best-effort persistence
|
|
41
|
+
}
|
|
29
42
|
},
|
|
30
43
|
remove: (key) => {
|
|
31
44
|
localStorage.removeItem(key)
|
|
@@ -15,11 +15,16 @@ export function toTeardown(result: EffectResult): Teardown | undefined {
|
|
|
15
15
|
}
|
|
16
16
|
if (result instanceof Promise) {
|
|
17
17
|
return () => {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
/* Swallow a rejection: an async body that rejected (e.g. an aborted RPC) must
|
|
19
|
+
not surface as an unhandled rejection when the teardown runs at dispose. */
|
|
20
|
+
result.then(
|
|
21
|
+
(teardown) => {
|
|
22
|
+
if (typeof teardown === 'function') {
|
|
23
|
+
teardown()
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
() => undefined,
|
|
27
|
+
)
|
|
23
28
|
}
|
|
24
29
|
}
|
|
25
30
|
return undefined
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/* The per-request SSR render context, threaded through a layout chain and every
|
|
2
|
+
child component a page inlines. `next` is the block-id counter: each `await`/`try`
|
|
3
|
+
block draws from it in depth-first document order — the SAME order the client
|
|
4
|
+
allocates ids during its synchronous hydration walk, so the streamed fragments and
|
|
5
|
+
the `RESUME` manifest line up. Request-local (not a module global) because SSR
|
|
6
|
+
render is async — a blocking `await` yields, and a shared global counter would
|
|
7
|
+
interleave across concurrent requests. */
|
|
8
|
+
export type RenderContext = { next: number }
|
|
@@ -1,22 +1,27 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
import type { ResumeEntry } from '../RESUME.ts'
|
|
2
|
+
|
|
3
|
+
/* One STREAMING await block captured during SSR (no `then` on the `await` tag): its
|
|
4
|
+
boundary id, the promise to await, and the async string-renderers for the resolved
|
|
5
|
+
value / error. `renderToStream` flushes each resolved fragment out of order. The
|
|
6
|
+
renderers are async so a nested `await` block inside the branch composes. `catch`
|
|
7
|
+
is absent when the block has no catch branch — a rejection then surfaces to the
|
|
8
|
+
stream/error path instead of rendering an empty branch. (Blocking awaits — a `then`
|
|
9
|
+
on the tag — never land here: they render inline during the async render pass and
|
|
10
|
+
seed `SsrRender.resume`.) */
|
|
7
11
|
export type SsrAwait = {
|
|
8
12
|
id: number
|
|
9
|
-
blocking?: boolean
|
|
10
13
|
promise: () => unknown
|
|
11
|
-
then: (value: unknown) => string
|
|
12
|
-
catch?: (error: unknown) => string
|
|
14
|
+
then: (value: unknown) => Promise<string>
|
|
15
|
+
catch?: (error: unknown) => Promise<string>
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
/* The result of a component's server `render()`: the pending-shell HTML, the
|
|
16
|
-
serializable document snapshot for client resume,
|
|
17
|
-
|
|
19
|
+
serializable document snapshot for client resume, the STREAMING await blocks to
|
|
20
|
+
flush out of order, and `resume` — the inline-rendered BLOCKING await values keyed
|
|
21
|
+
by boundary id, seeded into the manifest so hydration adopts them without a refetch. */
|
|
18
22
|
export type SsrRender = {
|
|
19
23
|
html: string
|
|
20
24
|
state: unknown
|
|
21
25
|
awaits: SsrAwait[]
|
|
26
|
+
resume: Record<number, ResumeEntry>
|
|
22
27
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RenderContext } from './RenderContext.ts'
|
|
1
2
|
import type { SsrRender } from './SsrRender.ts'
|
|
2
3
|
import type { UiProps } from './UiProps.ts'
|
|
3
4
|
|
|
@@ -7,7 +8,12 @@ for SSR and the hydration hooks. This is the shape `compileModule` emits and the
|
|
|
7
8
|
page/route registries carry — abide-ui's compiled-component shape.
|
|
8
9
|
*/
|
|
9
10
|
export type UiComponent = ((host: Element, props?: UiProps) => () => void) & {
|
|
10
|
-
|
|
11
|
+
/* `ctx` is the request-local block-id counter, threaded so a child's ids share the
|
|
12
|
+
page's depth-first numbering; omitted at the top level (a fresh counter defaults
|
|
13
|
+
in). Returns a Promise when the component has an inline `await` (a blocking `{#await
|
|
14
|
+
… then}` block or a child render); otherwise renders synchronously. Callers `await`
|
|
15
|
+
it either way (awaiting a sync value just returns it). */
|
|
16
|
+
render: (props?: UiProps, ctx?: RenderContext) => SsrRender | Promise<SsrRender>
|
|
11
17
|
hydrate?: (host: Element, props?: UiProps) => () => void
|
|
12
18
|
/* The bare client build (`(host, props) => void`) — appends the component's nodes
|
|
13
19
|
to `host`. A nested child mounts it into a marker range (`mountRange`/`mountChild`)
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { SocketClientFrame } from '../server/sockets/types/SocketClientFrame.ts'
|
|
2
2
|
import type { SocketServerFrame } from '../server/sockets/types/SocketServerFrame.ts'
|
|
3
3
|
import { createSocketSubRegistry } from '../shared/createSocketSubRegistry.ts'
|
|
4
|
+
import { decodeRefJson } from '../shared/decodeRefJson.ts'
|
|
5
|
+
import { encodeRefJson } from '../shared/encodeRefJson.ts'
|
|
4
6
|
import { SOCKETS_PATH } from '../shared/SOCKETS_PATH.ts'
|
|
5
7
|
import type { SocketChannel } from '../shared/types/SocketChannel.ts'
|
|
6
8
|
import { withBase } from '../shared/withBase.ts'
|
|
@@ -55,7 +57,8 @@ export function getSocketChannel(): SocketChannel {
|
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
function send(frame: SocketClientFrame): void {
|
|
58
|
-
|
|
60
|
+
// ref-json frame so a published message graph with cycles/shared refs survives the wire.
|
|
61
|
+
const message = encodeRefJson(frame)
|
|
59
62
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
60
63
|
ws.send(message)
|
|
61
64
|
return
|
|
@@ -90,7 +93,7 @@ export function getSocketChannel(): SocketChannel {
|
|
|
90
93
|
ws.addEventListener('message', (event) => {
|
|
91
94
|
let frame: SocketServerFrame
|
|
92
95
|
try {
|
|
93
|
-
frame =
|
|
96
|
+
frame = decodeRefJson(event.data) as SocketServerFrame
|
|
94
97
|
} catch {
|
|
95
98
|
return
|
|
96
99
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { encodeRefJson } from '../shared/encodeRefJson.ts'
|
|
2
|
+
import type { ResumeEntry } from './runtime/RESUME.ts'
|
|
3
|
+
|
|
4
|
+
/* ref-json-encode an await-resume entry, or `undefined` if it can't be serialized.
|
|
5
|
+
encodeRefJson is total (cycles become back-references, functions fold to undefined),
|
|
6
|
+
but a pathological throw must not blank the surrounding seed/stream — drop just this
|
|
7
|
+
entry and warn, so the client re-runs that one branch's promise while every other
|
|
8
|
+
branch stays seeded. Shared by the streaming (`renderToStream`) and buffered/seed
|
|
9
|
+
(`resumeSeedScript`) paths so the serialize-or-refetch policy lives in one place. */
|
|
10
|
+
export function tryEncodeResume(entry: ResumeEntry, id: number | string): string | undefined {
|
|
11
|
+
try {
|
|
12
|
+
return encodeRefJson(entry)
|
|
13
|
+
} catch (cause) {
|
|
14
|
+
console.warn(
|
|
15
|
+
`[abide] resume for await ${id} is not serializable; client will refetch it`,
|
|
16
|
+
cause,
|
|
17
|
+
)
|
|
18
|
+
return undefined
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -9,9 +9,9 @@ import type { SyncTransport } from './SyncTransport.ts'
|
|
|
9
9
|
A lexical scope: the unit that owns a region's reactive data, its lifetime, and
|
|
10
10
|
the capabilities applied to it. Its data surface MIRRORS `Doc` (read/replace/add/
|
|
11
11
|
remove/cell/derive/apply/snapshot) so the compiler can target a scope as a
|
|
12
|
-
component's data binding directly. It nests (`child`/`root`),
|
|
13
|
-
|
|
14
|
-
`<Child parentScope={scope} />`.
|
|
12
|
+
component's data binding directly. It nests (`child`/`root`), passes values
|
|
13
|
+
down the tree as context (`share`/`shared`), and carries the capability surface as
|
|
14
|
+
methods so a scope is a passable value: `<Child parentScope={scope} />`.
|
|
15
15
|
|
|
16
16
|
Capabilities route where the scope's changes go: `record()` to an undo journal,
|
|
17
17
|
`persist()` to durable storage, `broadcast()` to peers — declared once, then
|
|
@@ -46,6 +46,12 @@ export type Scope = {
|
|
|
46
46
|
/* tree */
|
|
47
47
|
child: (initial?: unknown) => Scope
|
|
48
48
|
root: () => Scope
|
|
49
|
+
/* context — values shared DOWN the tree (not in the reactive doc, which doesn't
|
|
50
|
+
inherit): `share` puts a named value on this scope; `shared` reads the closest
|
|
51
|
+
ancestor (self included) that has the key, undefined if none. The value is held
|
|
52
|
+
by reference, so reactive context = share a `cell`/scope, not a plain object. */
|
|
53
|
+
share: (key: string, value: unknown) => void
|
|
54
|
+
shared: <T>(key: string) => T | undefined
|
|
49
55
|
/* capabilities — enable where the scope's changes go */
|
|
50
56
|
record: (options?: { limit?: number }) => void
|
|
51
57
|
persist: (key?: string) => void
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
HTML-escapes a compile-time-constant string — a static attribute value or static
|
|
3
|
-
text — the same five characters the runtime `$esc` handles. Shared by the SSR
|
|
4
|
-
generator and the static-clone skeleton generator so server markup and the client
|
|
5
|
-
clone template can't diverge on escaping. Static text reaches here already
|
|
6
|
-
entity-decoded (see parseTemplate), so escaping round-trips it through the HTML
|
|
7
|
-
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
|
-
}
|
|
File without changes
|