@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.
Files changed (146) hide show
  1. package/AGENTS.md +234 -300
  2. package/CHANGELOG.md +34 -0
  3. package/README.md +50 -93
  4. package/package.json +3 -2
  5. package/src/abideResolverPlugin.ts +104 -14
  6. package/src/buildCli.ts +1 -1
  7. package/src/discoveryEntry.ts +3 -3
  8. package/src/lib/bundle/BundleMenuItem.ts +1 -1
  9. package/src/lib/cli/createClient.ts +12 -12
  10. package/src/lib/cli/dispatchCommand.ts +1 -1
  11. package/src/lib/cli/printSessionStatus.ts +1 -1
  12. package/src/lib/cli/resolveCliTarget.ts +1 -1
  13. package/src/lib/cli/runCli.ts +1 -1
  14. package/src/lib/cli/types/CliManifest.ts +1 -1
  15. package/src/lib/cli/types/CliManifestEntry.ts +2 -2
  16. package/src/lib/mcp/annotationsForMethod.ts +5 -5
  17. package/src/lib/mcp/createMcpServer.ts +3 -3
  18. package/src/lib/mcp/mcpSurface.ts +14 -14
  19. package/src/lib/mcp/types/McpServerOptions.ts +1 -1
  20. package/src/lib/server/DELETE.ts +4 -4
  21. package/src/lib/server/GET.ts +4 -4
  22. package/src/lib/server/HEAD.ts +4 -4
  23. package/src/lib/server/PATCH.ts +4 -4
  24. package/src/lib/server/POST.ts +4 -4
  25. package/src/lib/server/PUT.ts +4 -4
  26. package/src/lib/server/agent.ts +4 -4
  27. package/src/lib/server/env.ts +1 -1
  28. package/src/lib/server/error.ts +49 -7
  29. package/src/lib/server/json.ts +1 -1
  30. package/src/lib/server/rpc/buildErrorConstructors.ts +19 -0
  31. package/src/lib/server/rpc/{defineVerb.ts → defineRpc.ts} +52 -34
  32. package/src/lib/server/rpc/{dispatchVerbInProcess.ts → dispatchRpcInProcess.ts} +3 -3
  33. package/src/lib/server/rpc/fieldErrorsFromIssues.ts +23 -0
  34. package/src/lib/server/rpc/{findVerbByCommandName.ts → findRpcByCommandName.ts} +5 -5
  35. package/src/lib/server/rpc/parseArgs.ts +6 -6
  36. package/src/lib/server/rpc/readBodyWithinLimit.ts +2 -2
  37. package/src/lib/server/rpc/registerRpc.ts +6 -0
  38. package/src/lib/server/rpc/{verbRegistry.ts → rpcRegistry.ts} +4 -4
  39. package/src/lib/server/rpc/{runWithVerbTimeout.ts → runWithRpcTimeout.ts} +4 -4
  40. package/src/lib/server/rpc/types/RemoteHandler.ts +9 -3
  41. package/src/lib/server/rpc/types/{VerbHelper.ts → RpcHelper.ts} +57 -23
  42. package/src/lib/server/rpc/types/{VerbRegistryEntry.ts → RpcRegistryEntry.ts} +3 -3
  43. package/src/lib/server/rpc/types/TypedResponse.ts +2 -2
  44. package/src/lib/server/rpc/unprocessed.ts +6 -7
  45. package/src/lib/server/rpc/validationError.ts +17 -0
  46. package/src/lib/server/runtime/buildInspectorSurface.ts +7 -7
  47. package/src/lib/server/runtime/buildOpenApiSpec.ts +6 -6
  48. package/src/lib/server/runtime/createRouteDispatcher.ts +7 -7
  49. package/src/lib/server/runtime/createServer.ts +4 -4
  50. package/src/lib/server/runtime/crossOriginForbidden.ts +1 -1
  51. package/src/lib/server/runtime/crossOriginGate.ts +4 -4
  52. package/src/lib/server/runtime/logExposedSurfaces.ts +4 -4
  53. package/src/lib/server/runtime/registryManifests.ts +2 -2
  54. package/src/lib/server/runtime/types/InspectorCacheEntry.ts +1 -1
  55. package/src/lib/server/runtime/types/InspectorRpc.ts +27 -0
  56. package/src/lib/server/runtime/types/InspectorSurface.ts +4 -4
  57. package/src/lib/server/runtime/types/RequestStore.ts +1 -1
  58. package/src/lib/server/runtime/warnUnguardedMcp.ts +2 -2
  59. package/src/lib/server/sockets/createSocketDispatcher.ts +15 -14
  60. package/src/lib/server/sockets/types/SocketOperation.ts +2 -2
  61. package/src/lib/shared/HttpError.ts +15 -1
  62. package/src/lib/shared/UNREACHABLE_STATUSES.ts +13 -0
  63. package/src/lib/shared/buildRpcRequest.ts +5 -5
  64. package/src/lib/shared/cache.ts +50 -168
  65. package/src/lib/shared/carriesBodyArgs.ts +4 -4
  66. package/src/lib/shared/createCacheStore.ts +12 -0
  67. package/src/lib/shared/createRemoteFunction.ts +11 -6
  68. package/src/lib/shared/decodeResponse.ts +2 -2
  69. package/src/lib/shared/detectRpcMethod.ts +17 -0
  70. package/src/lib/shared/emitLogRecord.ts +3 -3
  71. package/src/lib/shared/extraForwardHeaders.ts +1 -1
  72. package/src/lib/shared/findExportCallSite.ts +30 -273
  73. package/src/lib/shared/forwardHeaders.ts +2 -2
  74. package/src/lib/shared/httpErrorFor.ts +26 -0
  75. package/src/lib/shared/isAsciiWhitespace.ts +5 -0
  76. package/src/lib/shared/isIdentPart.ts +9 -0
  77. package/src/lib/shared/isIdentStart.ts +8 -0
  78. package/src/lib/shared/isReadOnlyMethod.ts +4 -4
  79. package/src/lib/shared/keyForRemoteCall.ts +4 -4
  80. package/src/lib/shared/log.ts +1 -1
  81. package/src/lib/shared/outboxProbeSlot.ts +20 -0
  82. package/src/lib/shared/pending.ts +4 -1
  83. package/src/lib/shared/prepareRpcModule.ts +96 -14
  84. package/src/lib/shared/probeRegistries.ts +17 -4
  85. package/src/lib/shared/queryStringFromArgs.ts +1 -1
  86. package/src/lib/shared/remoteMetaStore.ts +2 -2
  87. package/src/lib/shared/resolveClientFlags.ts +1 -1
  88. package/src/lib/shared/skipNonCode.ts +230 -0
  89. package/src/lib/shared/streamResponse.ts +9 -6
  90. package/src/lib/shared/stripImport.ts +3 -3
  91. package/src/lib/shared/types/CacheEntry.ts +2 -4
  92. package/src/lib/shared/types/CacheOnContext.ts +1 -11
  93. package/src/lib/shared/types/CacheStore.ts +13 -7
  94. package/src/lib/shared/types/ClientFlags.ts +1 -1
  95. package/src/lib/shared/types/ErrorConstructors.ts +17 -0
  96. package/src/lib/shared/types/ErrorDescriptor.ts +10 -0
  97. package/src/lib/shared/types/ErrorSpec.ts +11 -0
  98. package/src/lib/shared/types/HttpMethod.ts +1 -0
  99. package/src/lib/shared/types/Outbox.ts +9 -0
  100. package/src/lib/shared/types/OutboxEntry.ts +27 -0
  101. package/src/lib/shared/types/RawRemoteFunction.ts +2 -2
  102. package/src/lib/shared/types/RemoteCallable.ts +2 -2
  103. package/src/lib/shared/types/RemoteFunction.ts +21 -6
  104. package/src/lib/shared/types/RequestScopeInfo.ts +1 -1
  105. package/src/lib/shared/types/RpcErrorGuard.ts +40 -0
  106. package/src/lib/shared/types/RpcInvoker.ts +1 -1
  107. package/src/lib/shared/types/StandardSchemaV1.ts +1 -1
  108. package/src/lib/shared/types/ValidationErrorData.ts +20 -0
  109. package/src/lib/shared/url.ts +3 -3
  110. package/src/lib/shared/writeRpcDts.ts +7 -7
  111. package/src/lib/shared/writeTestRpcDts.ts +3 -3
  112. package/src/lib/test/createTestApp.ts +10 -10
  113. package/src/lib/ui/compile/awaitPlan.ts +48 -0
  114. package/src/lib/ui/compile/compileShadow.ts +75 -21
  115. package/src/lib/ui/compile/desugarSignals.ts +42 -6
  116. package/src/lib/ui/compile/generateBuild.ts +43 -60
  117. package/src/lib/ui/compile/generateSSR.ts +84 -54
  118. package/src/lib/ui/compile/ifPlan.ts +30 -0
  119. package/src/lib/ui/compile/parseTemplate.ts +41 -11
  120. package/src/lib/ui/compile/stripEffects.ts +17 -9
  121. package/src/lib/ui/compile/switchPlan.ts +22 -0
  122. package/src/lib/ui/compile/tryPlan.ts +29 -0
  123. package/src/lib/ui/compile/types/TemplateAttr.ts +8 -2
  124. package/src/lib/ui/compile/types/TemplateNode.ts +6 -2
  125. package/src/lib/ui/createScope.ts +20 -2
  126. package/src/lib/ui/dom/disposeRange.ts +6 -10
  127. package/src/lib/ui/dom/hydrate.ts +2 -5
  128. package/src/lib/ui/dom/mount.ts +1 -2
  129. package/src/lib/ui/dom/withScope.ts +6 -9
  130. package/src/lib/ui/effect.ts +4 -2
  131. package/src/lib/ui/navigate.ts +40 -13
  132. package/src/lib/ui/outbox.ts +30 -110
  133. package/src/lib/ui/remoteProxy.ts +160 -10
  134. package/src/lib/ui/router.ts +3 -3
  135. package/src/lib/ui/rpcOutbox/createOutboxQueue.ts +281 -0
  136. package/src/lib/ui/rpcOutbox/outboxRegistry.ts +69 -0
  137. package/src/lib/ui/socketChannel.ts +7 -1
  138. package/src/lib/ui/types/Scope.ts +20 -5
  139. package/template/CLAUDE.md +1 -1
  140. package/template/src/server/rpc/getHello.ts +3 -3
  141. package/template/test/app.test.ts +2 -2
  142. package/src/lib/server/rpc/registerVerb.ts +0 -6
  143. package/src/lib/server/runtime/types/InspectorVerb.ts +0 -27
  144. package/src/lib/shared/detectVerbMethod.ts +0 -17
  145. package/src/lib/shared/types/HttpVerb.ts +0 -1
  146. package/src/lib/ui/types/Outbox.ts +0 -14
@@ -0,0 +1,27 @@
1
+ /* The lifecycle status of a parked mutation: waiting to (re)send, or currently sending.
2
+ A write lives in the outbox ONLY while it can't reach the server; the instant the server
3
+ responds at all — a 2xx OR a real 4xx/500 — the entry leaves the queue, so there is no
4
+ terminal `error` state. Only the unreachable family (transport failure / 502/503/504/52x)
5
+ keeps it `queued`. */
6
+ export type OutboxStatus = 'queued' | 'sending'
7
+
8
+ /* One durable, replayable mutation in an RPC's outbox — parked because the original
9
+ call couldn't reach the server (transport failure, or a 502/503/504/52x). `controller`
10
+ is the entry's own abort handle (cancel = `controller.abort()`); `request` is the
11
+ synthesized, persisted Request the drain re-sends; `args` is the typed input (for
12
+ rendering); `error` is why it's parked (the `kind: 'queued'` HttpError from the
13
+ unreachable attempt); `retry()` kicks a FIFO drain. `settled` is the eventual outcome
14
+ of THIS write as if the original call had reached the server — it resolves with the
15
+ decoded result on delivery and rejects with the real HttpError on a server refusal (or
16
+ an AbortError on cancel). It may stay pending indefinitely if the write is never
17
+ replayed; reading it is what arms it (a never-read `settled` never rejects). */
18
+ export type OutboxEntry<Args> = {
19
+ id: string
20
+ controller: AbortController
21
+ request: Request
22
+ args: Args
23
+ status: OutboxStatus
24
+ error?: unknown
25
+ retry: () => Promise<void>
26
+ settled: Promise<unknown>
27
+ }
@@ -1,4 +1,4 @@
1
- import type { HttpVerb } from './HttpVerb.ts'
1
+ import type { HttpMethod } from './HttpMethod.ts'
2
2
  import type { RemoteCallable } from './RemoteCallable.ts'
3
3
 
4
4
  /*
@@ -9,6 +9,6 @@ so callers that need status / headers / body streaming or want to
9
9
  implement custom error handling can opt out of the decode.
10
10
  */
11
11
  export type RawRemoteFunction<Args> = RemoteCallable<Args, Response> & {
12
- readonly method: HttpVerb
12
+ readonly method: HttpMethod
13
13
  readonly url: string
14
14
  }
@@ -2,8 +2,8 @@ import type { RpcOptions } from './RpcOptions.ts'
2
2
 
3
3
  /*
4
4
  Call signature shared by RemoteFunction and RawRemoteFunction. The base
5
- signature keeps `args` required so a schema'd verb can't silently drop its
6
- input; when `Args` admits undefined (no-input verbs) an intersected
5
+ signature keeps `args` required so a schema'd rpc can't silently drop its
6
+ input; when `Args` admits undefined (no-input rpcs) an intersected
7
7
  optional-arg signature lets call sites write `fn()` instead of
8
8
  `fn(undefined)`. Intersection rather than a bare conditional so the type
9
9
  stays callable while `Args` is still generic (cache() invokes producers
@@ -1,7 +1,10 @@
1
1
  import type { ClientFlags } from './ClientFlags.ts'
2
- import type { HttpVerb } from './HttpVerb.ts'
2
+ import type { ErrorSpec } from './ErrorSpec.ts'
3
+ import type { HttpMethod } from './HttpMethod.ts'
4
+ import type { Outbox } from './Outbox.ts'
3
5
  import type { RawRemoteFunction } from './RawRemoteFunction.ts'
4
6
  import type { RemoteCallable } from './RemoteCallable.ts'
7
+ import type { RpcErrorGuard } from './RpcErrorGuard.ts'
5
8
  import type { Subscribable } from './Subscribable.ts'
6
9
 
7
10
  /*
@@ -25,23 +28,35 @@ HTTP rpc isn't the place for long-lived multi-publisher subscriptions.
25
28
  `.fetch(req)` is the framework's request-dispatch entry point — used by
26
29
  the router to invoke the handler from an incoming HTTP request, not
27
30
  for user code.
28
- `crossOrigin` (server-side only, set via the verb's opts) exempts a
29
- mutating verb from the router's same-origin CSRF gate, accepting browser
31
+ `crossOrigin` (server-side only, set via the rpc's opts) exempts a
32
+ mutating rpc from the router's same-origin CSRF gate, accepting browser
30
33
  requests whose Origin doesn't match the app's own host.
31
34
  */
32
35
  /*
33
- A body verb (POST/PUT/PATCH) also accepts a FormData in place of typed args:
36
+ A body rpc (POST/PUT/PATCH) also accepts a FormData in place of typed args:
34
37
  buildRpcRequest ships it as a multipart body and the server splits text fields
35
38
  into args (still schema-validated) and File parts into files(). FormData is
36
39
  stringly-typed, so this is the upload escape hatch — typed object args remain
37
40
  the default for everything else.
38
41
  */
39
- export type RemoteFunction<Args, Return> = RemoteCallable<Args, Return> & {
40
- readonly method: HttpVerb
42
+ export type RemoteFunction<
43
+ Args,
44
+ Return,
45
+ Errors extends ErrorSpec = Record<never, never>,
46
+ > = RemoteCallable<Args, Return> & {
47
+ readonly method: HttpMethod
41
48
  readonly url: string
42
49
  readonly clients: ClientFlags
43
50
  readonly crossOrigin?: boolean
44
51
  readonly raw: RawRemoteFunction<Args>
45
52
  stream(args?: Args | FormData): Subscribable<Return>
46
53
  fetch(request: Request): Promise<Response>
54
+ /* Type-guard a caught error against this rpc's declared `errors` (plus the framework
55
+ `'validation'` / `'queued'`): narrows `.kind` and, for a known kind, `.data` — the
56
+ per-rpc replacement for a global guard, since the error name → data type mapping
57
+ lives in the rpc's own spec. */
58
+ readonly isError: RpcErrorGuard<Errors>
59
+ /* Present only on a durable (`outbox`) RPC's client proxy: callable for the reactive
60
+ list of undelivered entries, with `retry()` to drain the queue. Undefined otherwise. */
61
+ readonly outbox?: Outbox<Args>
47
62
  }
@@ -3,7 +3,7 @@ import type { TraceContext } from './TraceContext.ts'
3
3
  /*
4
4
  The request-scope facts the shared layer reads through requestScopeSlot:
5
5
  trace position, elapsed ms since the scope opened (request start on the
6
- server, navigation start in the browser), and the verb+path that anchors log
6
+ server, navigation start in the browser), and the method+path that anchors log
7
7
  lines. Resolved fresh per read so `elapsedMs` is current at the call.
8
8
  */
9
9
  export type RequestScopeInfo = {
@@ -0,0 +1,40 @@
1
+ import type { HttpError } from '../HttpError.ts'
2
+ import type { ErrorSpec } from './ErrorSpec.ts'
3
+ import type { OutboxEntry } from './OutboxEntry.ts'
4
+ import type { StandardSchemaV1 } from './StandardSchemaV1.ts'
5
+ import type { ValidationErrorData } from './ValidationErrorData.ts'
6
+
7
+ /* The payload a declared error name carries: its data schema's inferred input, or
8
+ `unknown` for a nullary error (no data schema). */
9
+ type DeclaredErrorData<
10
+ Errors extends ErrorSpec,
11
+ Name extends keyof Errors,
12
+ > = Errors[Name]['data'] extends StandardSchemaV1
13
+ ? StandardSchemaV1.InferInput<Errors[Name]['data']>
14
+ : unknown
15
+
16
+ /*
17
+ A rpc's `isError` guard — branch a caught error on a kind THIS rpc knows, with the
18
+ data type narrowed from the rpc's own `errors` spec. `rpc.isError(err, 'outOfStock')`
19
+ narrows `.data` to that error's payload; the framework-reserved `'validation'` /
20
+ `'queued'` to their fixed shapes; any other string narrows `.kind` only (`.data`
21
+ stays `unknown` — it belongs to a different rpc, whose type isn't in scope here).
22
+
23
+ The declared-name overload comes first so an app error shadows the reserved ones on a
24
+ name collision, and so the kind argument autocompletes to the rpc's declared names.
25
+ */
26
+ export interface RpcErrorGuard<Errors extends ErrorSpec> {
27
+ <Name extends keyof Errors & string>(
28
+ error: unknown,
29
+ kind: Name,
30
+ ): error is HttpError & { kind: Name; data: DeclaredErrorData<Errors, Name> }
31
+ (
32
+ error: unknown,
33
+ kind: 'validation',
34
+ ): error is HttpError & { kind: 'validation'; data: ValidationErrorData }
35
+ (
36
+ error: unknown,
37
+ kind: 'queued',
38
+ ): error is HttpError & { kind: 'queued'; data: OutboxEntry<unknown> }
39
+ <Kind extends string>(error: unknown, kind: Kind): error is HttpError & { kind: Kind }
40
+ }
@@ -1,4 +1,4 @@
1
- /* A verb invoker mirrors the verb's own call: a plain call decodes the body
1
+ /* A rpc invoker mirrors the rpc's own call: a plain call decodes the body
2
2
  (throws HttpError on non-2xx), `.raw` returns the Response untouched. Shared
3
3
  by every name→callable RPC proxy (the CLI client, the test harness). */
4
4
  export type RpcInvoker = ((args?: unknown) => Promise<unknown>) & {
@@ -2,7 +2,7 @@
2
2
  Mirror of the Standard Schema v1 spec interface (standardschema.dev). Any
3
3
  library that implements the spec — zod, valibot, arktype, etc. — produces
4
4
  values whose `~standard` property structurally matches this shape, so users
5
- can pass their existing schemas to verb helpers without an adapter.
5
+ can pass their existing schemas to rpc helpers without an adapter.
6
6
 
7
7
  Kept inline (no `@standard-schema/spec` dep) because the spec is type-only
8
8
  and tiny; adding a package for ~30 lines of interface would be churn. The
@@ -0,0 +1,20 @@
1
+ import type { StandardSchemaV1 } from './StandardSchemaV1.ts'
2
+
3
+ /*
4
+ The `data` payload an input/file validation failure carries on its HttpError —
5
+ `kind: 'validation'`, status 422. `issues` is the raw Standard Schema issue list
6
+ (every message + full path); `fields` is the form-friendly top-level-field →
7
+ first-message map (fieldErrorsFromIssues). Because a throw can't carry the rpc's
8
+ per-kind type to the catch, `HttpError.data` is typed `unknown` — narrow it with
9
+ this when `err.kind === 'validation'`:
10
+
11
+ if (err instanceof HttpError && err.kind === 'validation') {
12
+ const { fields } = err.data as ValidationErrorData
13
+ // fields.email === 'Required', …
14
+ }
15
+ */
16
+ // @documentation response
17
+ export type ValidationErrorData = {
18
+ readonly issues: readonly StandardSchemaV1.Issue[]
19
+ readonly fields: Record<string, string>
20
+ }
@@ -11,7 +11,7 @@ type Query = Record<string, QueryValue>
11
11
  /*
12
12
  Augmentable rpc map. Codegen emits a `declare module '<importName>/shared/url'`
13
13
  block filling this with `'/rpc/<file>': args` for each query-carrying (GET)
14
- verb, so url('/rpc/search', { q }) types its args against the verb's own
14
+ rpc, so url('/rpc/search', { q }) types its args against the rpc's own
15
15
  signature. Empty by default — an absent entry falls through to the page/asset
16
16
  branch, so the helper works before the generated d.ts lands.
17
17
  */
@@ -47,7 +47,7 @@ no bracket segments yields {}, which collapses the params slot away. The
47
47
  catch-all branch recurses on its head too, so `[name]` segments before a
48
48
  `[...rest]` are kept.
49
49
  */
50
- type PathParams<P extends string> = P extends `${infer Head}[...${infer Rest}]${infer Tail}`
50
+ export type PathParams<P extends string> = P extends `${infer Head}[...${infer Rest}]${infer Tail}`
51
51
  ? PathParams<Head> & { [K in Rest]: string | number } & PathParams<Tail>
52
52
  : P extends `${string}[${infer Name}]${infer Tail}`
53
53
  ? { [K in Name]: string | number } & PathParams<Tail>
@@ -59,7 +59,7 @@ Resolves any in-app URL to its base-correct, typed form — the single chokepoin
59
59
  so a project mounted under APP_URL's subpath (e.g. /v2) generates links, asset
60
60
  refs, and rpc hrefs that stay within the mount. Three disjoint URL kinds, keyed
61
61
  off the path:
62
- - rpc (flat /rpc/*, present in RpcRoutes): the verb's args, serialised to query.
62
+ - rpc (flat /rpc/*, present in RpcRoutes): the rpc's args, serialised to query.
63
63
  - page route (has [name] segments): path params, then optional query.
64
64
  - asset / paramless / raw: bare path, then optional query.
65
65
  External URLs (scheme-qualified or protocol-relative) skip the base untouched,
@@ -1,15 +1,15 @@
1
1
  import { carriesBodyArgs } from './carriesBodyArgs.ts'
2
- import { detectVerbMethod } from './detectVerbMethod.ts'
2
+ import { detectRpcMethod } from './detectRpcMethod.ts'
3
3
  import { fileStem } from './fileStem.ts'
4
4
  import { rpcUrlForFile } from './rpcUrlForFile.ts'
5
5
  import { writeDts } from './writeDts.ts'
6
6
 
7
7
  /*
8
8
  Emits a `.d.ts` that augments abide's `RpcRoutes` interface with one entry per
9
- query-carrying $rpc verb, so `url('/rpc/search', { q })` types its args against
10
- the verb's own signature. Only GET/DELETE/HEAD (non-body) verbs are included —
11
- a url() can carry a query string but not a request body, so a POST verb has no
12
- URL form. `RpcArgs` lifts the args type out of the verb's RemoteFunction
9
+ query-carrying $rpc rpc, so `url('/rpc/search', { q })` types its args against
10
+ the rpc's own signature. Only GET/DELETE/HEAD (non-body) rpcs are included —
11
+ a url() can carry a query string but not a request body, so a POST rpc has no
12
+ URL form. `RpcArgs` lifts the args type out of the rpc's RemoteFunction
13
13
  (dropping the FormData upload variant); the file path resolves the export by
14
14
  its filename, the abide one-export-per-file convention. Written to
15
15
  `src/.abide/rpc.d.ts` so the consumer's src tsconfig include picks it up, keyed
@@ -28,8 +28,8 @@ export async function writeRpcDts({
28
28
  }): Promise<void> {
29
29
  const lines = await Promise.all(
30
30
  rpcFiles.map(async (file) => {
31
- const method = detectVerbMethod(await Bun.file(`${rpcDir}/${file}`).text())
32
- // A body verb's args can't ride a URL — leave it out of the url() rpc map.
31
+ const method = detectRpcMethod(await Bun.file(`${rpcDir}/${file}`).text())
32
+ // A body rpc's args can't ride a URL — leave it out of the url() rpc map.
33
33
  if (!method || carriesBodyArgs(method)) {
34
34
  return undefined
35
35
  }
@@ -5,9 +5,9 @@ import { writeDts } from './writeDts.ts'
5
5
 
6
6
  /*
7
7
  Emits a `.d.ts` that augments createTestApp's `RpcClient` interface with one
8
- entry per $rpc verb, keyed by command name (the same key `app.rpc.<name>`
9
- resolves at runtime). Each entry lifts the verb's args + resolved return out of
10
- its RemoteFunction so `app.rpc.getProduct({ id })` types against the verb's own
8
+ entry per $rpc rpc, keyed by command name (the same key `app.rpc.<name>`
9
+ resolves at runtime). Each entry lifts the rpc's args + resolved return out of
10
+ its RemoteFunction so `app.rpc.getProduct({ id })` types against the rpc's own
11
11
  signature — args in, decoded body out, plus `.raw` for the Response. `RpcArgs`
12
12
  drops the FormData upload variant exactly as writeRpcDts does; `RpcReturn`
13
13
  reads the resolved body type. Written to `src/.abide/testRpc.d.ts` so the
@@ -25,7 +25,7 @@ import { rpc } from '../../_virtual/rpc.ts'
25
25
  import { shell } from '../../_virtual/shell.ts'
26
26
  // @ts-expect-error virtual module resolved by abideResolverPlugin
27
27
  import { sockets } from '../../_virtual/sockets.ts'
28
- import { verbRegistry } from '../server/rpc/verbRegistry.ts'
28
+ import { rpcRegistry } from '../server/rpc/rpcRegistry.ts'
29
29
  import { createServer } from '../server/runtime/createServer.ts'
30
30
  import { ensureRegistriesLoaded } from '../server/runtime/registryManifests.ts'
31
31
  import { requestContext } from '../server/runtime/requestContext.ts'
@@ -46,8 +46,8 @@ import type { RemoteFunction } from '../shared/types/RemoteFunction.ts'
46
46
  import { createTestSocketChannel } from './createTestSocketChannel.ts'
47
47
 
48
48
  /*
49
- Augmentable verb/socket maps for `app.rpc.<verb>` / `app.sockets.<name>`. The
50
- build's writeTestRpcDts / writeTestSocketsDts emit one entry per verb/socket
49
+ Augmentable rpc/socket maps for `app.rpc.<rpc>` / `app.sockets.<name>`. The
50
+ build's writeTestRpcDts / writeTestSocketsDts emit one entry per rpc/socket
51
51
  (into src/.abide/), so the keys + signatures are the project's real surface
52
52
  with no imports. Empty here; types arrive once the app has been built. Mirrors
53
53
  url's RpcRoutes / health's AppHealthMap.
@@ -58,7 +58,7 @@ export interface RpcClient {}
58
58
  // biome-ignore lint/suspicious/noEmptyInterface: augmented by the generated testSockets.d.ts
59
59
  export interface SocketClient {}
60
60
 
61
- /* The booted app under test. Every named subsystem is reachable as the verb
61
+ /* The booted app under test. Every named subsystem is reachable as the rpc
62
62
  you call, the socket you iterate, or a path you fetch — over the real
63
63
  server, so the full pipeline (CSRF, cookies, base path) runs. */
64
64
  export type TestApp = {
@@ -69,7 +69,7 @@ export type TestApp = {
69
69
  matches routes at raw paths — the APP_URL mount base is stripped by an
70
70
  external proxy in production, absent here — so paths carry no base. */
71
71
  fetch: (path: string, init?: RequestInit) => Promise<Response>
72
- /* Verb calls over HTTP, keyed by command name: `app.rpc.getProduct({ id })`. */
72
+ /* Rpc calls over HTTP, keyed by command name: `app.rpc.getProduct({ id })`. */
73
73
  rpc: RpcClient
74
74
  /* Sockets keyed by name: `app.sockets.ticker` is the Socket — iterate it for
75
75
  the live stream, `.tail(n)` to seed, `.publish(m)` to send. */
@@ -88,7 +88,7 @@ export type TestApp = {
88
88
  Boots the real app on an ephemeral port — the same wiring serverEntry performs,
89
89
  minus the standalone-binary env layers. Imports the framework's virtual
90
90
  manifests (resolved by abideResolverPlugin, registered via `abide/preload`
91
- in the consumer's bunfig), so the routes, verbs, and sockets are the project's
91
+ in the consumer's bunfig), so the routes, rpcs, and sockets are the project's
92
92
  real surface, not a fixture. Pass nothing: `await createTestApp()` is the app
93
93
  exactly as `bun start` would serve it.
94
94
 
@@ -138,19 +138,19 @@ export async function createTestApp(): Promise<TestApp> {
138
138
  return fetch(`${origin}${path}`, init)
139
139
  }
140
140
 
141
- /* Verb modules loaded once so the registry holds every RemoteFunction; the
141
+ /* Rpc modules loaded once so the registry holds every RemoteFunction; the
142
142
  proxy maps command name → an HTTP call against the booted server. */
143
143
  await ensureRegistriesLoaded()
144
144
  const remotes = new Map<string, RemoteFunction<unknown, unknown>>(
145
- Array.from(verbRegistry.values()).map((entry) => [
145
+ Array.from(rpcRegistry.values()).map((entry) => [
146
146
  commandNameForUrl(entry.remote.url),
147
147
  entry.remote,
148
148
  ]),
149
149
  )
150
150
 
151
151
  function send(remote: RemoteFunction<unknown, unknown>, args: unknown): Promise<Response> {
152
- /* Same-origin Origin header so the CSRF gate admits mutating verbs; the
153
- server serves verbs at their raw url (no mount base applied here). */
152
+ /* Same-origin Origin header so the CSRF gate admits mutating rpcs; the
153
+ server serves rpcs at their raw url (no mount base applied here). */
154
154
  const request = buildRpcRequest({
155
155
  method: remote.method,
156
156
  url: remote.url,
@@ -0,0 +1,48 @@
1
+ import { resolveBranches } from './resolveBranches.ts'
2
+ import type { TemplateNode } from './types/TemplateNode.ts'
3
+
4
+ /* The structural shape of an `await` block, resolved ONCE from the node so the build and
5
+ SSR back-ends share one reading of it and only own emission. Both previously recomputed
6
+ branch resolution, the `finallyChildren` default, the blocking-vs-streaming split of the
7
+ resolved content/binding, and the surface-the-rejection rule — the parts that can silently
8
+ diverge. Same single-source-of-truth model the positional walks already use. */
9
+ export type AwaitPlan = {
10
+ /* `then` on the tag → blocking: no pending branch, the children ARE the resolved content
11
+ (settled before first flush). Absent → streaming: pending flushes, `then` resolves later. */
12
+ blocking: boolean
13
+ /* The pending content (streaming) — `[]` when blocking. */
14
+ pending: TemplateNode[]
15
+ /* The resolved content + its bound name: the children-minus-branch bound to `node.as`
16
+ when blocking, the `then` branch bound to its `as` when streaming. `_value` default. */
17
+ resolvedChildren: TemplateNode[]
18
+ resolvedAs: string
19
+ /* The catch content + its bound name (`_error` default); empty when no catch branch. */
20
+ catchChildren: TemplateNode[]
21
+ catchAs: string
22
+ finallyChildren: TemplateNode[]
23
+ /* Neither catch nor finally → a rejection must surface (re-throw / `undefined` catch thunk)
24
+ instead of rendering an empty branch. */
25
+ surfaceRejection: boolean
26
+ }
27
+
28
+ /* Resolves an `await` node's branches into the shared structural plan. */
29
+ export function awaitPlan(node: Extract<TemplateNode, { kind: 'await' }>): AwaitPlan {
30
+ const [thenBranch, catchBranch, finallyBranch] = resolveBranches(
31
+ node,
32
+ 'then',
33
+ 'catch',
34
+ 'finally',
35
+ )
36
+ const finallyChildren = finallyBranch?.children ?? []
37
+ const nonBranch = node.children.filter((child) => child.kind !== 'branch')
38
+ return {
39
+ blocking: node.blocking,
40
+ pending: node.blocking ? [] : nonBranch,
41
+ resolvedChildren: node.blocking ? nonBranch : (thenBranch?.children ?? []),
42
+ resolvedAs: (node.blocking ? node.as : thenBranch?.as) ?? '_value',
43
+ catchChildren: catchBranch?.children ?? [],
44
+ catchAs: catchBranch?.as ?? '_error',
45
+ finallyChildren,
46
+ surfaceRejection: catchBranch === undefined && finallyChildren.length === 0,
47
+ }
48
+ }
@@ -282,6 +282,14 @@ function scopeLineFor(
282
282
  propsShapes.push(shape === undefined ? 'Record<string, any>' : verbatim(shape))
283
283
  return { text: `const ${verbatim(declaration)};`, segments: [span(declaration, 6)] }
284
284
  }
285
+ /* The rewrite drops the callee from the line, so hovering `state`/`computed`/
286
+ `linked` at its call site has nothing to resolve. Append the callee as a
287
+ trailing reference statement (`…; state;`), mapped back to its source span,
288
+ so it resolves to the same primitive the destructure does. */
289
+ const withCalleeRef = (line: ScopeLine): ScopeLine => ({
290
+ text: `${line.text} ${verbatim(call.expression)};`,
291
+ segments: [...line.segments, span(call.expression, line.text.length + 1)],
292
+ })
285
293
  if (callee === 'state') {
286
294
  /* state<T>(initial): T is the value type — carry it onto the `let` so an
287
295
  explicit annotation isn't lost to `any`/`any[]` inference of the initial. */
@@ -297,13 +305,16 @@ function scopeLineFor(
297
305
  then correctly flagged possibly-undefined; a guard narrows cleanly. */
298
306
  const valueType = annotation === '' ? ': unknown' : `${annotation} | undefined`
299
307
  /* map the binding name (offset 4, past `let `) so hover/go-to resolve on it */
300
- return { text: `let ${name}!${valueType};`, segments: [span(declaration.name, 4)] }
308
+ return withCalleeRef({
309
+ text: `let ${name}!${valueType};`,
310
+ segments: [span(declaration.name, 4)],
311
+ })
301
312
  }
302
313
  const prefix = `let ${name}${annotation} = (`
303
- return {
314
+ return withCalleeRef({
304
315
  text: `${prefix}${verbatim(init)});`,
305
316
  segments: [span(declaration.name, 4), span(init, prefix.length)],
306
- }
317
+ })
307
318
  }
308
319
  /* computed<T>(compute) / linked<T>(seed) — the only callees left: T is the value
309
320
  type — the call's first arg is a thunk, so invoking it yields the value. Annotate
@@ -317,16 +328,16 @@ function scopeLineFor(
317
328
  /* binding-name map offset = past the keyword + space (`let ` = 4, `const ` = 6) */
318
329
  const keywordOffset = keyword.length + 1
319
330
  if (fn === undefined) {
320
- return {
331
+ return withCalleeRef({
321
332
  text: `${keyword} ${name} = undefined;`,
322
333
  segments: [span(declaration.name, keywordOffset)],
323
- }
334
+ })
324
335
  }
325
336
  const prefix = `${keyword} ${name}${annotation} = (`
326
- return {
337
+ return withCalleeRef({
327
338
  text: `${prefix}${verbatim(fn)})();`,
328
339
  segments: [span(declaration.name, keywordOffset), span(fn, prefix.length)],
329
- }
340
+ })
330
341
  }
331
342
 
332
343
  /* Emits a sibling list — each node standalone via `emitNode`. */
@@ -359,23 +370,29 @@ function emitNode(node: TemplateNode, builder: Builder): void {
359
370
  emitNodes(node.children, builder)
360
371
  return
361
372
  case 'component': {
362
- /* Check each prop against the child's declared type. The imported tag
363
- resolves (via the shadow host) to the child's `(props: Props) => …`
364
- default, so `Parameters<typeof Child>[0]["name"]` is that prop's type;
365
- assigning the mapped value to it lands a mismatch diagnostic on the
366
- offending expression (an annotated target reports the error on the RHS,
367
- unlike an object literal which reports it on the key). */
368
- for (const prop of node.props) {
369
- /* Lead with a defensive `;`: this IIFE is the one shadow emission that
370
- starts with `(`, so without it a preceding scope statement left
371
- unterminated (a script ending in a call with no trailing semicolon,
372
- e.g. `effect(() => …)`) merges across the newline into `effect(…)(…)`
373
- a spurious "not callable" on the author's last statement. */
373
+ /* The imported tag resolves (via the shadow host) to the child's
374
+ `(props: Props) => …` default, so `Parameters<typeof Child>[0]` is its prop
375
+ shape. `on*` / `bind:` / `attach` props are framework-handled passthrough
376
+ (not part of the declared shape), so they are checked leniently each value
377
+ against its own declared key type never flagged as excess. */
378
+ const handled = (prop: { name: string; spread?: boolean }): boolean =>
379
+ prop.spread === true ||
380
+ prop.name.startsWith('on') ||
381
+ prop.name.startsWith('bind:') ||
382
+ prop.name.startsWith('class:') ||
383
+ prop.name.startsWith('style:') ||
384
+ prop.name === 'attach'
385
+ const hasSpread = node.props.some((prop) => prop.spread)
386
+ for (const prop of node.props.filter(handled)) {
387
+ /* Lead with a defensive `;`: an IIFE/object-literal arg starts with `(` or
388
+ `{`, so without it a preceding scope statement left unterminated (a script
389
+ ending in a call with no trailing semicolon, e.g. `effect(() => …)`)
390
+ merges across the newline into `effect(…)(…)` — a spurious "not callable". */
374
391
  if (prop.spread) {
375
392
  /* A `{...expr}` spread contributes a SUBSET of the props (required ones
376
393
  may come from another spread/explicit prop), so check it against
377
- `Partial<Props>` — every key it does carry must match the child's
378
- declared type, without demanding completeness. */
394
+ `Partial<Props>` — every key it does carry must match, without
395
+ demanding completeness. */
379
396
  builder.raw(`;((__spread: Partial<Parameters<typeof ${node.name}>[0]>) => {})(`)
380
397
  } else {
381
398
  builder.raw(
@@ -385,6 +402,43 @@ function emitNode(node: TemplateNode, builder: Builder): void {
385
402
  builder.expr(prop.code, prop.loc)
386
403
  builder.raw(');\n')
387
404
  }
405
+ /* The plain data props as one object-literal argument typed to the child's whole
406
+ prop shape: a missing required prop errors on the literal (anchored at the tag
407
+ via the mapped `{`), an unknown prop errors on its key, a wrong type on its
408
+ value. Skipped when a spread is present — a spread may supply required props,
409
+ so completeness can't be demanded; the data props fall back to lenient per-key
410
+ checks instead. */
411
+ const dataProps = node.props.filter((prop) => !handled(prop))
412
+ if (hasSpread) {
413
+ for (const prop of dataProps) {
414
+ builder.raw(
415
+ `;((__prop: Parameters<typeof ${node.name}>[0][${JSON.stringify(prop.name)}]) => {})(`,
416
+ )
417
+ builder.expr(prop.code, prop.loc)
418
+ builder.raw(');\n')
419
+ }
420
+ } else {
421
+ builder.raw(`;((__c: Parameters<typeof ${node.name}>[0]): void => { void __c })({`)
422
+ /* A zero-length anchor right after `{`, pointing at the tag: a missing-
423
+ required-prop error spans the literal from `{`, so it overlaps this and
424
+ maps to the tag (an empty span trivially satisfies the source-text ==
425
+ shadow-text invariant). */
426
+ builder.mapped('', node.loc)
427
+ builder.raw('\n')
428
+ for (const prop of dataProps) {
429
+ /* The key mapped to its source name (excess-prop errors land on the key);
430
+ the value verbatim-mapped (wrong-type errors land on the value). */
431
+ if (prop.nameLoc !== undefined) {
432
+ builder.mapped(prop.name, prop.nameLoc)
433
+ } else {
434
+ builder.raw(prop.name)
435
+ }
436
+ builder.raw(': ')
437
+ builder.expr(prop.code, prop.loc)
438
+ builder.raw(',\n')
439
+ }
440
+ builder.raw('});\n')
441
+ }
388
442
  emitNodes(node.children, builder)
389
443
  return
390
444
  }
@@ -4,20 +4,56 @@ import { REACTIVE_CALLEES } from './REACTIVE_CALLEES.ts'
4
4
  const factory = ts.factory
5
5
 
6
6
  /* The reactive primitives that must be reached through a scope. A bare call to one of
7
- these is a compile error: reactive state is owned by a scope and the surface must show
8
- it (`scope().state(...)`), so a reader always sees the scope interaction. */
9
- const SCOPE_PRIMITIVES: ReadonlySet<string> = new Set(['state', 'linked', 'computed'])
7
+ these is a compile error: a reactive primitive is owned by a scope and the surface must
8
+ show it (`scope().state(...)`), so a reader always sees the scope interaction. `effect`
9
+ is here too a reaction is scope-owned (it tears down with the scope), so it joins the
10
+ one surface; unlike the cells it stays a runtime call (`scope().effect(...)` passes
11
+ through to the `effect` helper), not a doc slot. */
12
+ const SCOPE_PRIMITIVES: ReadonlySet<string> = new Set(['state', 'linked', 'computed', 'effect'])
13
+
14
+ /* The primitive names a top-level `const { state, computed } = scope()` destructure binds.
15
+ Such a name is scope-bound — its bare call below is the destructured method, not a stray
16
+ global — so it is exempt from the bare-primitive error. Only a destructure of a `scope()`
17
+ call counts (receiver-agnostic on the callee name, matching signalCallee); an aliased
18
+ binding (`{ state: s }`) is not recognised, so the canonical name must be kept. */
19
+ function scopeDestructuredPrimitives(source: ts.SourceFile): Set<string> {
20
+ const bound = new Set<string>()
21
+ for (const statement of source.statements) {
22
+ if (!ts.isVariableStatement(statement)) {
23
+ continue
24
+ }
25
+ for (const declaration of statement.declarationList.declarations) {
26
+ if (
27
+ signalCallee(declaration) === 'scope' &&
28
+ ts.isObjectBindingPattern(declaration.name)
29
+ ) {
30
+ for (const element of declaration.name.elements) {
31
+ if (
32
+ element.propertyName === undefined &&
33
+ ts.isIdentifier(element.name) &&
34
+ SCOPE_PRIMITIVES.has(element.name.text)
35
+ ) {
36
+ bound.add(element.name.text)
37
+ }
38
+ }
39
+ }
40
+ }
41
+ }
42
+ return bound
43
+ }
10
44
 
11
45
  /* Throws on a bare scope primitive (`state(0)` instead of `scope().state(0)`) or on the
12
46
  removed `prop(...)` reader — props are now read by destructuring `props()`. Walks all
13
- calls, so a stray one nested in a function is caught too, not just top-level declarations. */
47
+ calls, so a stray one nested in a function is caught too, not just top-level declarations.
48
+ A primitive destructured from `scope()` at the top is scope-bound and exempt. */
14
49
  function assertScopedPrimitives(source: ts.SourceFile): void {
50
+ const scopeBound = scopeDestructuredPrimitives(source)
15
51
  const visit = (node: ts.Node): void => {
16
52
  if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
17
53
  const name = node.expression.text
18
- if (SCOPE_PRIMITIVES.has(name)) {
54
+ if (SCOPE_PRIMITIVES.has(name) && !scopeBound.has(name)) {
19
55
  throw new Error(
20
- `abide: bare \`${name}(...)\` is not allowed — reactive state lives on a scope. Use \`scope().${name}(...)\` (or a captured handle: \`const s = scope(); s.${name}(...)\`).`,
56
+ `abide: bare \`${name}(...)\` is not allowed — a reactive primitive lives on a scope. Use \`scope().${name}(...)\` (or a captured handle: \`const s = scope(); s.${name}(...)\`).`,
21
57
  )
22
58
  }
23
59
  if (name === 'prop') {