@abide/abide 0.44.0 → 0.45.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 (119) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +64 -0
  3. package/README.md +171 -181
  4. package/package.json +2 -1
  5. package/src/abideResolverPlugin.ts +6 -1
  6. package/src/devEntry.ts +43 -9
  7. package/src/lib/cli/parseArgvForRpc.ts +21 -24
  8. package/src/lib/cli/runCli.ts +9 -24
  9. package/src/lib/cli/tokenizeArgvFlags.ts +84 -0
  10. package/src/lib/server/error.ts +48 -53
  11. package/src/lib/server/json.ts +4 -3
  12. package/src/lib/server/jsonl.ts +1 -1
  13. package/src/lib/server/rpc/defineRpc.ts +11 -9
  14. package/src/lib/server/rpc/types/RemoteHandler.ts +5 -7
  15. package/src/lib/server/rpc/types/RpcHelper.ts +125 -35
  16. package/src/lib/server/rpc/types/TypedError.ts +18 -0
  17. package/src/lib/server/rpc/validationError.ts +3 -3
  18. package/src/lib/server/runtime/STATUS_TEXT.ts +22 -0
  19. package/src/lib/server/runtime/createServer.ts +36 -12
  20. package/src/lib/server/runtime/devHotModuleResponse.ts +1 -1
  21. package/src/lib/server/runtime/gzipResponse.ts +14 -16
  22. package/src/lib/server/runtime/installAmbientScopeStore.ts +33 -0
  23. package/src/lib/server/runtime/runWithRequestScope.ts +4 -1
  24. package/src/lib/server/runtime/snapshotEntryFromCache.ts +2 -0
  25. package/src/lib/server/runtime/typedErrorResponse.ts +26 -0
  26. package/src/lib/server/runtime/types/RequestStore.ts +25 -0
  27. package/src/lib/server/runtime/withResponseDefaults.ts +8 -3
  28. package/src/lib/server/sockets/defineSocket.ts +5 -1
  29. package/src/lib/server/sse.ts +1 -1
  30. package/src/lib/shared/HttpError.ts +1 -1
  31. package/src/lib/shared/RPC_ARGS_TYPE.ts +7 -0
  32. package/src/lib/shared/activeCacheStore.ts +5 -14
  33. package/src/lib/shared/activePage.ts +5 -19
  34. package/src/lib/shared/augmentModule.ts +18 -0
  35. package/src/lib/shared/basePath.ts +6 -5
  36. package/src/lib/shared/baseResolver.ts +10 -0
  37. package/src/lib/shared/baseSlot.ts +6 -12
  38. package/src/lib/shared/cache.ts +46 -4
  39. package/src/lib/shared/cacheEntryFromSnapshot.ts +21 -1
  40. package/src/lib/shared/cacheKeyOf.ts +7 -0
  41. package/src/lib/shared/cacheKeyStore.ts +8 -0
  42. package/src/lib/shared/cacheStoreResolver.ts +12 -0
  43. package/src/lib/shared/cacheStoreSlot.ts +5 -13
  44. package/src/lib/shared/changeAffectsClient.ts +35 -0
  45. package/src/lib/shared/createResolverSlot.ts +37 -0
  46. package/src/lib/shared/debugGate.ts +90 -0
  47. package/src/lib/shared/emitLogRecord.ts +18 -4
  48. package/src/lib/shared/globalCacheStoreResolver.ts +12 -0
  49. package/src/lib/shared/globalCacheStoreSlot.ts +6 -11
  50. package/src/lib/shared/httpErrorFor.ts +1 -1
  51. package/src/lib/shared/isDebugEnabled.ts +3 -10
  52. package/src/lib/shared/isDebugNegated.ts +4 -6
  53. package/src/lib/shared/pageResolver.ts +16 -0
  54. package/src/lib/shared/pageSlot.ts +5 -14
  55. package/src/lib/shared/recordCacheKey.ts +6 -0
  56. package/src/lib/shared/requestScopeResolver.ts +12 -0
  57. package/src/lib/shared/requestScopeSlot.ts +7 -12
  58. package/src/lib/shared/responseBodyKind.ts +35 -0
  59. package/src/lib/shared/setBaseResolver.ts +2 -4
  60. package/src/lib/shared/setCacheStoreResolver.ts +3 -5
  61. package/src/lib/shared/setGlobalCacheStoreResolver.ts +3 -5
  62. package/src/lib/shared/setPageResolver.ts +2 -5
  63. package/src/lib/shared/setRequestScopeResolver.ts +3 -5
  64. package/src/lib/shared/toTagSet.ts +3 -3
  65. package/src/lib/shared/types/CacheEntry.ts +15 -0
  66. package/src/lib/shared/types/CacheOptions.ts +5 -5
  67. package/src/lib/shared/types/CacheSnapshotEntry.ts +5 -0
  68. package/src/lib/shared/types/DebugGate.ts +10 -0
  69. package/src/lib/shared/types/ErrorSpec.ts +6 -6
  70. package/src/lib/shared/types/RemoteFunction.ts +8 -4
  71. package/src/lib/shared/types/ResolverSlot.ts +10 -0
  72. package/src/lib/shared/writeDts.ts +8 -1
  73. package/src/lib/shared/writePublicAssetsDts.ts +5 -9
  74. package/src/lib/shared/writeRoutesDts.ts +12 -16
  75. package/src/lib/shared/writeRpcDts.ts +13 -12
  76. package/src/lib/shared/writeTestRpcDts.ts +11 -11
  77. package/src/lib/shared/writeTestSocketsDts.ts +9 -9
  78. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
  79. package/src/lib/ui/compile/abideUiPlugin.ts +9 -7
  80. package/src/lib/ui/compile/assertRuntimeHelpersBound.ts +24 -12
  81. package/src/lib/ui/compile/compileModule.ts +37 -16
  82. package/src/lib/ui/compile/compileShadow.ts +75 -1
  83. package/src/lib/ui/compile/createShadowLanguageService.ts +29 -3
  84. package/src/lib/ui/compile/generateBuild.ts +7 -1
  85. package/src/lib/ui/compile/generateSSR.ts +10 -3
  86. package/src/lib/ui/compile/parseTemplate.ts +4 -1
  87. package/src/lib/ui/compile/types/TemplateNode.ts +1 -1
  88. package/src/lib/ui/createScope.ts +11 -4
  89. package/src/lib/ui/deferResume.ts +29 -0
  90. package/src/lib/ui/dom/appendText.ts +19 -4
  91. package/src/lib/ui/dom/awaitBlock.ts +74 -26
  92. package/src/lib/ui/dom/eachAsync.ts +11 -18
  93. package/src/lib/ui/dom/mountSwappableRange.ts +79 -0
  94. package/src/lib/ui/dom/skeleton.ts +10 -1
  95. package/src/lib/ui/dom/switchBlock.ts +15 -63
  96. package/src/lib/ui/dom/when.ts +11 -64
  97. package/src/lib/ui/installHotBridge.ts +2 -0
  98. package/src/lib/ui/matchRoute.ts +18 -1
  99. package/src/lib/ui/remoteProxy.ts +11 -1
  100. package/src/lib/ui/renderToStream.ts +13 -8
  101. package/src/lib/ui/resumeSeedScript.ts +2 -2
  102. package/src/lib/ui/runtime/CURRENT_SCOPE.ts +14 -1
  103. package/src/lib/ui/runtime/RESUME.ts +6 -0
  104. package/src/lib/ui/runtime/ambientScopeBacking.ts +26 -0
  105. package/src/lib/ui/runtime/createDoc.ts +102 -12
  106. package/src/lib/ui/runtime/createEffectNode.ts +7 -1
  107. package/src/lib/ui/runtime/flushEffects.ts +18 -0
  108. package/src/lib/ui/runtime/generationGuard.ts +40 -0
  109. package/src/lib/ui/runtime/runNode.ts +9 -1
  110. package/src/lib/ui/runtime/types/SsrRender.ts +4 -3
  111. package/src/lib/ui/scope.ts +6 -8
  112. package/src/lib/ui/tryEncodeResume.ts +5 -2
  113. package/src/lib/ui/types/Scope.ts +3 -4
  114. package/src/zodCjsPlugin.ts +16 -1
  115. package/src/lib/server/rpc/buildErrorConstructors.ts +0 -19
  116. package/src/lib/shared/matchesDebugPattern.ts +0 -16
  117. package/src/lib/shared/parseDebugPatterns.ts +0 -21
  118. package/src/lib/shared/types/ErrorConstructors.ts +0 -17
  119. package/src/lib/shared/types/ErrorDescriptor.ts +0 -10
@@ -0,0 +1,12 @@
1
+ import { createResolverSlot } from './createResolverSlot.ts'
2
+ import type { RequestScopeInfo } from './types/RequestScopeInfo.ts'
3
+
4
+ /*
5
+ Slot + setter for the request-scope resolver. The server installs an ALS-backed
6
+ resolver (createServer, reading the RequestStore); the client a module-singleton
7
+ seeded from __SSR__ (startClient). No fallback creator — an unset resolver (or one
8
+ returning undefined outside any request) means "no scope": callers read
9
+ `.resolver?.()` and treat undefined as absent. requestScopeSlot /
10
+ setRequestScopeResolver re-export the slot and setter.
11
+ */
12
+ export const requestScopeResolver = createResolverSlot<RequestScopeInfo>()
@@ -1,15 +1,10 @@
1
- import type { RequestScopeInfo } from './types/RequestScopeInfo.ts'
1
+ import { requestScopeResolver } from './requestScopeResolver.ts'
2
2
 
3
3
  /*
4
- Internal slot the runtime entries register their request-scope resolver into.
5
- The server installs an ALS-backed resolver (createServer, reading the
6
- RequestStore); the client installs a module-singleton resolver seeded from
7
- __SSR__ (startClient). Undefined resolveror a resolver returning undefined
8
- outside any request means "no scope": trace() returns undefined and log
9
- lines print without the context prefix. Mirrors pageSlot / cacheStoreSlot.
4
+ Internal slot the runtime entries register their request-scope resolver into (see
5
+ requestScopeResolver). Exposed so callers read `.resolver?.()` and test helpers
6
+ snapshot/poke `.resolver` directly. Undefined resolver or one returning
7
+ undefined outside any requestmeans "no scope": trace() returns undefined and
8
+ log lines print without the context prefix.
10
9
  */
11
- export const requestScopeSlot: {
12
- resolver: (() => RequestScopeInfo | undefined) | undefined
13
- } = {
14
- resolver: undefined,
15
- }
10
+ export const requestScopeSlot = requestScopeResolver.slot
@@ -0,0 +1,35 @@
1
+ import { contentBodyKind } from './contentBodyKind.ts'
2
+ import { contentTypeOf } from './contentTypeOf.ts'
3
+
4
+ /*
5
+ Compressible Content-Types — text and structured-text payloads. Binary or
6
+ already-compressed bodies (images, fonts, archives, zstd/gzip blobs) gain
7
+ nothing from a second pass and only burn CPU.
8
+ */
9
+ const COMPRESSIBLE_TYPE =
10
+ /^(?:text\/|application\/(?:json|javascript|xml|[\w.-]+\+(?:json|xml))|image\/svg)/
11
+
12
+ /*
13
+ The post-handler body class that decides a dynamic response's wire handling:
14
+ `streaming` (SSE / JSONL — drained frame-by-frame, never gzip-buffered, opted
15
+ out of the idle timeout), `compressible` (text/structured-text — gzipped when
16
+ the client accepts it), or `opaque` (binary/already-encoded — passed through).
17
+
18
+ One header read + classify per response, threaded through the dispatch pipeline
19
+ so `gzipResponse` and the idle-timeout opt-out don't each re-derive it from the
20
+ Content-Type (the S2 finding: the same body was classified 3-4×). Mirrors the
21
+ shared `contentBodyKind` streaming bucket so the wire path and the decode path
22
+ can't disagree on what's a stream.
23
+ */
24
+ export type ResponseBodyKind = 'streaming' | 'compressible' | 'opaque'
25
+
26
+ export function responseBodyKind(response: Response): ResponseBodyKind {
27
+ const contentType = contentTypeOf(response.headers)
28
+ if (contentBodyKind(contentType) === 'streaming') {
29
+ return 'streaming'
30
+ }
31
+ if (COMPRESSIBLE_TYPE.test(contentType)) {
32
+ return 'compressible'
33
+ }
34
+ return 'opaque'
35
+ }
@@ -1,6 +1,4 @@
1
- import { baseSlot } from './baseSlot.ts'
1
+ import { baseResolver } from './baseResolver.ts'
2
2
 
3
3
  // Registers the runtime's mount-base resolver. Called once per side at boot.
4
- export function setBaseResolver(fn: () => string): void {
5
- baseSlot.resolver = fn
6
- }
4
+ export const setBaseResolver = baseResolver.set
@@ -1,6 +1,4 @@
1
- import { cacheStoreSlot } from './cacheStoreSlot.ts'
2
- import type { CacheStore } from './types/CacheStore.ts'
1
+ import { cacheStoreResolver } from './cacheStoreResolver.ts'
3
2
 
4
- export function setCacheStoreResolver(fn: () => CacheStore | undefined): void {
5
- cacheStoreSlot.resolver = fn
6
- }
3
+ // Registers the runtime's active-CacheStore resolver. Called once per side at boot.
4
+ export const setCacheStoreResolver = cacheStoreResolver.set
@@ -1,6 +1,4 @@
1
- import { globalCacheStoreSlot } from './globalCacheStoreSlot.ts'
2
- import type { CacheStore } from './types/CacheStore.ts'
1
+ import { globalCacheStoreResolver } from './globalCacheStoreResolver.ts'
3
2
 
4
- export function setGlobalCacheStoreResolver(fn: () => CacheStore | undefined): void {
5
- globalCacheStoreSlot.resolver = fn
6
- }
3
+ // Registers the process-level cache store resolver. Called once per side at boot.
4
+ export const setGlobalCacheStoreResolver = globalCacheStoreResolver.set
@@ -1,7 +1,4 @@
1
- import { pageSlot } from './pageSlot.ts'
2
- import type { PageSnapshot } from './types/PageSnapshot.ts'
1
+ import { pageResolver } from './pageResolver.ts'
3
2
 
4
3
  // Registers the runtime's page resolver. Called once per side at boot.
5
- export function setPageResolver(fn: () => PageSnapshot | undefined): void {
6
- pageSlot.resolver = fn
7
- }
4
+ export const setPageResolver = pageResolver.set
@@ -1,6 +1,4 @@
1
- import { requestScopeSlot } from './requestScopeSlot.ts'
2
- import type { RequestScopeInfo } from './types/RequestScopeInfo.ts'
1
+ import { requestScopeResolver } from './requestScopeResolver.ts'
3
2
 
4
- export function setRequestScopeResolver(fn: () => RequestScopeInfo | undefined): void {
5
- requestScopeSlot.resolver = fn
6
- }
3
+ // Registers the runtime's request-scope resolver. Called once per side at boot.
4
+ export const setRequestScopeResolver = requestScopeResolver.set
@@ -1,4 +1,4 @@
1
- /* Normalizes a tags option (one tag or many) to a Set for O(1) membership. */
2
- export function toTagSet(tags: string | string[]): Set<string> {
3
- return new Set(typeof tags === 'string' ? [tags] : tags)
1
+ /* Normalizes a tags option to a Set for O(1) membership. */
2
+ export function toTagSet(tags: string[]): Set<string> {
3
+ return new Set(tags)
4
4
  }
@@ -59,9 +59,24 @@ export type CacheEntry = {
59
59
  ttl: number | undefined
60
60
  expiresAt: number | undefined
61
61
  value?: unknown
62
+ /*
63
+ Lazy warm materializer (Tier 2 deferred seed): a memoized thunk that decodes the
64
+ shipped body into the warm value on first call, so a deferred snapshot pays no decode
65
+ at hydration — only when a read actually needs the value. `value` stays undefined until
66
+ then; the read gate treats an entry with `warm` set as warm without invoking it. Eager
67
+ snapshots set `value` and leave this undefined.
68
+ */
69
+ warm?: () => unknown
62
70
  tags?: Set<string>
63
71
  settled?: boolean
64
72
  hydrated?: boolean
73
+ /*
74
+ Marks that the SSR resume path deferred this entry's `{#await cache()}` value — shipped a
75
+ `{ defer, key }` marker instead of inlining it. The post-stream snapshot drain reads it to
76
+ ship the body as a LAZY seed (decoded on first read, not at hydration). Set server-side
77
+ only, on the request-scoped entry, during the stream.
78
+ */
79
+ deferred?: boolean
65
80
  refreshing?: boolean
66
81
  invalidation?: InvalidationState
67
82
  }
@@ -6,10 +6,10 @@ milliseconds-past-resolve that the entry stays live: omitted = forever, 0 =
6
6
  dedupe only (entry dropped once the promise settles — the mutation idiom:
7
7
  in-flight coalescing and pending() visibility, nothing retained), any other
8
8
  number = TTL.
9
- `tags` is one or more free-form labels grouping unrelated calls so one
10
- `cache.invalidate({ tags })` drops every entry sharing any of them — pass an
11
- array when a call belongs to multiple invalidation groups. A unique tag (e.g. a
12
- uuid) shared by a set of calls gives them their own private invalidation group.
9
+ `tags` is an array of free-form labels grouping unrelated calls so one
10
+ `cache.invalidate({ tags })` drops every entry sharing any of them — list
11
+ multiple when a call belongs to multiple invalidation groups. A unique tag (e.g.
12
+ a uuid) shared by a set of calls gives them their own private invalidation group.
13
13
 
14
14
  `global` opts the entry into the process-level store instead of the default
15
15
  request-scoped one (server) — so a value computed in one request is reused by
@@ -37,7 +37,7 @@ uncheckable — set `swr` only on a producer that is a pure read.
37
37
  */
38
38
  export type CacheOptions = {
39
39
  ttl?: number
40
- tags?: string | string[]
40
+ tags?: string[]
41
41
  global?: boolean
42
42
  swr?: boolean | { throttle?: number; debounce?: number }
43
43
  }
@@ -14,4 +14,9 @@ export type CacheSnapshotEntry = {
14
14
  statusText: string
15
15
  headers: Array<[string, string]>
16
16
  body: string
17
+ /* Deferred seed (Tier 2): the client stores the body but does NOT decode it at boot —
18
+ the warm value is materialized lazily on the first read, off the hydration path. Set
19
+ by the server for a deferred `{#await cache()}` whose value ships via a `{defer,key}`
20
+ resume marker instead of inline, so hydration touches neither copy's decode. */
21
+ lazy?: boolean
17
22
  }
@@ -0,0 +1,10 @@
1
+ /*
2
+ The two questions a DEBUG string answers for one channel name. `enabled` gates a
3
+ diagnostic channel (inclusion, with exclusions winning); `negated` is the off
4
+ switch for always-on channels (a `-name` pattern silencing the app's own or
5
+ abide's framework voice). Returned by debugGate, already bound to one env value.
6
+ */
7
+ export type DebugGate = {
8
+ enabled(name: string): boolean
9
+ negated(name: string): boolean
10
+ }
@@ -1,11 +1,11 @@
1
1
  import type { StandardSchemaV1 } from './StandardSchemaV1.ts'
2
2
 
3
3
  /*
4
- A rpc's declared error set, keyed by error NAME (not status, so two errors can
5
- share a status). Each entry names its HTTP `status` and an optional `data`
6
- schema whose inferred input the error constructor requires. Passed as the rpc's
7
- `errors` opt; the client derives a typed `Result` union from it (see
8
- `RpcErrorUnion`), and the handler receives matching constructors (see
9
- `ErrorConstructors`).
4
+ A rpc's error set, keyed by error NAME (not status, so two errors can share a
5
+ status). Each entry names its HTTP `status` and an optional `data` schema whose
6
+ inferred input the error constructor requires. Built at the type level from the
7
+ `error.typed(...)` constructors a handler RETURNS (see `TypedError` +
8
+ `InferredErrors` in RpcHelper); the client's `rpc.isError` narrows `.kind` /
9
+ `.data` off it (see `RpcErrorGuard`).
10
10
  */
11
11
  export type ErrorSpec = Record<string, { status: number; data?: StandardSchemaV1 }>
@@ -43,6 +43,7 @@ export type RemoteFunction<
43
43
  Args,
44
44
  Return,
45
45
  Errors extends ErrorSpec = Record<never, never>,
46
+ Durable extends boolean = false,
46
47
  > = RemoteCallable<Args, Return> & {
47
48
  readonly method: HttpMethod
48
49
  readonly url: string
@@ -56,7 +57,10 @@ export type RemoteFunction<
56
57
  per-rpc replacement for a global guard, since the error name → data type mapping
57
58
  lives in the rpc's own spec. */
58
59
  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>
62
- }
60
+ } /* `outbox` presence follows the `outbox: true` opt: the mutating helper threads `Durable`
61
+ into the return type so a DURABLE rpc's `.outbox` is the required queue face (no optional
62
+ chain). A non-durable rpc keeps it optional — assignable to a durable one everywhere a
63
+ bare `RemoteFunction<Args, Return>` slot (cache selectors, registries) is expected, since
64
+ required→optional widens cleanly and no call site had to learn the `Durable` bit. */ & (Durable extends true
65
+ ? { readonly outbox: Outbox<Args> }
66
+ : { readonly outbox?: Outbox<Args> })
@@ -0,0 +1,10 @@
1
+ /*
2
+ The mutable cell a runtime entry registers its resolver into, plus the single
3
+ lazy/value fallback used when no resolver is registered. createServer and
4
+ startClient install side-specific resolvers; isolated tests poke `.resolver` /
5
+ `.fallback` directly, so both fields stay public and mutable.
6
+ */
7
+ export type ResolverSlot<T> = {
8
+ resolver: (() => T | undefined) | undefined
9
+ fallback: T | undefined
10
+ }
@@ -8,5 +8,12 @@ src tsconfig include picks up), wrapping `body` in the shared banner + the
8
8
  differ only in `body`, so the envelope lives here.
9
9
  */
10
10
  export async function writeDts(cwd: string, name: string, body: string): Promise<void> {
11
- await Bun.write(`${cwd}/src/.abide/${name}.d.ts`, `${DTS_BANNER}\n${body}\n\nexport {}\n`)
11
+ const next = `${DTS_BANNER}\n${body}\n\nexport {}\n`
12
+ const file = Bun.file(`${cwd}/src/.abide/${name}.d.ts`)
13
+ // Skip the write when byte-identical — an unchanged rebuild (the common case)
14
+ // otherwise bumps mtime every save, forcing the editor's TS server to re-typecheck.
15
+ if ((await file.exists()) && (await file.text()) === next) {
16
+ return
17
+ }
18
+ await Bun.write(file, next)
12
19
  }
@@ -1,3 +1,4 @@
1
+ import { augmentModule } from './augmentModule.ts'
1
2
  import { writeDts } from './writeDts.ts'
2
3
 
3
4
  /*
@@ -19,13 +20,8 @@ export async function writePublicAssetsDts({
19
20
  importName: string
20
21
  }): Promise<void> {
21
22
  const entries = publicFiles
22
- .map((file) => ` ${JSON.stringify(`/${file}`)}: true`)
23
- .toSorted()
24
- .join('\n')
25
- const body = `declare module '${importName}/shared/url' {
26
- interface PublicAssets {
27
- ${entries}
28
- }
29
- }`
30
- await writeDts(cwd, 'publicAssets', body)
23
+ .map((file): [string, string] => [`/${file}`, 'true'])
24
+ .toSorted(([a], [b]) => (JSON.stringify(a) < JSON.stringify(b) ? -1 : 1))
25
+ const module = augmentModule(`${importName}/shared/url`, 'PublicAssets', entries)
26
+ await writeDts(cwd, 'publicAssets', module)
31
27
  }
@@ -1,3 +1,4 @@
1
+ import { augmentModule } from './augmentModule.ts'
1
2
  import { pageUrlForFile } from './pageUrlForFile.ts'
2
3
  import { routeParamsShape } from './routeParamsShape.ts'
3
4
  import { writeDts } from './writeDts.ts'
@@ -25,21 +26,16 @@ export async function writeRoutesDts({
25
26
  const routes = pageFiles
26
27
  .map((file) => ({ route: pageUrlForFile(file) }))
27
28
  .toSorted((a, b) => a.route.localeCompare(b.route))
28
- const entries = routes
29
- .map(({ route }) => ` ${JSON.stringify(route)}: ${routeParamsShape(route)}`)
30
- .join('\n')
29
+ const routesModule = augmentModule(
30
+ `${importName}/shared/page`,
31
+ 'Routes',
32
+ routes.map(({ route }): [string, string] => [route, routeParamsShape(route)]),
33
+ )
31
34
  /* Keys-only mirror for url()'s autocomplete (values unused — PathParams derives the shape). */
32
- const urlKeys = routes.map(({ route }) => ` ${JSON.stringify(route)}: true`).join('\n')
33
- const body = `declare module '${importName}/shared/page' {
34
- interface Routes {
35
- ${entries}
36
- }
37
- }
38
-
39
- declare module '${importName}/shared/url' {
40
- interface PageRoutes {
41
- ${urlKeys}
42
- }
43
- }`
44
- await writeDts(cwd, 'routes', body)
35
+ const urlModule = augmentModule(
36
+ `${importName}/shared/url`,
37
+ 'PageRoutes',
38
+ routes.map(({ route }): [string, string] => [route, 'true']),
39
+ )
40
+ await writeDts(cwd, 'routes', `${routesModule}\n\n${urlModule}`)
45
41
  }
@@ -1,6 +1,8 @@
1
+ import { augmentModule } from './augmentModule.ts'
1
2
  import { carriesBodyArgs } from './carriesBodyArgs.ts'
2
3
  import { detectRpcMethod } from './detectRpcMethod.ts'
3
4
  import { fileStem } from './fileStem.ts'
5
+ import { RPC_ARGS_TYPE } from './RPC_ARGS_TYPE.ts'
4
6
  import { rpcUrlForFile } from './rpcUrlForFile.ts'
5
7
  import { writeDts } from './writeDts.ts'
6
8
 
@@ -26,24 +28,23 @@ export async function writeRpcDts({
26
28
  rpcFiles: string[]
27
29
  importName: string
28
30
  }): Promise<void> {
29
- const lines = await Promise.all(
30
- rpcFiles.map(async (file) => {
31
+ const pairs = await Promise.all(
32
+ rpcFiles.map(async (file): Promise<[string, string] | undefined> => {
31
33
  const method = detectRpcMethod(await Bun.file(`${rpcDir}/${file}`).text())
32
34
  // A body rpc's args can't ride a URL — leave it out of the url() rpc map.
33
35
  if (!method || carriesBodyArgs(method)) {
34
36
  return undefined
35
37
  }
36
38
  const importPath = `../server/rpc/${file}`
37
- return ` ${JSON.stringify(rpcUrlForFile(file))}: RpcArgs<typeof import(${JSON.stringify(importPath)}).${fileStem(file)}>`
39
+ return [
40
+ rpcUrlForFile(file),
41
+ `RpcArgs<typeof import(${JSON.stringify(importPath)}).${fileStem(file)}>`,
42
+ ]
38
43
  }),
39
44
  )
40
- const entries = lines.filter((line) => line !== undefined).toSorted()
41
- const body = `type RpcArgs<Fn> = Fn extends (args: infer Args) => unknown ? Exclude<Args, FormData> : never
42
-
43
- declare module '${importName}/shared/url' {
44
- interface RpcRoutes {
45
- ${entries.join('\n')}
46
- }
47
- }`
48
- await writeDts(cwd, 'rpc', body)
45
+ const entries = pairs
46
+ .filter((pair) => pair !== undefined)
47
+ .toSorted(([a], [b]) => (JSON.stringify(a) < JSON.stringify(b) ? -1 : 1))
48
+ const module = augmentModule(`${importName}/shared/url`, 'RpcRoutes', entries)
49
+ await writeDts(cwd, 'rpc', `${RPC_ARGS_TYPE}\n\n${module}`)
49
50
  }
@@ -1,5 +1,7 @@
1
+ import { augmentModule } from './augmentModule.ts'
1
2
  import { commandNameForUrl } from './commandNameForUrl.ts'
2
3
  import { fileStem } from './fileStem.ts'
4
+ import { RPC_ARGS_TYPE } from './RPC_ARGS_TYPE.ts'
3
5
  import { rpcUrlForFile } from './rpcUrlForFile.ts'
4
6
  import { writeDts } from './writeDts.ts'
5
7
 
@@ -24,22 +26,20 @@ export async function writeTestRpcDts({
24
26
  importName: string
25
27
  }): Promise<void> {
26
28
  const entries = rpcFiles
27
- .map((file) => {
29
+ .map((file): [string, string] => {
28
30
  const name = commandNameForUrl(rpcUrlForFile(file))
29
31
  const importPath = `../server/rpc/${file}`
30
- return ` ${JSON.stringify(name)}: RpcInvoker<typeof import(${JSON.stringify(importPath)}).${fileStem(file)}>`
32
+ return [
33
+ name,
34
+ `RpcInvoker<typeof import(${JSON.stringify(importPath)}).${fileStem(file)}>`,
35
+ ]
31
36
  })
32
- .toSorted()
33
- const body = `type RpcArgs<Fn> = Fn extends (args: infer Args) => unknown ? Exclude<Args, FormData> : never
37
+ .toSorted(([a], [b]) => (JSON.stringify(a) < JSON.stringify(b) ? -1 : 1))
38
+ const helperTypes = `${RPC_ARGS_TYPE}
34
39
  type RpcReturn<Fn> = Fn extends (...args: never[]) => Promise<infer Return> ? Return : never
35
40
  type RpcInvoker<Fn> = ((args?: RpcArgs<Fn>) => Promise<RpcReturn<Fn>>) & {
36
41
  raw: (args?: RpcArgs<Fn>) => Promise<Response>
37
- }
38
-
39
- declare module '${importName}/test/createTestApp' {
40
- interface RpcClient {
41
- ${entries.join('\n')}
42
- }
43
42
  }`
44
- await writeDts(cwd, 'testRpc', body)
43
+ const module = augmentModule(`${importName}/test/createTestApp`, 'RpcClient', entries)
44
+ await writeDts(cwd, 'testRpc', `${helperTypes}\n\n${module}`)
45
45
  }
@@ -1,3 +1,4 @@
1
+ import { augmentModule } from './augmentModule.ts'
1
2
  import { fileStem } from './fileStem.ts'
2
3
  import { socketNameForFile } from './socketNameForFile.ts'
3
4
  import { writeDts } from './writeDts.ts'
@@ -20,15 +21,14 @@ export async function writeTestSocketsDts({
20
21
  importName: string
21
22
  }): Promise<void> {
22
23
  const entries = socketFiles
23
- .map((file) => {
24
+ .map((file): [string, string] => {
24
25
  const importPath = `../server/sockets/${file}`
25
- return ` ${JSON.stringify(socketNameForFile(file))}: typeof import(${JSON.stringify(importPath)}).${fileStem(file)}`
26
+ return [
27
+ socketNameForFile(file),
28
+ `typeof import(${JSON.stringify(importPath)}).${fileStem(file)}`,
29
+ ]
26
30
  })
27
- .toSorted()
28
- const body = `declare module '${importName}/test/createTestApp' {
29
- interface SocketClient {
30
- ${entries.join('\n')}
31
- }
32
- }`
33
- await writeDts(cwd, 'testSockets', body)
31
+ .toSorted(([a], [b]) => (JSON.stringify(a) < JSON.stringify(b) ? -1 : 1))
32
+ const module = augmentModule(`${importName}/test/createTestApp`, 'SocketClient', entries)
33
+ await writeDts(cwd, 'testSockets', module)
34
34
  }
@@ -35,6 +35,7 @@ export const UI_RUNTIME_IMPORTS: { name: string; specifier: string; alias?: stri
35
35
  { name: 'eachAsync', specifier: 'ui/dom/eachAsync', alias: '$$eachAsync' },
36
36
  { name: 'when', specifier: 'ui/dom/when', alias: '$$when' },
37
37
  { name: 'awaitBlock', specifier: 'ui/dom/awaitBlock', alias: '$$awaitBlock' },
38
+ { name: 'deferResume', specifier: 'ui/deferResume', alias: '$$deferResume' },
38
39
  { name: 'tryBlock', specifier: 'ui/dom/tryBlock', alias: '$$tryBlock' },
39
40
  { name: 'switchBlock', specifier: 'ui/dom/switchBlock', alias: '$$switchBlock' },
40
41
  { name: 'mountSlot', specifier: 'ui/dom/mountSlot', alias: '$$mountSlot' },
@@ -3,7 +3,6 @@ import type { BunPlugin } from 'bun'
3
3
  import { fileName } from '../../shared/fileName.ts'
4
4
  import { messageFromError } from '../../shared/messageFromError.ts'
5
5
  import { AbideCompileError } from './AbideCompileError.ts'
6
- import { analyzeComponent } from './analyzeComponent.ts'
7
6
  import { compileModule } from './compileModule.ts'
8
7
  import { nearestProjectRoot } from './nearestProjectRoot.ts'
9
8
  import { offsetToLineColumn } from './offsetToLineColumn.ts'
@@ -63,14 +62,17 @@ export const abideUiPlugin: BunPlugin = {
63
62
  throw new Error(`${message.replace(/^\[abide\]\s*/, `[abide] ${at} — `)}`)
64
63
  }
65
64
  }
66
- const code = compileAbide(() => compileModule(source, { isLayout, moduleId }))
65
+ /* One compile pass yields both the module code and its scoped `<style>` blocks —
66
+ the styles come from the analysis `compileModule` already ran, so the loader no
67
+ longer re-analyzes the source just to recover them. */
68
+ const { code, styles } = compileAbide(() =>
69
+ compileModule(source, { isLayout, moduleId }),
70
+ )
67
71
  /* Browser build with `<style>`(s): concatenate every scoped block's CSS and
68
72
  pull it into the bundle via one virtual import, keyed by `moduleId` so the
69
- registry id and the CSS id agree. */
70
- const styles = compileAbide(() =>
71
- toBrowser ? analyzeComponent(source, moduleId).styles : [],
72
- )
73
- if (styles.length === 0) {
73
+ registry id and the CSS id agree. Server builds skip the import (SSR styling
74
+ comes from the already-linked sheet). */
75
+ if (!toBrowser || styles.length === 0) {
74
76
  return { contents: code, loader: 'ts' }
75
77
  }
76
78
  const virtual = `abide-style:${moduleId}`
@@ -15,20 +15,32 @@ and require that name to be imported or locally bound. A helper name inside a st
15
15
  comment is not a `CallExpression` callee, so a docs component quoting framework code
16
16
  (`mount(...)` in a snippet) never false-positives. Compile-time only; never on the hot path.
17
17
  */
18
- export function assertRuntimeHelpersBound(module: string, context: string): void {
18
+ /* `module` is either the source string (standalone use — parsed here) or the already-parsed
19
+ tree of the bodies WITHOUT the prepended import block (compile pipeline — shares the one
20
+ parse the dead-import filter made). In the latter case the import block's bindings aren't in
21
+ the tree, so `importedHelpers` supplies the helper names that block will bind. */
22
+ export function assertRuntimeHelpersBound(
23
+ module: string | ts.SourceFile,
24
+ importedHelpers: Set<string>,
25
+ context: string,
26
+ ): void {
19
27
  /* The EMITTED local names (the `$$` alias when set) — codegen calls those, and the
20
28
  aliased import binds them, so the bound/called check must use the same form. */
21
29
  const helperNames = new Set(UI_RUNTIME_IMPORTS.map((entry) => entry.alias ?? entry.name))
22
- const source = ts.createSourceFile(
23
- 'module.ts',
24
- module,
25
- ts.ScriptTarget.Latest,
26
- /* setParentNodes */ true,
27
- )
28
- /* Names a bare call can resolve to: every import binding plus every declared name.
29
- Collected generously (any identifier in a binding position) — over-approximating the
30
- bound set only risks missing a defect, never raising a false alarm on valid output. */
31
- const bound = new Set<string>()
30
+ const source =
31
+ typeof module === 'string'
32
+ ? ts.createSourceFile(
33
+ 'module.ts',
34
+ module,
35
+ ts.ScriptTarget.Latest,
36
+ /* setParentNodes */ true,
37
+ )
38
+ : module
39
+ /* Names a bare call can resolve to: the import block's helper bindings (supplied when the
40
+ tree omits them) plus every import binding and declared name in the tree. Collected
41
+ generously (any identifier in a binding position) — over-approximating the bound set only
42
+ risks missing a defect, never raising a false alarm on valid output. */
43
+ const bound = new Set<string>(importedHelpers)
32
44
  const calledHelpers: { name: string; position: number }[] = []
33
45
  const visit = (node: ts.Node): void => {
34
46
  if (
@@ -62,7 +74,7 @@ export function assertRuntimeHelpersBound(module: string, context: string): void
62
74
  if (unbound !== undefined) {
63
75
  const { line, character } = source.getLineAndCharacterOfPosition(unbound.position)
64
76
  throw new Error(
65
- `[abide] ${context} calls runtime helper \`${unbound.name}\` at line ${line + 1}:${character + 1} but never imports it — the dead-import filter dropped it. Please report this with the component source.\nOutput:\n${module}`,
77
+ `[abide] ${context} calls runtime helper \`${unbound.name}\` at line ${line + 1}:${character + 1} but never imports it — the dead-import filter dropped it. Please report this with the component source.\nOutput:\n${source.text}`,
66
78
  )
67
79
  }
68
80
  }