@abide/abide 0.42.0 → 0.43.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 +234 -300
- package/CHANGELOG.md +34 -0
- package/README.md +50 -93
- package/package.json +3 -2
- package/src/abideResolverPlugin.ts +104 -14
- package/src/buildCli.ts +1 -1
- package/src/discoveryEntry.ts +3 -3
- package/src/lib/bundle/BundleMenuItem.ts +1 -1
- package/src/lib/cli/createClient.ts +12 -12
- package/src/lib/cli/dispatchCommand.ts +1 -1
- package/src/lib/cli/printSessionStatus.ts +1 -1
- package/src/lib/cli/resolveCliTarget.ts +1 -1
- package/src/lib/cli/runCli.ts +1 -1
- package/src/lib/cli/types/CliManifest.ts +1 -1
- package/src/lib/cli/types/CliManifestEntry.ts +2 -2
- package/src/lib/mcp/annotationsForMethod.ts +5 -5
- package/src/lib/mcp/createMcpServer.ts +3 -3
- package/src/lib/mcp/mcpSurface.ts +14 -14
- package/src/lib/mcp/types/McpServerOptions.ts +1 -1
- package/src/lib/server/DELETE.ts +4 -4
- package/src/lib/server/GET.ts +4 -4
- package/src/lib/server/HEAD.ts +4 -4
- package/src/lib/server/PATCH.ts +4 -4
- package/src/lib/server/POST.ts +4 -4
- package/src/lib/server/PUT.ts +4 -4
- package/src/lib/server/agent.ts +4 -4
- package/src/lib/server/env.ts +1 -1
- package/src/lib/server/error.ts +49 -7
- package/src/lib/server/json.ts +1 -1
- package/src/lib/server/rpc/buildErrorConstructors.ts +19 -0
- package/src/lib/server/rpc/{defineVerb.ts → defineRpc.ts} +52 -34
- package/src/lib/server/rpc/{dispatchVerbInProcess.ts → dispatchRpcInProcess.ts} +3 -3
- package/src/lib/server/rpc/fieldErrorsFromIssues.ts +23 -0
- package/src/lib/server/rpc/{findVerbByCommandName.ts → findRpcByCommandName.ts} +5 -5
- package/src/lib/server/rpc/parseArgs.ts +6 -6
- package/src/lib/server/rpc/readBodyWithinLimit.ts +2 -2
- package/src/lib/server/rpc/registerRpc.ts +6 -0
- package/src/lib/server/rpc/{verbRegistry.ts → rpcRegistry.ts} +4 -4
- package/src/lib/server/rpc/{runWithVerbTimeout.ts → runWithRpcTimeout.ts} +4 -4
- package/src/lib/server/rpc/types/RemoteHandler.ts +9 -3
- package/src/lib/server/rpc/types/{VerbHelper.ts → RpcHelper.ts} +57 -23
- package/src/lib/server/rpc/types/{VerbRegistryEntry.ts → RpcRegistryEntry.ts} +3 -3
- package/src/lib/server/rpc/types/TypedResponse.ts +2 -2
- package/src/lib/server/rpc/unprocessed.ts +6 -7
- package/src/lib/server/rpc/validationError.ts +17 -0
- package/src/lib/server/runtime/buildInspectorSurface.ts +7 -7
- package/src/lib/server/runtime/buildOpenApiSpec.ts +6 -6
- package/src/lib/server/runtime/createRouteDispatcher.ts +7 -7
- package/src/lib/server/runtime/createServer.ts +4 -4
- package/src/lib/server/runtime/crossOriginForbidden.ts +1 -1
- package/src/lib/server/runtime/crossOriginGate.ts +4 -4
- package/src/lib/server/runtime/logExposedSurfaces.ts +4 -4
- package/src/lib/server/runtime/registryManifests.ts +2 -2
- package/src/lib/server/runtime/types/InspectorCacheEntry.ts +1 -1
- package/src/lib/server/runtime/types/InspectorRpc.ts +27 -0
- package/src/lib/server/runtime/types/InspectorSurface.ts +4 -4
- package/src/lib/server/runtime/types/RequestStore.ts +1 -1
- package/src/lib/server/runtime/warnUnguardedMcp.ts +2 -2
- package/src/lib/server/sockets/createSocketDispatcher.ts +15 -14
- package/src/lib/server/sockets/types/SocketOperation.ts +2 -2
- package/src/lib/shared/HttpError.ts +15 -1
- package/src/lib/shared/UNREACHABLE_STATUSES.ts +13 -0
- package/src/lib/shared/buildRpcRequest.ts +5 -5
- package/src/lib/shared/cache.ts +50 -168
- package/src/lib/shared/carriesBodyArgs.ts +4 -4
- package/src/lib/shared/createCacheStore.ts +12 -0
- package/src/lib/shared/createRemoteFunction.ts +11 -6
- package/src/lib/shared/decodeResponse.ts +2 -2
- package/src/lib/shared/detectRpcMethod.ts +17 -0
- package/src/lib/shared/emitLogRecord.ts +3 -3
- package/src/lib/shared/extraForwardHeaders.ts +1 -1
- package/src/lib/shared/findExportCallSite.ts +30 -273
- package/src/lib/shared/forwardHeaders.ts +2 -2
- package/src/lib/shared/httpErrorFor.ts +26 -0
- package/src/lib/shared/isAsciiWhitespace.ts +5 -0
- package/src/lib/shared/isIdentPart.ts +9 -0
- package/src/lib/shared/isIdentStart.ts +8 -0
- package/src/lib/shared/isReadOnlyMethod.ts +4 -4
- package/src/lib/shared/keyForRemoteCall.ts +4 -4
- package/src/lib/shared/log.ts +1 -1
- package/src/lib/shared/outboxProbeSlot.ts +20 -0
- package/src/lib/shared/pending.ts +4 -1
- package/src/lib/shared/prepareRpcModule.ts +96 -14
- package/src/lib/shared/probeRegistries.ts +17 -4
- package/src/lib/shared/queryStringFromArgs.ts +1 -1
- package/src/lib/shared/remoteMetaStore.ts +2 -2
- package/src/lib/shared/resolveClientFlags.ts +1 -1
- package/src/lib/shared/skipNonCode.ts +230 -0
- package/src/lib/shared/streamResponse.ts +9 -6
- package/src/lib/shared/stripImport.ts +3 -3
- package/src/lib/shared/types/CacheEntry.ts +2 -4
- package/src/lib/shared/types/CacheOnContext.ts +1 -11
- package/src/lib/shared/types/CacheStore.ts +13 -7
- package/src/lib/shared/types/ClientFlags.ts +1 -1
- package/src/lib/shared/types/ErrorConstructors.ts +17 -0
- package/src/lib/shared/types/ErrorDescriptor.ts +10 -0
- package/src/lib/shared/types/ErrorSpec.ts +11 -0
- package/src/lib/shared/types/HttpMethod.ts +1 -0
- package/src/lib/shared/types/Outbox.ts +9 -0
- package/src/lib/shared/types/OutboxEntry.ts +27 -0
- package/src/lib/shared/types/RawRemoteFunction.ts +2 -2
- package/src/lib/shared/types/RemoteCallable.ts +2 -2
- package/src/lib/shared/types/RemoteFunction.ts +21 -6
- package/src/lib/shared/types/RequestScopeInfo.ts +1 -1
- package/src/lib/shared/types/RpcErrorGuard.ts +40 -0
- package/src/lib/shared/types/RpcInvoker.ts +1 -1
- package/src/lib/shared/types/StandardSchemaV1.ts +1 -1
- package/src/lib/shared/types/ValidationErrorData.ts +20 -0
- package/src/lib/shared/url.ts +3 -3
- package/src/lib/shared/writeRpcDts.ts +7 -7
- package/src/lib/shared/writeTestRpcDts.ts +3 -3
- package/src/lib/test/createTestApp.ts +10 -10
- package/src/lib/ui/compile/awaitPlan.ts +48 -0
- package/src/lib/ui/compile/compileShadow.ts +75 -21
- package/src/lib/ui/compile/desugarSignals.ts +42 -6
- package/src/lib/ui/compile/generateBuild.ts +43 -60
- package/src/lib/ui/compile/generateSSR.ts +84 -54
- package/src/lib/ui/compile/ifPlan.ts +30 -0
- package/src/lib/ui/compile/parseTemplate.ts +41 -11
- package/src/lib/ui/compile/stripEffects.ts +17 -9
- package/src/lib/ui/compile/switchPlan.ts +22 -0
- package/src/lib/ui/compile/tryPlan.ts +29 -0
- package/src/lib/ui/compile/types/TemplateAttr.ts +8 -2
- package/src/lib/ui/compile/types/TemplateNode.ts +6 -2
- package/src/lib/ui/createScope.ts +20 -2
- package/src/lib/ui/dom/disposeRange.ts +6 -10
- package/src/lib/ui/dom/hydrate.ts +2 -5
- package/src/lib/ui/dom/mount.ts +1 -2
- package/src/lib/ui/dom/withScope.ts +6 -9
- package/src/lib/ui/effect.ts +4 -2
- package/src/lib/ui/navigate.ts +40 -13
- package/src/lib/ui/outbox.ts +30 -110
- package/src/lib/ui/remoteProxy.ts +160 -10
- package/src/lib/ui/router.ts +3 -3
- package/src/lib/ui/rpcOutbox/createOutboxQueue.ts +281 -0
- package/src/lib/ui/rpcOutbox/outboxRegistry.ts +69 -0
- package/src/lib/ui/socketChannel.ts +7 -1
- package/src/lib/ui/types/Scope.ts +20 -5
- package/template/CLAUDE.md +1 -1
- package/template/src/server/rpc/getHello.ts +3 -3
- package/template/test/app.test.ts +2 -2
- package/src/lib/server/rpc/registerVerb.ts +0 -6
- package/src/lib/server/runtime/types/InspectorVerb.ts +0 -27
- package/src/lib/shared/detectVerbMethod.ts +0 -17
- package/src/lib/shared/types/HttpVerb.ts +0 -1
- package/src/lib/ui/types/Outbox.ts +0 -14
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { resolveBranches } from './resolveBranches.ts'
|
|
2
|
+
import type { TemplateNode } from './types/TemplateNode.ts'
|
|
3
|
+
|
|
4
|
+
/* The structural shape of a `try` error boundary, resolved once so the build and SSR
|
|
5
|
+
back-ends share one reading and only own emission. Both previously recomputed branch
|
|
6
|
+
resolution, the `finallyChildren` default, the guarded (children-minus-branch) split, and
|
|
7
|
+
the catch binding / presence. */
|
|
8
|
+
export type TryPlan = {
|
|
9
|
+
/* The guarded content — every child except the `catch`/`finally` branch nodes. */
|
|
10
|
+
guarded: TemplateNode[]
|
|
11
|
+
/* The catch content + its bound name (`_error` default); empty when no catch branch. */
|
|
12
|
+
catchChildren: TemplateNode[]
|
|
13
|
+
catchAs: string
|
|
14
|
+
finallyChildren: TemplateNode[]
|
|
15
|
+
/* No catch → a throw propagates to the enclosing boundary (re-throw / `undefined` thunk). */
|
|
16
|
+
hasCatch: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/* Resolves a `try` node's branches into the shared structural plan. */
|
|
20
|
+
export function tryPlan(node: Extract<TemplateNode, { kind: 'try' }>): TryPlan {
|
|
21
|
+
const [catchBranch, finallyBranch] = resolveBranches(node, 'catch', 'finally')
|
|
22
|
+
return {
|
|
23
|
+
guarded: node.children.filter((child) => child.kind !== 'branch'),
|
|
24
|
+
catchChildren: catchBranch?.children ?? [],
|
|
25
|
+
catchAs: catchBranch?.as ?? '_error',
|
|
26
|
+
finallyChildren: finallyBranch?.children ?? [],
|
|
27
|
+
hasCatch: catchBranch !== undefined,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -13,9 +13,15 @@ absolute offset of `code` in the original `.abide` source (see TextPart) —
|
|
|
13
13
|
optional, set only when the parser tracks positions for the type-checking shadow.
|
|
14
14
|
*/
|
|
15
15
|
export type TemplateAttr =
|
|
16
|
-
| { kind: 'static'; name: string; value: string; bare?: true }
|
|
17
|
-
| { kind: 'expression'; name: string; code: string; loc?: number }
|
|
16
|
+
| { kind: 'static'; name: string; value: string; bare?: true; nameLoc?: number }
|
|
17
|
+
| { kind: 'expression'; name: string; code: string; loc?: number; nameLoc?: number }
|
|
18
18
|
| { kind: 'event'; event: string; code: string; loc?: number }
|
|
19
19
|
| { kind: 'bind'; property: string; code: string; loc?: number }
|
|
20
|
+
/* `class:<name>={code}` — toggles the class on/off by `code`'s truthiness, written
|
|
21
|
+
surgically (classList.toggle) without re-rendering the element. */
|
|
22
|
+
| { kind: 'class'; name: string; code: string; loc?: number }
|
|
23
|
+
/* `style:<property>={code}` — writes one inline style/custom property (style.setProperty)
|
|
24
|
+
reactively, again without a re-render. `property` may be a CSS custom prop (`--pct`). */
|
|
25
|
+
| { kind: 'style'; property: string; code: string; loc?: number }
|
|
20
26
|
| { kind: 'attach'; code: string; loc?: number }
|
|
21
27
|
| { kind: 'spread'; code: string; loc?: number }
|
|
@@ -72,9 +72,13 @@ export type TemplateNode =
|
|
|
72
72
|
| {
|
|
73
73
|
kind: 'component'
|
|
74
74
|
name: string
|
|
75
|
+
/* Source offset of the tag name — the anchor for a whole-mount diagnostic
|
|
76
|
+
(a missing required prop, which has no supplied expression to point at). */
|
|
77
|
+
loc?: number
|
|
75
78
|
/* Each authored attribute as a prop. A `spread` entry (`{...code}`) carries no
|
|
76
|
-
`name`; its keys merge in at runtime (`mergeProps`/`spreadProps`).
|
|
77
|
-
|
|
79
|
+
`name`; its keys merge in at runtime (`mergeProps`/`spreadProps`). `nameLoc` is
|
|
80
|
+
the prop name's source offset — the anchor for an excess-prop diagnostic. */
|
|
81
|
+
props: { name: string; code: string; loc?: number; nameLoc?: number; spread?: boolean }[]
|
|
78
82
|
children: TemplateNode[]
|
|
79
83
|
}
|
|
80
84
|
| { kind: 'switch'; subject: string; children: TemplateNode[]; loc?: number }
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { computed } from './computed.ts'
|
|
2
|
+
import { effect } from './effect.ts'
|
|
2
3
|
import { history } from './history.ts'
|
|
3
4
|
import { linked } from './linked.ts'
|
|
4
5
|
import { persist as persistDoc } from './persist.ts'
|
|
@@ -37,6 +38,10 @@ export function createScope(
|
|
|
37
38
|
const data = (): Doc => (document ??= createDoc({}))
|
|
38
39
|
const id = parent === undefined ? `scope-${nextId++}` : `${parent.id}.${nextId++}`
|
|
39
40
|
const children: Scope[] = []
|
|
41
|
+
/* Adopted build teardowns (the reactivity stopper from the mount core). Disposed
|
|
42
|
+
first and in reverse on teardown, before children and capabilities — so the one
|
|
43
|
+
`dispose` runs the order the call sites hand-composed as `stop(); lexical.dispose()`. */
|
|
44
|
+
const owned: Array<() => void> = []
|
|
40
45
|
/* Context values shared down the tree, held apart from the reactive doc (which
|
|
41
46
|
a child does not inherit): keyed by name, read by the closest ancestor walk. */
|
|
42
47
|
const shared = new Map<string, unknown>()
|
|
@@ -56,11 +61,17 @@ export function createScope(
|
|
|
56
61
|
cell: (path) => data().cell(path),
|
|
57
62
|
derive: (path, compute) => data().derive(path, compute),
|
|
58
63
|
snapshot: () => data().snapshot(),
|
|
59
|
-
/* The
|
|
60
|
-
|
|
64
|
+
/* The reactive primitives — namespaced under the scope but AMBIENT-bound, not
|
|
65
|
+
receiver-bound: each binds whatever scope is rendering and the finest ambient
|
|
66
|
+
build window (branch/row), so the handle is namespacing, not a binding target.
|
|
67
|
+
Binding to the receiver would leak branch-local cells (see ADR-0012). */
|
|
61
68
|
state,
|
|
62
69
|
linked,
|
|
63
70
|
computed,
|
|
71
|
+
effect,
|
|
72
|
+
own: (dispose) => {
|
|
73
|
+
owned.push(dispose)
|
|
74
|
+
},
|
|
64
75
|
child: (childInitial = {}) => {
|
|
65
76
|
const created = createScope(childInitial, self)
|
|
66
77
|
children.push(created)
|
|
@@ -91,6 +102,13 @@ export function createScope(
|
|
|
91
102
|
canUndo: () => past?.canUndo() ?? false,
|
|
92
103
|
canRedo: () => past?.canRedo() ?? false,
|
|
93
104
|
dispose: () => {
|
|
105
|
+
/* Stop the build's reactivity first (reverse order), before tearing down nested
|
|
106
|
+
children and the boundary-crossing capabilities — the order the call sites
|
|
107
|
+
hand-composed as `stop(); lexical.dispose()`. */
|
|
108
|
+
for (let index = owned.length - 1; index >= 0; index -= 1) {
|
|
109
|
+
owned[index]?.()
|
|
110
|
+
}
|
|
111
|
+
owned.length = 0
|
|
94
112
|
for (const created of children) {
|
|
95
113
|
created.dispose()
|
|
96
114
|
}
|
|
@@ -3,18 +3,14 @@ import { clearBetween } from './clearBetween.ts'
|
|
|
3
3
|
|
|
4
4
|
/*
|
|
5
5
|
The standard teardown for a marker-bounded range (component, layout/page boundary,
|
|
6
|
-
slot):
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
disposer contract lives in a
|
|
6
|
+
slot): dispose its lexical scope (which stops the content's reactivity first, then its
|
|
7
|
+
nested children and capabilities), and clear the nodes between the markers — leaving the
|
|
8
|
+
markers in place so a hot swap rebuilds the range. Shared by every range mount
|
|
9
|
+
(`fillRange`, `mountRange`, `fillBoundary`) so the one disposer contract lives in a
|
|
10
|
+
single place.
|
|
10
11
|
*/
|
|
11
|
-
export function disposeRange(
|
|
12
|
-
scoped: { stop: () => void; lexical: Scope },
|
|
13
|
-
start: Comment,
|
|
14
|
-
end: Comment,
|
|
15
|
-
): () => void {
|
|
12
|
+
export function disposeRange(scoped: { lexical: Scope }, start: Comment, end: Comment): () => void {
|
|
16
13
|
return () => {
|
|
17
|
-
scoped.stop()
|
|
18
14
|
scoped.lexical.dispose()
|
|
19
15
|
clearBetween(start, end)
|
|
20
16
|
}
|
|
@@ -29,11 +29,8 @@ export function hydrate(
|
|
|
29
29
|
try {
|
|
30
30
|
/* Same shared mount core as `mount` (see `withScope`) — a hydrated component owns a
|
|
31
31
|
scope too, adopting the model its build adopts — run with the claim cursor active. */
|
|
32
|
-
const {
|
|
33
|
-
return () =>
|
|
34
|
-
stop()
|
|
35
|
-
lexical.dispose()
|
|
36
|
-
}
|
|
32
|
+
const { lexical } = withScope(scopeLabel(host), () => scope(() => build(host, props)))
|
|
33
|
+
return () => lexical.dispose()
|
|
37
34
|
} finally {
|
|
38
35
|
RENDER.hydration = previous
|
|
39
36
|
}
|
package/src/lib/ui/dom/mount.ts
CHANGED
|
@@ -23,9 +23,8 @@ export function mount(
|
|
|
23
23
|
/* Establish this component's lexical scope (nested, `awaiting` so it adopts the model
|
|
24
24
|
doc the build's first `doc()` creates) and render pass, run the build under it, and
|
|
25
25
|
restore the previous scope — the shared mount core (see `withScope`). */
|
|
26
|
-
const {
|
|
26
|
+
const { lexical } = withScope(scopeLabel(host), () => scope(() => build(host, props)))
|
|
27
27
|
return () => {
|
|
28
|
-
stop()
|
|
29
28
|
lexical.dispose()
|
|
30
29
|
host.textContent = ''
|
|
31
30
|
}
|
|
@@ -11,23 +11,20 @@ under the current one in `awaiting` mode — so it adopts the model doc its firs
|
|
|
11
11
|
creates — brackets a render pass — so its `await`/`try` block ids draw from the shared
|
|
12
12
|
counter in SSR-stream order — runs `build`, then restores the previous scope (synchronous
|
|
13
13
|
build, so the restore is exact). `build` returns its reactivity stopper (from `scope` or
|
|
14
|
-
`fillBefore`)
|
|
15
|
-
the
|
|
14
|
+
`fillBefore`), which the lexical scope ADOPTS (`own`) so the scope has a single teardown:
|
|
15
|
+
the caller wraps its own DOM teardown (clear host vs clear range) around one
|
|
16
|
+
`lexical.dispose()`, no longer composing a separate `stop()` at every site.
|
|
16
17
|
*/
|
|
17
|
-
export function withScope(
|
|
18
|
-
label: string | undefined,
|
|
19
|
-
build: () => () => void,
|
|
20
|
-
): { stop: () => void; lexical: Scope } {
|
|
18
|
+
export function withScope(label: string | undefined, build: () => () => void): { lexical: Scope } {
|
|
21
19
|
const parentScope = CURRENT_SCOPE.current
|
|
22
20
|
const lexical = createScope({}, parentScope, true, label)
|
|
23
21
|
enterRenderPass()
|
|
24
22
|
CURRENT_SCOPE.current = lexical
|
|
25
|
-
let stop: () => void = () => undefined
|
|
26
23
|
try {
|
|
27
|
-
|
|
24
|
+
lexical.own(build())
|
|
28
25
|
} finally {
|
|
29
26
|
exitRenderPass()
|
|
30
27
|
CURRENT_SCOPE.current = parentScope
|
|
31
28
|
}
|
|
32
|
-
return {
|
|
29
|
+
return { lexical }
|
|
33
30
|
}
|
package/src/lib/ui/effect.ts
CHANGED
|
@@ -10,9 +10,11 @@ dispose — and may be async (its teardown then runs once the promise settles, b
|
|
|
10
10
|
only the reads before its first `await` are tracked). Returns a dispose that runs
|
|
11
11
|
the final teardown and detaches it from the graph. This is abide's from-scratch
|
|
12
12
|
effect primitive: the open-on-first-read / close-on-last-reader lifecycle,
|
|
13
|
-
grounded in abide's own reactive core.
|
|
13
|
+
grounded in abide's own reactive core. Authored as `scope().effect(...)` (the bare
|
|
14
|
+
name is reserved); this module export stays for the generated binding code that
|
|
15
|
+
calls it directly (and for the SSR strip), so it carries no standalone demo.
|
|
14
16
|
*/
|
|
15
|
-
// @documentation
|
|
17
|
+
// @documentation plumbing
|
|
16
18
|
export function effect(fn: () => EffectResult): () => void {
|
|
17
19
|
/* Re-runs fire after the build, so pin the scope this effect was created under
|
|
18
20
|
(the dep-tracking observer is managed separately, so this can't disturb it). */
|
package/src/lib/ui/navigate.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { parseRouteSegments } from '../shared/parseRouteSegments.ts'
|
|
2
|
+
import type { PageRoutes, PathParams } from '../shared/url.ts'
|
|
3
|
+
import { url } from '../shared/url.ts'
|
|
1
4
|
import { historyEntries } from './runtime/historyEntries.ts'
|
|
2
5
|
import { runtimePath } from './runtime/runtimePath.ts'
|
|
3
6
|
|
|
@@ -8,21 +11,19 @@ export type NavigateOptions = {
|
|
|
8
11
|
keepScroll?: boolean
|
|
9
12
|
}
|
|
10
13
|
|
|
11
|
-
/*
|
|
12
|
-
|
|
13
|
-
the
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
entry and lands fresh content (a redirect), so its saved scroll no longer applies:
|
|
14
|
+
/* Writes an ALREADY-RESOLVED path into history + the reactive route. The router uses this
|
|
15
|
+
for paths it built from a real URL (a clicked link, a popstate, a server redirect) — they
|
|
16
|
+
already carry the mount base, so they must NOT pass through `url()` again. Each entry
|
|
17
|
+
carries a monotonic `abideEntry` id so the router can bucket/restore its scroll offset
|
|
18
|
+
across the page teardown the rebuild does. A push leaves the current entry behind — its
|
|
19
|
+
scroll is bucketed so back restores it — and mints a fresh id. A replace destroys the
|
|
20
|
+
current entry and lands fresh content (a redirect), so its saved scroll no longer applies:
|
|
19
21
|
the bucket is discarded and the id kept, so the new page restores to top/anchor.
|
|
20
22
|
`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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
export function navigate(
|
|
23
|
+
selecting another episode on the same detail page) where a top jump is jarring: the live
|
|
24
|
+
offset is bucketed under the destination entry id, so the post-rebuild `restore` reapplies
|
|
25
|
+
it instead of scrolling to top. */
|
|
26
|
+
export function navigatePath(
|
|
26
27
|
path: string,
|
|
27
28
|
{ replace = false, keepScroll = false }: NavigateOptions = {},
|
|
28
29
|
): void {
|
|
@@ -48,3 +49,29 @@ export function navigate(
|
|
|
48
49
|
}
|
|
49
50
|
runtimePath.value = path
|
|
50
51
|
}
|
|
52
|
+
|
|
53
|
+
/* Navigates to a typed in-app path: a route literal with `[name]` segments takes its params
|
|
54
|
+
first (`navigate('/p/[id]', { id })`), then options; a paramless or already-interpolated
|
|
55
|
+
path takes options directly (`navigate('/p', { replace })`). The path is built through
|
|
56
|
+
`url()` — base-correct, params interpolated — exactly as a link href would be, so a
|
|
57
|
+
dynamic `/p/${x}` falls through url()'s paramless branch and is base-prefixed too. */
|
|
58
|
+
// @documentation navigate
|
|
59
|
+
export function navigate<P extends keyof PageRoutes | (string & {})>(
|
|
60
|
+
path: P,
|
|
61
|
+
...rest: keyof PathParams<P> extends never
|
|
62
|
+
? [options?: NavigateOptions]
|
|
63
|
+
: [params: PathParams<P>, options?: NavigateOptions]
|
|
64
|
+
): void {
|
|
65
|
+
/* A path declaring `[name]` segments takes params first, options second — mirroring
|
|
66
|
+
url()'s own arg discrimination; otherwise the first arg is options. */
|
|
67
|
+
const hasParams = parseRouteSegments(path).some((segment) => segment.kind === 'param')
|
|
68
|
+
const [first, second] = rest as [unknown, NavigateOptions?]
|
|
69
|
+
/* Params ride url()'s first vararg slot — a path with `[name]` segments interpolates
|
|
70
|
+
them, a paramless one ignores undefined and just base-prefixes. Widen `path` to
|
|
71
|
+
string so url() takes its plain-path overload; its runtime keys off the segments. */
|
|
72
|
+
const params = hasParams
|
|
73
|
+
? (first as Record<string, string | number | boolean | undefined>)
|
|
74
|
+
: undefined
|
|
75
|
+
const options = (hasParams ? second : (first as NavigateOptions | undefined)) ?? {}
|
|
76
|
+
navigatePath(url(path as string, params), options)
|
|
77
|
+
}
|
package/src/lib/ui/outbox.ts
CHANGED
|
@@ -1,115 +1,35 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { createDoc as doc } from './runtime/createDoc.ts'
|
|
5
|
-
import type { Outbox } from './types/Outbox.ts'
|
|
6
|
-
import type { PersistenceStore } from './types/PersistenceStore.ts'
|
|
1
|
+
import type { OutboxEntry } from '../shared/types/OutboxEntry.ts'
|
|
2
|
+
import type { RemoteFunction } from '../shared/types/RemoteFunction.ts'
|
|
3
|
+
import { outboxRegistry } from './rpcOutbox/outboxRegistry.ts'
|
|
7
4
|
|
|
8
|
-
/* One
|
|
9
|
-
type OutboxEntry<
|
|
5
|
+
/* One entry in the global outbox, tagged with the RPC it belongs to. */
|
|
6
|
+
export type GlobalOutboxEntry = OutboxEntry<unknown> & { rpc: RemoteFunction<unknown, unknown> }
|
|
7
|
+
|
|
8
|
+
/* The global outbox: callable for the flat entry list, awaitable `retry()` to drain every
|
|
9
|
+
queue (resolves when every replay settles). */
|
|
10
|
+
export type GlobalOutbox = (() => GlobalOutboxEntry[]) & { retry: () => Promise<void> }
|
|
11
|
+
|
|
12
|
+
function list(): GlobalOutboxEntry[] {
|
|
13
|
+
return outboxRegistry.all().flatMap(({ rpc, queue }) =>
|
|
14
|
+
queue.entries().map((entry) => ({
|
|
15
|
+
...entry,
|
|
16
|
+
rpc: rpc as RemoteFunction<unknown, unknown>,
|
|
17
|
+
})),
|
|
18
|
+
)
|
|
19
|
+
}
|
|
10
20
|
|
|
11
21
|
/*
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
(the effect below re-drains on the online edge);
|
|
20
|
-
- rejected while still online → a real server/validation failure: drop the entry
|
|
21
|
-
so the queue can't wedge, and report it via `onDrop` so the caller can roll back
|
|
22
|
-
the optimistic change it applied (e.g. `history().undo()` or a `cache.patch`).
|
|
23
|
-
Delivery is at-least-once (a crash between a successful send and its dequeue
|
|
24
|
-
re-sends on reload), so `send` should be idempotent. Reconnect RESYNC of reads is
|
|
25
|
-
`cache.on`'s job, not this. Client-intended: `send` performs a network mutation.
|
|
22
|
+
The global, reactive view of every durable RPC's outbox — a flat list of undelivered
|
|
23
|
+
entries across all `outbox` rpcs, each tagged with its `rpc`. Reactive: reading it in a
|
|
24
|
+
template/effect subscribes to each registered queue, so it updates as writes park, drain,
|
|
25
|
+
fail, or cancel. Use it for an app-wide "N unsynced" badge or a sync panel; cancel a
|
|
26
|
+
pending write through `entry.controller.abort()`, retry one through `entry.retry()`, or
|
|
27
|
+
drain everything through `outbox.retry()`. A single rpc's slice is `rpc.outbox`. Server-
|
|
28
|
+
side there are no client queues, so it returns an empty list.
|
|
26
29
|
*/
|
|
27
30
|
// @documentation ui
|
|
28
|
-
export
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
onDrop,
|
|
34
|
-
}: {
|
|
35
|
-
key: string
|
|
36
|
-
send: (payload: T) => Promise<void>
|
|
37
|
-
store?: PersistenceStore
|
|
38
|
-
online?: () => boolean
|
|
39
|
-
onDrop?: (payload: T, error: unknown) => void
|
|
40
|
-
}): Outbox<T> {
|
|
41
|
-
const queue = doc({ items: [] as OutboxEntry<T>[] })
|
|
42
|
-
/* `debounce: 0` because the queue writes itself synchronously below — a queued
|
|
43
|
-
mutation must be durable the instant it's recorded, not after a debounce
|
|
44
|
-
window where a crash could lose it. */
|
|
45
|
-
const persistence = persist(queue, key, { store, debounce: 0 })
|
|
46
|
-
/* Drop the head and make the dequeue durable at once, so a reload never re-sends
|
|
47
|
-
an entry already acknowledged. */
|
|
48
|
-
const dequeue = (): void => {
|
|
49
|
-
queue.remove('items/0')
|
|
50
|
-
persistence.flush()
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/* At most one drain runs at a time; `settled` lets a caller await the active one.
|
|
54
|
-
A drain re-reads the queue each step, so entries enqueued mid-drain are picked
|
|
55
|
-
up by the running loop. An empty drain finishes synchronously (it hits no
|
|
56
|
-
`await`), so `draining` is back to false before the next `enqueue` — there is no
|
|
57
|
-
resolved-but-still-flagged window for a new entry to fall into. */
|
|
58
|
-
let draining = false
|
|
59
|
-
let settled: Promise<void> = Promise.resolve()
|
|
60
|
-
const flush = (): Promise<void> => {
|
|
61
|
-
if (draining) {
|
|
62
|
-
return settled
|
|
63
|
-
}
|
|
64
|
-
draining = true
|
|
65
|
-
settled = (async () => {
|
|
66
|
-
try {
|
|
67
|
-
while (online()) {
|
|
68
|
-
const { items } = queue.snapshot() as { items: OutboxEntry<T>[] }
|
|
69
|
-
const head = items[0]
|
|
70
|
-
if (head === undefined) {
|
|
71
|
-
break
|
|
72
|
-
}
|
|
73
|
-
try {
|
|
74
|
-
await send(head.payload)
|
|
75
|
-
} catch (error) {
|
|
76
|
-
/* Went offline mid-send → keep the head, retry on reconnect. */
|
|
77
|
-
if (!online()) {
|
|
78
|
-
break
|
|
79
|
-
}
|
|
80
|
-
/* Online but rejected → permanent: drop it, report for rollback. */
|
|
81
|
-
dequeue()
|
|
82
|
-
onDrop?.(head.payload, error)
|
|
83
|
-
continue
|
|
84
|
-
}
|
|
85
|
-
dequeue()
|
|
86
|
-
}
|
|
87
|
-
} finally {
|
|
88
|
-
draining = false
|
|
89
|
-
}
|
|
90
|
-
})()
|
|
91
|
-
return settled
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/* Re-drain whenever connectivity returns — reading `online()` subscribes this
|
|
95
|
-
effect, so the offline→online edge fires it. */
|
|
96
|
-
const stop = effect(() => {
|
|
97
|
-
if (online()) {
|
|
98
|
-
void flush()
|
|
99
|
-
}
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
return {
|
|
103
|
-
enqueue: (payload: T) => {
|
|
104
|
-
queue.add('items/-', { id: crypto.randomUUID(), payload })
|
|
105
|
-
persistence.flush() // durable before we attempt the network
|
|
106
|
-
void flush()
|
|
107
|
-
},
|
|
108
|
-
pending: () => (queue.read('items') as OutboxEntry<T>[]).map((entry) => entry.payload),
|
|
109
|
-
flush,
|
|
110
|
-
dispose: () => {
|
|
111
|
-
stop()
|
|
112
|
-
persistence.dispose()
|
|
113
|
-
},
|
|
114
|
-
}
|
|
115
|
-
}
|
|
31
|
+
export const outbox: GlobalOutbox = Object.assign(list, {
|
|
32
|
+
async retry(): Promise<void> {
|
|
33
|
+
await Promise.all(outboxRegistry.all().map(({ queue }) => queue.retry()))
|
|
34
|
+
},
|
|
35
|
+
})
|
|
@@ -6,17 +6,34 @@ import { HttpError } from '../shared/HttpError.ts'
|
|
|
6
6
|
import { OFFLINE_HEADER } from '../shared/OFFLINE_HEADER.ts'
|
|
7
7
|
import { rpcTimeoutSlot } from '../shared/rpcTimeoutSlot.ts'
|
|
8
8
|
import { trace } from '../shared/trace.ts'
|
|
9
|
-
import type {
|
|
9
|
+
import type { HttpMethod } from '../shared/types/HttpMethod.ts'
|
|
10
|
+
import type { Outbox } from '../shared/types/Outbox.ts'
|
|
10
11
|
import type { RemoteFunction } from '../shared/types/RemoteFunction.ts'
|
|
11
12
|
import type { RpcOptions } from '../shared/types/RpcOptions.ts'
|
|
13
|
+
import { UNREACHABLE_STATUSES } from '../shared/UNREACHABLE_STATUSES.ts'
|
|
12
14
|
import { withBase } from '../shared/withBase.ts'
|
|
15
|
+
import { createOutboxQueue, type OutboxQueue } from './rpcOutbox/createOutboxQueue.ts'
|
|
16
|
+
import { outboxRegistry } from './rpcOutbox/outboxRegistry.ts'
|
|
13
17
|
import { currentAbortSignal } from './runtime/currentAbortSignal.ts'
|
|
14
18
|
import { REQUEST_SUPERSEDED } from './runtime/REQUEST_SUPERSEDED.ts'
|
|
19
|
+
import type { PersistenceStore } from './types/PersistenceStore.ts'
|
|
20
|
+
|
|
21
|
+
/* The framework-reserved `HttpError.kind` for a request the durable outbox parked because
|
|
22
|
+
the server was unreachable — distinct from a handler-declared error name. Lets a caller
|
|
23
|
+
branch with `error instanceof HttpError && error.kind === 'queued'`. */
|
|
24
|
+
const QUEUED = 'queued'
|
|
25
|
+
|
|
26
|
+
/* A durable RPC's per-call options. `outbox: true` parks an unreachable call for replay;
|
|
27
|
+
`store` exists for testing — production uses the default persistence store. */
|
|
28
|
+
export type DurableOptions = {
|
|
29
|
+
outbox?: boolean
|
|
30
|
+
store?: PersistenceStore
|
|
31
|
+
}
|
|
15
32
|
|
|
16
33
|
/*
|
|
17
|
-
Client-side substitute for a
|
|
18
|
-
call per
|
|
19
|
-
target uses
|
|
34
|
+
Client-side substitute for a rpc-defined handler. The bundler emits one
|
|
35
|
+
call per rpc export inside an `$rpc/**` module (GET / POST / …): server
|
|
36
|
+
target uses defineRpc (real handler), browser target uses remoteProxy
|
|
20
37
|
(fetch over the network). Both paths produce identical RemoteFunction
|
|
21
38
|
shapes and identical WeakMap metadata so cache() works the same on either
|
|
22
39
|
side.
|
|
@@ -25,13 +42,26 @@ side.
|
|
|
25
42
|
the query string (GET/DELETE/HEAD). Plain `fn(args)` decodes the Response
|
|
26
43
|
by Content-Type and throws HttpError on non-2xx; `.raw(args)` is the
|
|
27
44
|
escape hatch that returns the Response untouched.
|
|
45
|
+
|
|
46
|
+
A durable (`outbox: true`) rpc is still a normal RemoteFunction — it fetches and
|
|
47
|
+
throws exactly the same. The differences: when the server can't be reached
|
|
48
|
+
(a transport failure, or a 502/503/504/52x), the request is `park`ed onto the
|
|
49
|
+
RPC's app-owned outbox as a SIDE-EFFECT and the throw is a `kind: 'queued'`
|
|
50
|
+
HttpError; and once a backlog exists, a fresh call parks straight to the TAIL
|
|
51
|
+
(no fetch) so writes can't land out of order. The parked write waits for
|
|
52
|
+
`rpc.outbox.retry()` (or the global `outbox.retry()`) — there is no auto-drain;
|
|
53
|
+
the app owns when to replay. `rpc.outbox()` exposes the queue.
|
|
28
54
|
*/
|
|
29
55
|
// @documentation plumbing
|
|
30
56
|
export function remoteProxy<Args, Return>(
|
|
31
|
-
method:
|
|
57
|
+
method: HttpMethod,
|
|
32
58
|
url: string,
|
|
59
|
+
durable?: DurableOptions,
|
|
33
60
|
): RemoteFunction<Args, Return> {
|
|
34
|
-
|
|
61
|
+
/* Assigned after `createRemoteFunction` so the invoke closure (which runs later, per
|
|
62
|
+
call) parks through the shared queue; undefined leaves the plain fetch path. */
|
|
63
|
+
let queue: OutboxQueue<Args> | undefined
|
|
64
|
+
const fn = createRemoteFunction<Args, Return>({
|
|
35
65
|
method,
|
|
36
66
|
url,
|
|
37
67
|
clients: browserClientFlags,
|
|
@@ -49,12 +79,132 @@ export function remoteProxy<Args, Return>(
|
|
|
49
79
|
headers: rpcHeaders(opts?.headers),
|
|
50
80
|
}),
|
|
51
81
|
/*
|
|
52
|
-
Forcing `getRequest()` once builds the Request and seeds the
|
|
53
|
-
|
|
54
|
-
|
|
82
|
+
Forcing `getRequest()` once builds the Request and seeds the cache meta thunk in
|
|
83
|
+
createRemoteFunction with the same instance, so cache() readers don't reconstruct
|
|
84
|
+
it. On a durable rpc, an unreachable result parks a pristine CLONE and throws a
|
|
85
|
+
`queued`-tagged HttpError — `fetch` consumes the original (its body stream is read
|
|
86
|
+
and locked), so parking that same instance would leave the queue a request a resend
|
|
87
|
+
can't reconstruct and a capture can't read. The clone is parked, the original is
|
|
88
|
+
sent. The throw lets the caller branch on `error.kind === 'queued'` (parked, will
|
|
89
|
+
retry) vs. a real server rejection; `error.data` is the parked entry, so a caller
|
|
90
|
+
can `await (error.data as OutboxEntry).settled` for the eventual outcome.
|
|
55
91
|
*/
|
|
56
|
-
invoke: (
|
|
92
|
+
invoke: (args, getRequest, opts) => {
|
|
93
|
+
if (queue === undefined) {
|
|
94
|
+
return fetchWithTimeout(getRequest(), opts)
|
|
95
|
+
}
|
|
96
|
+
/* A non-empty queue means an undelivered backlog: park this call at the TAIL
|
|
97
|
+
and throw, rather than let a live fetch leapfrog the older writes and land
|
|
98
|
+
out of order. `retry()` then flushes the whole queue FIFO. */
|
|
99
|
+
if (queue.size() > 0) {
|
|
100
|
+
return Promise.reject(
|
|
101
|
+
queuedThrow(
|
|
102
|
+
queue,
|
|
103
|
+
args as Args,
|
|
104
|
+
getRequest().clone(),
|
|
105
|
+
unreachableResponse(),
|
|
106
|
+
undefined,
|
|
107
|
+
),
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
const request = getRequest()
|
|
111
|
+
const parkable = request.clone()
|
|
112
|
+
return fetchWithTimeout(request, opts).then(
|
|
113
|
+
(response) => {
|
|
114
|
+
if (UNREACHABLE_STATUSES.has(response.status)) {
|
|
115
|
+
throw queuedThrow(
|
|
116
|
+
queue,
|
|
117
|
+
args as Args,
|
|
118
|
+
parkable,
|
|
119
|
+
response,
|
|
120
|
+
new HttpError(response.clone()),
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
return response
|
|
124
|
+
},
|
|
125
|
+
(error: unknown) => {
|
|
126
|
+
if (shouldParkRejection(error)) {
|
|
127
|
+
const response =
|
|
128
|
+
error instanceof HttpError ? error.response : unreachableResponse()
|
|
129
|
+
throw queuedThrow(queue, args as Args, parkable, response, error)
|
|
130
|
+
}
|
|
131
|
+
throw error
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
},
|
|
57
135
|
})
|
|
136
|
+
if (durable?.outbox === true) {
|
|
137
|
+
queue = getOrCreateOutboxQueue<Args, Return>(url, fn, durable)
|
|
138
|
+
Object.assign(fn, { outbox: outboxFace(queue) })
|
|
139
|
+
}
|
|
140
|
+
return fn
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* The synthetic "unreachable" Response a park reuses when there is no real one — a
|
|
144
|
+
transport failure (fetch rejected) or a backlog park that never fetched. */
|
|
145
|
+
function unreachableResponse(): Response {
|
|
146
|
+
return new Response('queued', { status: 503, statusText: 'Service Unavailable' })
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/* Park the unreachable request (`cause` becomes the entry's parked reason, `entry.error`)
|
|
150
|
+
and return the `kind: 'queued'` HttpError to throw — its `.data` is the parked entry, so
|
|
151
|
+
a caller can `await (error.data as OutboxEntry).settled` for the eventual delivered
|
|
152
|
+
result or server refusal. */
|
|
153
|
+
function queuedThrow<Args>(
|
|
154
|
+
queue: OutboxQueue<Args> | undefined,
|
|
155
|
+
args: Args,
|
|
156
|
+
request: Request,
|
|
157
|
+
response: Response,
|
|
158
|
+
cause: unknown,
|
|
159
|
+
): HttpError {
|
|
160
|
+
const entry = queue?.park(args, request, cause)
|
|
161
|
+
return new HttpError(response, QUEUED, entry)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* A fetch REJECTION (no Response) the durable rpc should park: a transport failure or
|
|
165
|
+
the synthesized client-timeout 504. NOT a caller/scope abort — that's a deliberate
|
|
166
|
+
cancel, not the server being unreachable. (HTTP error STATUSES never reject — `fetch`
|
|
167
|
+
resolves with them — so 4xx/500 are classified on the response, not here.) */
|
|
168
|
+
function shouldParkRejection(error: unknown): boolean {
|
|
169
|
+
if (error === REQUEST_SUPERSEDED) {
|
|
170
|
+
return false
|
|
171
|
+
}
|
|
172
|
+
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
173
|
+
return false
|
|
174
|
+
}
|
|
175
|
+
if (error instanceof HttpError) {
|
|
176
|
+
return UNREACHABLE_STATUSES.has(error.response.status)
|
|
177
|
+
}
|
|
178
|
+
return true
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/* The single app-owned queue for a durable RPC url — created + registered on first use
|
|
182
|
+
so every call site (and the global `outbox()`) shares one queue. The send is a plain
|
|
183
|
+
`fetch`; createOutboxQueue rides the entry's abort signal on the resent Request and
|
|
184
|
+
keeps scope-abort + the client timeout out. */
|
|
185
|
+
function getOrCreateOutboxQueue<Args, Return>(
|
|
186
|
+
url: string,
|
|
187
|
+
rpc: RemoteFunction<Args, Return>,
|
|
188
|
+
durable: DurableOptions,
|
|
189
|
+
): OutboxQueue<Args> {
|
|
190
|
+
const existing = outboxRegistry.get(url)
|
|
191
|
+
if (existing !== undefined) {
|
|
192
|
+
return existing as OutboxQueue<Args>
|
|
193
|
+
}
|
|
194
|
+
const queue = createOutboxQueue<Args>({
|
|
195
|
+
url,
|
|
196
|
+
send: (request) => fetch(request),
|
|
197
|
+
store: durable.store,
|
|
198
|
+
})
|
|
199
|
+
outboxRegistry.register(url, queue as OutboxQueue<unknown>, rpc)
|
|
200
|
+
return queue
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/* The `.outbox` face: callable for the live entries, `retry()` to drain on demand. */
|
|
204
|
+
function outboxFace<Args>(queue: OutboxQueue<Args>): Outbox<Args> {
|
|
205
|
+
const face = (() => queue.entries()) as Outbox<Args>
|
|
206
|
+
face.retry = () => queue.retry()
|
|
207
|
+
return face
|
|
58
208
|
}
|
|
59
209
|
|
|
60
210
|
/*
|