@abide/abide 0.42.0 → 0.43.1
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 +41 -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 +89 -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 +45 -15
- 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
|
@@ -2,12 +2,14 @@ import ts from 'typescript'
|
|
|
2
2
|
import { TS_PRINTER } from './TS_PRINTER.ts'
|
|
3
3
|
|
|
4
4
|
/*
|
|
5
|
-
Removes
|
|
5
|
+
Removes effect calls from a script for the SSR back-end. Effects are client
|
|
6
6
|
lifecycle — they touch the DOM / run side effects and emit no HTML, so the server
|
|
7
7
|
render (a snapshot of the pre-effect markup, like every framework) must not run
|
|
8
|
-
them.
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
them. Both surfaces are stripped: the generated/runtime bare `effect(<args>)` and
|
|
9
|
+
the authored scope form `scope().effect(<args>)` (and a captured/destructured handle
|
|
10
|
+
`c.effect(<args>)`), each replaced by `undefined` — an `effect(() => …)` statement
|
|
11
|
+
becomes a no-op, and a `const stop = effect(…)` binding keeps a defined (unused) name.
|
|
12
|
+
Client compilation keeps effects untouched.
|
|
11
13
|
*/
|
|
12
14
|
export function stripEffects(code: string): string {
|
|
13
15
|
const source = ts.createSourceFile('script.ts', code, ts.ScriptTarget.Latest, true)
|
|
@@ -23,11 +25,7 @@ export function stripEffects(code: string): string {
|
|
|
23
25
|
export function stripEffectsTransformer(): ts.TransformerFactory<ts.SourceFile> {
|
|
24
26
|
return (context) => (root) => {
|
|
25
27
|
const visit = (node: ts.Node): ts.Node => {
|
|
26
|
-
if (
|
|
27
|
-
ts.isCallExpression(node) &&
|
|
28
|
-
ts.isIdentifier(node.expression) &&
|
|
29
|
-
node.expression.text === 'effect'
|
|
30
|
-
) {
|
|
28
|
+
if (ts.isCallExpression(node) && isEffectCallee(node.expression)) {
|
|
31
29
|
return ts.factory.createIdentifier('undefined')
|
|
32
30
|
}
|
|
33
31
|
return ts.visitEachChild(node, visit, context)
|
|
@@ -35,3 +33,13 @@ export function stripEffectsTransformer(): ts.TransformerFactory<ts.SourceFile>
|
|
|
35
33
|
return ts.visitNode(root, visit) as ts.SourceFile
|
|
36
34
|
}
|
|
37
35
|
}
|
|
36
|
+
|
|
37
|
+
/* An effect callee: the bare runtime helper (`effect`) or the scope-method form
|
|
38
|
+
(`scope().effect`, `c.effect`) the author writes. Receiver-agnostic on the `.effect`
|
|
39
|
+
member name, matching `effect` being reserved as a scope primitive. */
|
|
40
|
+
function isEffectCallee(expression: ts.Expression): boolean {
|
|
41
|
+
if (ts.isIdentifier(expression)) {
|
|
42
|
+
return expression.text === 'effect'
|
|
43
|
+
}
|
|
44
|
+
return ts.isPropertyAccessExpression(expression) && expression.name.text === 'effect'
|
|
45
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { TemplateNode } from './types/TemplateNode.ts'
|
|
2
|
+
|
|
3
|
+
type Case = Extract<TemplateNode, { kind: 'case' }>
|
|
4
|
+
|
|
5
|
+
/* The structural shape of a `switch`, resolved once so the build and SSR back-ends share one
|
|
6
|
+
reading of its cases and only own emission. Both previously recomputed the `case` filter;
|
|
7
|
+
SSR additionally located the match-less default. */
|
|
8
|
+
export type SwitchPlan = {
|
|
9
|
+
/* The `case` branches in source order (`match` set; the default leaves it unset). */
|
|
10
|
+
cases: Case[]
|
|
11
|
+
/* The match-less default branch, if present. */
|
|
12
|
+
fallback: Case | undefined
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/* Resolves a `switch` node's cases into the shared structural plan. */
|
|
16
|
+
export function switchPlan(node: Extract<TemplateNode, { kind: 'switch' }>): SwitchPlan {
|
|
17
|
+
const cases = node.children.filter((child): child is Case => child.kind === 'case')
|
|
18
|
+
return {
|
|
19
|
+
cases,
|
|
20
|
+
fallback: cases.find((branch) => branch.match === undefined),
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -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
|
+
})
|