@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/src/lib/ui/navigate.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { historyEntries } from './runtime/historyEntries.ts'
|
|
2
2
|
import { runtimePath } from './runtime/runtimePath.ts'
|
|
3
3
|
|
|
4
|
+
/* Options for `navigate`. `replace` swaps the current history entry instead of pushing.
|
|
5
|
+
`keepScroll` carries the live scroll offset onto the destination so it isn't reset. */
|
|
6
|
+
export type NavigateOptions = {
|
|
7
|
+
replace?: boolean
|
|
8
|
+
keepScroll?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
4
11
|
/* Navigates to `path`: writes a history entry (when available) and updates the
|
|
5
12
|
reactive route, which re-mounts the matching page via `router`. `replace` swaps
|
|
6
13
|
the current entry instead of pushing — used when honouring a server redirect, so
|
|
@@ -9,16 +16,34 @@ import { runtimePath } from './runtime/runtimePath.ts'
|
|
|
9
16
|
teardown the rebuild does. A push leaves the current entry behind — its scroll is
|
|
10
17
|
bucketed so back restores it — and mints a fresh id. A replace destroys the current
|
|
11
18
|
entry and lands fresh content (a redirect), so its saved scroll no longer applies:
|
|
12
|
-
the bucket is discarded and the id kept, so the new page restores to top/anchor.
|
|
19
|
+
the bucket is discarded and the id kept, so the new page restores to top/anchor.
|
|
20
|
+
`keepScroll` opts the destination out of that reset — for an in-page URL swap (e.g.
|
|
21
|
+
selecting another episode on the same detail page) where a top jump is jarring: the
|
|
22
|
+
live offset is bucketed under the destination entry id, so the post-rebuild `restore`
|
|
23
|
+
reapplies it instead of scrolling to top. */
|
|
13
24
|
// @documentation navigate
|
|
14
|
-
export function navigate(
|
|
25
|
+
export function navigate(
|
|
26
|
+
path: string,
|
|
27
|
+
{ replace = false, keepScroll = false }: NavigateOptions = {},
|
|
28
|
+
): void {
|
|
15
29
|
if (typeof history !== 'undefined') {
|
|
16
30
|
if (replace) {
|
|
17
|
-
|
|
31
|
+
/* keepScroll buckets the live offset under the (unchanged) entry id so
|
|
32
|
+
restore reapplies it; otherwise the superseded content drops its bucket. */
|
|
33
|
+
if (keepScroll) {
|
|
34
|
+
historyEntries.save()
|
|
35
|
+
} else {
|
|
36
|
+
historyEntries.discard()
|
|
37
|
+
}
|
|
18
38
|
history.replaceState({ abideEntry: historyEntries.current }, '', path)
|
|
19
39
|
} else {
|
|
20
40
|
historyEntries.save()
|
|
21
41
|
history.pushState({ abideEntry: historyEntries.next() }, '', path)
|
|
42
|
+
/* Re-bucket the same offset under the freshly minted id so the pushed entry
|
|
43
|
+
restores to it rather than to top. */
|
|
44
|
+
if (keepScroll) {
|
|
45
|
+
historyEntries.save()
|
|
46
|
+
}
|
|
22
47
|
}
|
|
23
48
|
}
|
|
24
49
|
runtimePath.value = path
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { resumeSeedScript } from './resumeSeedScript.ts'
|
|
2
2
|
import type { ResumeEntry } from './runtime/RESUME.ts'
|
|
3
3
|
import type { SsrAwait, SsrRender } from './runtime/types/SsrRender.ts'
|
|
4
|
+
import { tryEncodeResume } from './tryEncodeResume.ts'
|
|
4
5
|
|
|
5
6
|
/*
|
|
6
7
|
Out-of-order SSR streaming. Yields the shell first (so the browser paints
|
|
@@ -69,10 +70,15 @@ export async function* renderToStream(
|
|
|
69
70
|
const resolved = await Promise.race(inflight.values())
|
|
70
71
|
inflight.delete(resolved.id)
|
|
71
72
|
enqueueNew()
|
|
72
|
-
|
|
73
|
+
/* An unserializable value (e.g. a cyclic media tree) streams its rendered HTML
|
|
74
|
+
with NO seed script: both swap consumers (SSR_SWAP_SCRIPT, applyResolved) skip
|
|
75
|
+
registration when the leading child isn't a parseable script, so hydration
|
|
76
|
+
re-runs this one branch's promise — degrading to a refetch instead of aborting
|
|
77
|
+
the whole stream. */
|
|
78
|
+
const encoded = encodeStreamResume(resolved.resume, resolved.id)
|
|
73
79
|
yield resumeSeedScript(resumeDelta()) +
|
|
74
80
|
`<abide-resolve data-id="${resolved.id}">` +
|
|
75
|
-
`<script type="application/json">${encoded}</script>` +
|
|
81
|
+
(encoded === undefined ? '' : `<script type="application/json">${encoded}</script>`) +
|
|
76
82
|
`${resolved.html}</abide-resolve>`
|
|
77
83
|
}
|
|
78
84
|
}
|
|
@@ -105,11 +111,13 @@ function settle(block: SsrAwait): Promise<Settled> {
|
|
|
105
111
|
)
|
|
106
112
|
}
|
|
107
113
|
|
|
108
|
-
/*
|
|
109
|
-
text, so only `<` needs neutralizing (emitted as a unicode escape) to keep a
|
|
110
|
-
literal `</script>` from closing the block early — quotes stay raw
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
114
|
+
/* ref-json for a `<script type="application/json">` data block: script content is
|
|
115
|
+
raw text, so only `<` needs neutralizing (emitted as a unicode escape) to keep a
|
|
116
|
+
literal `</script>` from closing the block early — quotes stay raw, and the escape
|
|
117
|
+
survives `decodeRefJson`'s inner JSON.parse since `<` only ever appears inside JSON
|
|
118
|
+
strings. `tryEncodeResume` handles the serialize-or-refetch policy (undefined → no
|
|
119
|
+
script → the swap consumers skip registration → hydration re-runs that one promise).
|
|
120
|
+
`applyResolved`/the inline swap script store it via `.textContent`; `awaitBlock` decodes it. */
|
|
121
|
+
function encodeStreamResume(resume: ResumeEntry, id: number): string | undefined {
|
|
122
|
+
return tryEncodeResume(resume, id)?.replace(/</g, '\\u003c')
|
|
115
123
|
}
|
|
@@ -1,17 +1,27 @@
|
|
|
1
1
|
import { safeJsonForScript } from '../shared/safeJsonForScript.ts'
|
|
2
2
|
import type { ResumeEntry } from './runtime/RESUME.ts'
|
|
3
|
+
import { tryEncodeResume } from './tryEncodeResume.ts'
|
|
3
4
|
|
|
4
5
|
/* A self-contained `<script>` seeding the await-resume manifest with the blocking
|
|
5
6
|
values rendered inline on the server, so client hydration adopts each resolved
|
|
6
7
|
branch instead of re-running its promise. Empty when nothing blocking resolved.
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
Each entry is ref-json-encoded to a string (decoded at read in `awaitBlock`); the
|
|
9
|
+
id→string map runs as JS (`Object.assign`), so it's wrapped in `safeJsonForScript`
|
|
10
|
+
— escaping `<`, `-->`, and U+2028/U+2029 so an encoded value can't close the script
|
|
11
|
+
early or parse as a line terminator. Shared by the buffered (`createUiPageRenderer`)
|
|
12
|
+
and streaming (`renderToStream`) paths. */
|
|
11
13
|
// @documentation plumbing
|
|
12
14
|
export function resumeSeedScript(resume: Record<number, ResumeEntry>): string {
|
|
13
|
-
|
|
15
|
+
/* ref-json (not JSON) so a value carrying cycles or shared back-references — a
|
|
16
|
+
media tree with parent↔child links — seeds instead of being dropped. `tryEncodeResume`
|
|
17
|
+
drops just an unserializable entry (the client re-runs that one branch's promise),
|
|
18
|
+
keeping every other branch seeded rather than blanking the whole page. */
|
|
19
|
+
const encoded = Object.entries(resume).flatMap(([id, entry]) => {
|
|
20
|
+
const text = tryEncodeResume(entry, id)
|
|
21
|
+
return text === undefined ? [] : [[id, text] as const]
|
|
22
|
+
})
|
|
23
|
+
if (encoded.length === 0) {
|
|
14
24
|
return ''
|
|
15
25
|
}
|
|
16
|
-
return `<script>Object.assign(window.__abideResume=window.__abideResume||{},${safeJsonForScript(
|
|
26
|
+
return `<script>Object.assign(window.__abideResume=window.__abideResume||{},${safeJsonForScript(Object.fromEntries(encoded))})</script>`
|
|
17
27
|
}
|
package/src/lib/ui/router.ts
CHANGED
|
@@ -351,7 +351,7 @@ export function router(
|
|
|
351
351
|
/* handle() redirected: go where it pointed, replacing the blocked
|
|
352
352
|
URL so back doesn't trap on it. The router re-probes the target. */
|
|
353
353
|
if (decision.kind === 'redirect') {
|
|
354
|
-
navigate(decision.path, true)
|
|
354
|
+
navigate(decision.path, { replace: true })
|
|
355
355
|
return
|
|
356
356
|
}
|
|
357
357
|
/* handle() blocked it / redirected off-origin / the probe failed:
|
|
@@ -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
|
|
@@ -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,7 +25,7 @@ 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
|
}
|
|
@@ -29,7 +35,7 @@ export function localStoragePersistence(): PersistenceStore | undefined {
|
|
|
29
35
|
it fires from a debounced flush / pagehide handler with no caller to catch it,
|
|
30
36
|
and a dropped persist must not crash the app. */
|
|
31
37
|
try {
|
|
32
|
-
localStorage.setItem(key,
|
|
38
|
+
localStorage.setItem(key, encodeRefJson(snapshot))
|
|
33
39
|
} catch {
|
|
34
40
|
// best-effort persistence
|
|
35
41
|
}
|
|
@@ -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
|