@abide/abide 0.47.0 → 0.49.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 (84) hide show
  1. package/AGENTS.md +26 -13
  2. package/CHANGELOG.md +53 -0
  3. package/README.md +9 -3
  4. package/package.json +3 -1
  5. package/src/buildCli.ts +3 -5
  6. package/src/buildDisconnected.ts +2 -3
  7. package/src/bundleApp.ts +2 -3
  8. package/src/compile.ts +2 -4
  9. package/src/lib/bundle/installDownloads.ts +13 -3
  10. package/src/lib/bundle/installMacMenu.ts +13 -3
  11. package/src/lib/cli/parseArgvForRpc.ts +36 -9
  12. package/src/lib/cli/tokenizeArgvFlags.ts +4 -2
  13. package/src/lib/mcp/createMcpResourceServer.ts +13 -2
  14. package/src/lib/mcp/toolResultFromResponse.ts +40 -12
  15. package/src/lib/server/rpc/parseArgs.ts +15 -1
  16. package/src/lib/server/rpc/runWithRpcTimeout.ts +12 -1
  17. package/src/lib/server/runtime/buildCacheSnapshot.ts +6 -6
  18. package/src/lib/server/runtime/finalizeResponse.ts +11 -1
  19. package/src/lib/server/runtime/snapshotEntryFromCache.ts +10 -2
  20. package/src/lib/server/runtime/types/InspectorCacheEntry.ts +1 -1
  21. package/src/lib/server/runtime/types/InspectorCacheSnapshot.ts +4 -4
  22. package/src/lib/server/runtime/types/InspectorContext.ts +1 -1
  23. package/src/lib/shared/buildArtifact.ts +17 -0
  24. package/src/lib/shared/buildRpcRequest.ts +6 -2
  25. package/src/lib/shared/cache.ts +66 -28
  26. package/src/lib/shared/cacheStores.ts +3 -3
  27. package/src/lib/shared/canonicalJson.ts +24 -1
  28. package/src/lib/shared/createChannelLog.ts +24 -0
  29. package/src/lib/shared/exitOnBuildFailure.ts +4 -3
  30. package/src/lib/shared/parseEnv.ts +17 -6
  31. package/src/lib/shared/refresh.ts +3 -3
  32. package/src/lib/shared/serializeEnv.ts +18 -6
  33. package/src/lib/shared/sharedCacheStore.ts +14 -0
  34. package/src/lib/shared/sharedCacheStoreSlot.ts +10 -0
  35. package/src/lib/shared/snippet.ts +11 -6
  36. package/src/lib/shared/types/CacheOptions.ts +15 -11
  37. package/src/lib/shared/types/CacheStats.ts +1 -1
  38. package/src/lib/shared/types/SmartReadOptions.ts +15 -11
  39. package/src/lib/shared/url.ts +5 -0
  40. package/src/lib/test/createTestApp.ts +6 -6
  41. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
  42. package/src/lib/ui/compile/bindListenEvent.ts +5 -6
  43. package/src/lib/ui/compile/compileSSR.ts +1 -1
  44. package/src/lib/ui/compile/compileShadow.ts +121 -25
  45. package/src/lib/ui/compile/composeProps.ts +3 -2
  46. package/src/lib/ui/compile/desugarSignals.ts +1 -1
  47. package/src/lib/ui/compile/generateBuild.ts +23 -9
  48. package/src/lib/ui/compile/generateSSR.ts +49 -37
  49. package/src/lib/ui/compile/isWhitespaceText.ts +10 -2
  50. package/src/lib/ui/compile/lowerDocAccess.ts +39 -1
  51. package/src/lib/ui/compile/parseTemplate.ts +3 -1
  52. package/src/lib/ui/compile/renameSignalRefs.ts +5 -18
  53. package/src/lib/ui/compile/resolveReactiveExport.ts +4 -7
  54. package/src/lib/ui/compile/scopeCss.ts +27 -4
  55. package/src/lib/ui/dom/awaitBlock.ts +67 -29
  56. package/src/lib/ui/dom/discardBoundary.ts +24 -6
  57. package/src/lib/ui/dom/each.ts +15 -8
  58. package/src/lib/ui/dom/fillBefore.ts +6 -7
  59. package/src/lib/ui/dom/mergeProps.ts +1 -1
  60. package/src/lib/ui/dom/mountSlot.ts +1 -1
  61. package/src/lib/ui/dom/mutateDocArray.ts +38 -0
  62. package/src/lib/ui/dom/restProps.ts +2 -2
  63. package/src/lib/ui/dom/spreadProps.ts +3 -4
  64. package/src/lib/ui/dom/tryBlock.ts +10 -11
  65. package/src/lib/ui/dom/withScope.ts +7 -0
  66. package/src/lib/ui/history.ts +14 -7
  67. package/src/lib/ui/installHotBridge.ts +2 -0
  68. package/src/lib/ui/props.ts +17 -0
  69. package/src/lib/ui/renderChain.ts +7 -3
  70. package/src/lib/ui/router.ts +23 -17
  71. package/src/lib/ui/runtime/CHILD_PRESENT.ts +2 -2
  72. package/src/lib/ui/runtime/applyPatchToTree.ts +16 -3
  73. package/src/lib/ui/runtime/captureModelDoc.ts +22 -7
  74. package/src/lib/ui/runtime/createDoc.ts +28 -12
  75. package/src/lib/ui/runtime/flushEffects.ts +23 -1
  76. package/src/lib/ui/runtime/scope.ts +11 -0
  77. package/src/lib/ui/runtime/toTeardown.ts +10 -3
  78. package/src/lib/ui/runtime/types/UiProps.ts +6 -5
  79. package/src/lib/ui/runtime/withoutHydration.ts +22 -0
  80. package/src/lib/ui/startClient.ts +3 -3
  81. package/src/lib/ui/state.ts +9 -3
  82. package/src/serverEntry.ts +11 -9
  83. package/src/lib/shared/globalCacheStore.ts +0 -15
  84. package/src/lib/shared/globalCacheStoreSlot.ts +0 -12
@@ -1,4 +1,4 @@
1
- import { globalCacheStore } from '../../shared/globalCacheStore.ts'
1
+ import { sharedCacheStore } from '../../shared/sharedCacheStore.ts'
2
2
  import type { CacheEntry } from '../../shared/types/CacheEntry.ts'
3
3
  import type { InspectorCacheEntry } from './types/InspectorCacheEntry.ts'
4
4
  import type { InspectorCacheSnapshot } from './types/InspectorCacheSnapshot.ts'
@@ -48,14 +48,14 @@ function projectEntry(entry: CacheEntry, now: number): InspectorCacheEntry {
48
48
  }
49
49
 
50
50
  /*
51
- Snapshots the process-level cache store (the persistent one `cache(fn, { global:
52
- true })` writes to) for the inspector. Read at call time, so it reflects the
53
- store as it stands; request-scoped stores are deliberately excluded — they're
54
- ephemeral and already visible as per-request cache tallies.
51
+ Snapshots the process-level cache store (the persistent one `cache(fn, { shared:
52
+ true, ttl: Infinity })` writes to) for the inspector. Read at call time, so it
53
+ reflects the store as it stands; request-scoped stores are deliberately
54
+ excluded — they're ephemeral and already visible as per-request cache tallies.
55
55
  */
56
56
  export function buildCacheSnapshot(): InspectorCacheSnapshot {
57
57
  const now = Date.now()
58
- const entries = Array.from(globalCacheStore().entries.values(), (entry) =>
58
+ const entries = Array.from(sharedCacheStore().entries.values(), (entry) =>
59
59
  projectEntry(entry, now),
60
60
  )
61
61
  return { entries }
@@ -1,5 +1,6 @@
1
1
  import { responseBodyKind } from '../../shared/responseBodyKind.ts'
2
2
  import { gzipResponse } from './gzipResponse.ts'
3
+ import { STREAMED_HTML_HEADER } from './STREAMED_HTML_HEADER.ts'
3
4
  import type { RequestStore } from './types/RequestStore.ts'
4
5
 
5
6
  /*
@@ -24,7 +25,16 @@ export function finalizeResponse(
24
25
  exemptIdleTimeout: () => void,
25
26
  ): Response {
26
27
  const kind = responseBodyKind(response)
27
- store.responseStreaming = kind === 'streaming'
28
+ /* Close-monitoring (defer the closing record + cache-stats snapshot until the body
29
+ actually drains) must also cover progressively-streamed SSR HTML — a live
30
+ ReadableStream marked by STREAMED_HTML_HEADER that classifies as 'compressible' (it
31
+ still gzips), NOT as 'streaming'. Read the marker before gzipResponse strips it.
32
+ Without this, a page with a slow `{#await}` logs ~1ms elapsed and near-empty cache
33
+ stats at header time instead of the true drain-time values. */
34
+ const streamedHtml = response.headers.has(STREAMED_HTML_HEADER)
35
+ store.responseStreaming = kind === 'streaming' || streamedHtml
36
+ /* Only true frame protocols (SSE/JSONL, socket tail) opt out of the idle-timeout cap;
37
+ streamed HTML pages deliberately rely on it (see STREAMED_HTML_HEADER). */
28
38
  if (kind === 'streaming') {
29
39
  exemptIdleTimeout()
30
40
  }
@@ -63,8 +63,16 @@ export async function snapshotEntryFromCache(
63
63
  }
64
64
  /* Read a CLONE, not the original: a reader that captured this same `entry.promise`
65
65
  before the replacement below still holds `response` and may `.clone()` it — reading
66
- the original here would lock its body and throw "Body already used" for that reader. */
67
- const body = await response.clone().text()
66
+ the original here would lock its body and throw "Body already used" for that reader.
67
+ The body stream can still error mid-read (reset/truncated upstream) after headers
68
+ resolved — degrade that to undefined (matching the fetch-rejection path) so one flaky
69
+ entry doesn't reject the whole snapshot batch and 500 / abort the page stream. */
70
+ let body: string
71
+ try {
72
+ body = await response.clone().text()
73
+ } catch {
74
+ return undefined
75
+ }
68
76
  entry.promise = Promise.resolve(
69
77
  new Response(body, {
70
78
  status: response.status,
@@ -1,5 +1,5 @@
1
1
  /*
2
- One global cache entry projected for the inspector — the serializable facts the
2
+ One shared cache entry projected for the inspector — the serializable facts the
3
3
  Cache tab renders. The stored promise/Request/timer aren't included; what an
4
4
  operator wants is the entry's identity, lifecycle state, retention, and a peek
5
5
  at the held value.
@@ -1,10 +1,10 @@
1
1
  import type { InspectorCacheEntry } from './InspectorCacheEntry.ts'
2
2
 
3
3
  /*
4
- The inspector's view of the cache: the process-level (`global: true`) store's
5
- current entries. That's the persistent store — request-scoped caches live and
6
- die with their request, so they surface as per-request tallies in the feed and
7
- traces rather than here.
4
+ The inspector's view of the cache: the process-level (`shared: true, ttl:
5
+ Infinity`) store's current entries. That's the persistent store —
6
+ request-scoped caches live and die with their request, so they surface as
7
+ per-request tallies in the feed and traces rather than here.
8
8
  */
9
9
  export type InspectorCacheSnapshot = {
10
10
  entries: InspectorCacheEntry[]
@@ -17,7 +17,7 @@ export type InspectorContext = {
17
17
  app: { name: string; version: string }
18
18
  /* Eager-loads the registries, then projects the current RPC + socket catalog. */
19
19
  loadSurface: () => Promise<InspectorSurface>
20
- /* Snapshots the persistent (global) cache store — current entries with their
20
+ /* Snapshots the persistent (shared) cache store — current entries with their
21
21
  lifecycle state, retention, tags, and a value preview. */
22
22
  cacheSnapshot: () => InspectorCacheSnapshot
23
23
  /* Snapshots the requests whose handler is executing right now — the live
@@ -0,0 +1,17 @@
1
+ import type { BuildConfig, BuildOutput } from 'bun'
2
+ import { exitOnBuildFailure } from './exitOnBuildFailure.ts'
3
+
4
+ /*
5
+ Runs one Bun.build and fails the process on any diagnostic, returning the
6
+ successful output — the build-or-die pairing every one-shot build site shares
7
+ (compile, buildCli's discovery + cli, bundleApp, buildDisconnected). Keeps the
8
+ `Bun.build` + `exitOnBuildFailure` step atomic so a new build site can't ship
9
+ without the failure check. The incremental client build (build.ts) does NOT use
10
+ this: it must clean its staging dir and return `false` rather than exit, so it
11
+ keeps its own epilogue.
12
+ */
13
+ export async function buildArtifact(config: BuildConfig): Promise<BuildOutput> {
14
+ const result = await Bun.build(config)
15
+ exitOnBuildFailure(result)
16
+ return result
17
+ }
@@ -68,8 +68,12 @@ function appendQuery(method: HttpMethod, url: string, args: unknown): string {
68
68
  if (args === undefined) {
69
69
  return url
70
70
  }
71
- if (typeof args !== 'object' || args === null || Array.isArray(args)) {
72
- const got = Array.isArray(args) ? 'array' : typeof args
71
+ const isFormData = typeof FormData !== 'undefined' && args instanceof FormData
72
+ if (typeof args !== 'object' || args === null || Array.isArray(args) || isFormData) {
73
+ /* FormData has no own enumerable keys, so `queryStringFromArgs` would silently drop
74
+ every field; a query-carrying method (GET/DELETE/HEAD) can't take a body anyway.
75
+ Fail loudly like any other non-plain-object arg. */
76
+ const got = Array.isArray(args) ? 'array' : isFormData ? 'FormData' : typeof args
73
77
  throw new Error(`[abide] ${method} ${url} args must be a plain object — got ${got}`)
74
78
  }
75
79
  const suffix = queryStringFromArgs(args as Record<string, unknown>, false)
@@ -3,7 +3,6 @@ import { activeCacheStore } from './activeCacheStore.ts'
3
3
  import { cacheStores } from './cacheStores.ts'
4
4
  import { decodeResponse } from './decodeResponse.ts'
5
5
  import { getRemoteMeta } from './getRemoteMeta.ts'
6
- import { globalCacheStore } from './globalCacheStore.ts'
7
6
  import { HttpError } from './HttpError.ts'
8
7
  import { hydratingSlot } from './hydratingSlot.ts'
9
8
  import { invalidateEvent } from './invalidateEvent.ts'
@@ -16,6 +15,7 @@ import { rpcErrorRegistry } from './rpcErrorRegistry.ts'
16
15
  import { SocketDisconnectedError } from './SocketDisconnectedError.ts'
17
16
  import { selectorMatcher } from './selectorMatcher.ts'
18
17
  import { selectorPrefix } from './selectorPrefix.ts'
18
+ import { sharedCacheStore } from './sharedCacheStore.ts'
19
19
  import { openStreamProbe } from './subscribableProbes.ts'
20
20
  import { toTagSet } from './toTagSet.ts'
21
21
  import type { CacheEntry } from './types/CacheEntry.ts'
@@ -36,7 +36,7 @@ const cacheLog = abideLog.channel('abide:cache')
36
36
 
37
37
  /*
38
38
  Tallies one read and narrates it on the diagnostics channel. The sink is the
39
- request/tab store even when the data store is the process-level global one —
39
+ request/tab store even when the data store is the process-level shared one —
40
40
  attribution follows the asker, so a request's closing record reflects every
41
41
  read it made. A settled retained entry (including the warm SSR sync path) is
42
42
  a hit; an unsettled entry is a coalesced join of an in-flight call; no entry
@@ -62,8 +62,10 @@ Reads a call through a cache store. `cache(fn, args?, options?)` checks the stor
62
62
  for a prior entry and returns a shared promise on hit, or invokes `fn` once and
63
63
  stores its promise on miss — a direct read-through call, not a curried invoker.
64
64
  Args lead (the common refinement); options trail in a fixed final position so
65
- they can't collide with arg shapes. TTL = undefinedforever; ttl = 0 → dedupe
66
- only; ttl > 0 → entry expires `ttl` ms after the promise resolves.
65
+ they can't collide with arg shapes. ttl = 0dedupe only; ttl > 0 → entry
66
+ expires `ttl` ms after the promise resolves. Omitted ttl → forever for a
67
+ producer and for a remote call on the client; a remote call on the server with
68
+ neither ttl nor swr stated defaults to 0 (coalesce-only, see CacheOptions).
67
69
 
68
70
  Coalescing is always on: identical in-flight calls share one flight, so
69
71
  `cache(createPost, args, { ttl: 0 })` is the mutation idiom — double-submit
@@ -87,7 +89,7 @@ new reference every call and never does; a warning fires once per such call
87
89
  site), and the promise is stored and handed back as-is (no Response, no decode,
88
90
  no SSR snapshot).
89
91
 
90
- `options.global` puts the entry in the process-level store instead of the
92
+ `options.shared` puts the entry in the process-level store instead of the
91
93
  request-scoped one, so a value computed in one request is reused by later
92
94
  requests — the memoise-an-external-endpoint case. Default (omitted) is
93
95
  request-scoped on the server, which keeps per-user data from leaking across
@@ -172,9 +174,9 @@ cache.read = smartRead
172
174
 
173
175
  /*
174
176
  The shared read-through core. `smart` marks the smart bare call, which enables
175
- unconditional SWR retention for replayable reads (see smartRead / entry.retain);
176
- the public cache() passes false, keeping its explicit drop-on-ttl /
177
- drop-on-invalidate old surface.
177
+ unconditional SWR retention for a replayable read but only on the client (see
178
+ smartRead / entry.retain and the `retain` computation below); the public cache()
179
+ passes false, keeping its explicit drop-on-ttl / drop-on-invalidate old surface.
178
180
  */
179
181
  function readThrough<Args, Return>(
180
182
  fn: AnyRemote<Args, Return> | Producer<Args, Return>,
@@ -200,30 +202,45 @@ function readThrough<Args, Return>(
200
202
  const method = isRemote ? (rawFn as RawRemoteFunction<Args>).method : undefined
201
203
  const replayable = method !== undefined && REPLAYABLE_METHODS.has(method.toUpperCase())
202
204
  validatePolicy(options, method, smart)
203
- /* Unconditional SWR retention for a smart replayable read (unless opted out). */
204
- const retain = smart && replayable && options?.swr !== false
205
+ /* SWR retention is a client concern (the tab store lives; revalidation is
206
+ visible). On the server there is no live UI to hold stale, so a smart read
207
+ never retains — it coalesces only. */
208
+ const retain = smart && replayable && options?.swr !== false && typeof window !== 'undefined'
205
209
  /*
206
- A smart write is coalesce-only, not retained (design: same as cache(fn, body,
207
- { ttl: 0 })) a second identical submit must re-fire, never replay the first
208
- result. Default a smart write's ttl to 0 when the caller left it open. Reads and
209
- explicit cache() keep the caller's ttl (undefined = forever).
210
+ ttl defaults to 0 (coalesce-only) on the server for any remote read/write, and
211
+ on the client for a smart write in both cases only when the caller stated
212
+ neither ttl nor swr. The server default makes retention opt-in (via an explicit
213
+ ttl, paired with `shared` to survive the request). An explicit swr opts out so
214
+ it does not trip validatePolicy's "swr + ttl:0" throw; server-side swr is a
215
+ client-only concept with no added server guarantee.
210
216
  */
211
- const effectiveOptions =
212
- smart && isRemote && !replayable && options?.ttl === undefined
213
- ? { ...options, ttl: 0 }
214
- : options
217
+ const serverTtlZero =
218
+ isRemote &&
219
+ typeof window === 'undefined' &&
220
+ options?.ttl === undefined &&
221
+ options?.swr === undefined
222
+ const smartWriteTtlZero = smart && isRemote && !replayable && options?.ttl === undefined
223
+ const effectiveOptions = serverTtlZero || smartWriteTtlZero ? { ...options, ttl: 0 } : options
215
224
  if (!isRemote) {
216
225
  warnAnonymousProducer(fn as Producer<Args, Return>)
217
226
  }
218
- const store = effectiveOptions?.global ? globalCacheStore() : activeCacheStore()
227
+ const store = effectiveOptions?.shared ? sharedCacheStore() : activeCacheStore()
219
228
  if (!isRemote) {
220
229
  return invokeProducer(store, fn as Producer<Args, Return>, args, effectiveOptions)
221
230
  }
222
231
  const remote = rawFn as RawRemoteFunction<Args>
223
232
  const key = keyForRemoteCall(remote.method, remote.url, args)
233
+ if (
234
+ !effectiveOptions?.shared &&
235
+ effectiveOptions?.ttl !== undefined &&
236
+ effectiveOptions.ttl > 0 &&
237
+ store !== sharedCacheStore()
238
+ ) {
239
+ warnEphemeralTtl(key)
240
+ }
224
241
  store.subscribe(key)
225
242
  const existing = store.entries.get(key)
226
- recordRead(effectiveOptions?.global ? activeCacheStore() : store, key, existing)
243
+ recordRead(effectiveOptions?.shared ? activeCacheStore() : store, key, existing)
227
244
  if (existing) {
228
245
  tagEntry(existing, effectiveOptions?.tags)
229
246
  attachPolicy(existing, effectiveOptions, () => remote(args as Args), retain)
@@ -390,6 +407,20 @@ function warnAnonymousProducer(producer: (args?: never) => unknown): void {
390
407
  )
391
408
  }
392
409
 
410
+ /* Warn once per key when a ttl>0 read lands in an ephemeral (request-scoped)
411
+ store — the ttl expires with the request. `shared` (the process store) or a
412
+ client tab store are the retention homes; a request-scoped ttl is dead config. */
413
+ const warnedEphemeralTtl = new Set<string>()
414
+ function warnEphemeralTtl(key: string): void {
415
+ if (warnedEphemeralTtl.has(key)) {
416
+ return
417
+ }
418
+ warnedEphemeralTtl.add(key)
419
+ abideLog.warn(
420
+ `cache(): a request-scoped ttl expires with the request — add \`shared\` to cache "${key}" across requests, or drop the ttl.`,
421
+ )
422
+ }
423
+
393
424
  /*
394
425
  Producer path: key on the producer's reference + args, share the
395
426
  in-flight/retained promise on hit, and store the value promise as-is on miss — no
@@ -404,7 +435,7 @@ function invokeProducer<Args, Return>(
404
435
  const key = producerKey(producer, args)
405
436
  store.subscribe(key)
406
437
  const existing = store.entries.get(key)
407
- recordRead(options?.global ? activeCacheStore() : store, key, existing)
438
+ recordRead(options?.shared ? activeCacheStore() : store, key, existing)
408
439
  if (existing) {
409
440
  tagEntry(existing, options?.tags)
410
441
  attachPolicy(existing, options, () => producer(args), false)
@@ -502,11 +533,18 @@ function registerEntry(
502
533
  own method filter; writes never ship). The keep never applies on the
503
534
  client (the tab store outlives any unit — a kept write would block every
504
535
  future re-submit, so entries evict the moment they settle), to producer
505
- entries (no request), or to the process-level `global` store (not
506
- request-scopedkeeping it would leak forever).
536
+ entries (no request), or when the resolved store is the process-level
537
+ `shared` store including a non-shared read made outside an inbound
538
+ request, where the resolver falls back to `sharedCacheStore()` (Decision
539
+ 1): that store is never request-scoped, so keeping it would leak forever.
540
+ The `store !== sharedCacheStore()` check is what tells a genuinely
541
+ request-scoped store apart from that fallback.
507
542
  */
508
543
  const keepZeroTtlForRequest =
509
- request !== undefined && !options?.global && typeof window === 'undefined'
544
+ request !== undefined &&
545
+ !options?.shared &&
546
+ typeof window === 'undefined' &&
547
+ store !== sharedCacheStore()
510
548
  function deleteIfCurrent() {
511
549
  evictIfCurrent(store, entry)
512
550
  }
@@ -890,10 +928,10 @@ function peek<Args, Return>(
890
928
  active.trackLifecycle(key)
891
929
  let entry = active.entries.get(key)
892
930
  if (entry === undefined) {
893
- const global = globalCacheStore()
894
- global.subscribe(key)
895
- global.trackLifecycle(key)
896
- entry = global.entries.get(key)
931
+ const shared = sharedCacheStore()
932
+ shared.subscribe(key)
933
+ shared.trackLifecycle(key)
934
+ entry = shared.entries.get(key)
897
935
  }
898
936
  if (entry === undefined || entry.settled !== true || entry.value === undefined) {
899
937
  return undefined
@@ -1,10 +1,10 @@
1
1
  import { activeCacheStore } from './activeCacheStore.ts'
2
- import { globalCacheStore } from './globalCacheStore.ts'
2
+ import { sharedCacheStore } from './sharedCacheStore.ts'
3
3
  import type { CacheStore } from './types/CacheStore.ts'
4
4
 
5
5
  /* Active + process-level stores, deduped (one tab store on the client). */
6
6
  export function cacheStores(): CacheStore[] {
7
7
  const active = activeCacheStore()
8
- const global = globalCacheStore()
9
- return active === global ? [active] : [active, global]
8
+ const shared = sharedCacheStore()
9
+ return active === shared ? [active] : [active, shared]
10
10
  }
@@ -8,7 +8,8 @@ POST/PUT/PATCH bodies; the output is a key, not a request body, so it is free to
8
8
  encode types JSON.stringify would silently flatten (Map/Set → {}), coerce
9
9
  (Date → its ISO string, via toJSON before any replacer sees it) or drop
10
10
  (undefined). Covers the value types commonly passed as rpc args: primitives,
11
- arrays, plain objects, Date, Map, Set, and bigint. Functions and symbols can't
11
+ arrays, plain objects, Date, Map, Set, bigint, and FormData/File/Blob (the
12
+ multipart upload path). Functions and symbols can't
12
13
  key anything meaningful but are tagged rather than dropped so a stray one can't
13
14
  silently collapse two distinct argument sets onto the same key.
14
15
  */
@@ -55,6 +56,28 @@ export function canonicalJson(value: unknown): string {
55
56
  const members = Array.from(value, canonicalJson).sort()
56
57
  return `Set{${members.join(',')}}`
57
58
  }
59
+ /* FormData is the documented multipart escape hatch for body rpcs. Its fields are
60
+ NOT own enumerable keys, so the generic object branch below would return `{}` for
61
+ every distinct upload — collapsing them onto one cache key and coalescing unrelated
62
+ uploads onto each other. Encode its entries (sorted, so field order doesn't change
63
+ the key) with File/Blob distinguished by identity attributes below. */
64
+ if (typeof FormData !== 'undefined' && value instanceof FormData) {
65
+ const entries: string[] = []
66
+ value.forEach((entryValue, entryKey) => {
67
+ entries.push(`${JSON.stringify(entryKey)}=>${canonicalJson(entryValue)}`)
68
+ })
69
+ entries.sort()
70
+ return `FormData{${entries.join(',')}}`
71
+ }
72
+ /* File extends Blob — check it first so a named upload keys on its name too. Contents
73
+ can't be read synchronously, so identity attributes are the best available key;
74
+ distinct files (name/size/type/mtime) get distinct keys, which is the fix. */
75
+ if (typeof File !== 'undefined' && value instanceof File) {
76
+ return `File(${JSON.stringify(value.name)},${value.size},${JSON.stringify(value.type)},${value.lastModified})`
77
+ }
78
+ if (typeof Blob !== 'undefined' && value instanceof Blob) {
79
+ return `Blob(${value.size},${JSON.stringify(value.type)})`
80
+ }
58
81
  const record = value as Record<string, unknown>
59
82
  const entries = Object.keys(record)
60
83
  .sort()
@@ -24,8 +24,32 @@ function channelPatterns(): string | undefined {
24
24
  return undefined
25
25
  }
26
26
 
27
+ /* One line for an AggregateError sub-error: its message, prefixed with the source
28
+ position when it carries one. A thrown `Bun.build` failure holds a `BuildMessage`
29
+ per diagnostic, each with a `position.file` — naming that file is the point, since
30
+ without it a codegen/parse failure reads as a detail-free "Bundle failed". */
31
+ function formatAggregateEntry(entry: unknown): string {
32
+ if (typeof entry === 'object' && entry !== null && 'message' in entry) {
33
+ const message = String((entry as { message: unknown }).message)
34
+ const position = (entry as { position?: { file?: string; line?: number; column?: number } })
35
+ .position
36
+ if (position?.file) {
37
+ return ` ${position.file}:${position.line ?? 0}:${position.column ?? 0} — ${message}`
38
+ }
39
+ return ` ${message}`
40
+ }
41
+ return ` ${String(entry)}`
42
+ }
43
+
27
44
  // Prefers a full stack trace when the value is an Error so logs include the call site.
45
+ // An AggregateError (a thrown Bun.build failure among them) carries the real diagnostics
46
+ // in `.errors` — its own message is only a summary ("Bundle failed") — so expand each one,
47
+ // or every per-file position is dropped and the failure reads with zero detail.
28
48
  function errorParts(value: unknown): { msg: string; stack?: string } {
49
+ if (value instanceof AggregateError && value.errors.length > 0) {
50
+ const details = value.errors.map(formatAggregateEntry).join('\n')
51
+ return { msg: `${value.message}\n${details}` }
52
+ }
29
53
  if (value instanceof Error) {
30
54
  return { msg: value.message, stack: value.stack }
31
55
  }
@@ -2,9 +2,10 @@ import type { BuildOutput } from 'bun'
2
2
  import { abideLog } from './abideLog.ts'
3
3
 
4
4
  /*
5
- On a failed Bun.build(), logs each diagnostic and exits non-zero. Every abide
6
- build entrypoint (build / compile / buildCli / bundleApp) funnels its result
7
- through here so failure reporting can't drift between them.
5
+ On a failed Bun.build(), logs each diagnostic and exits non-zero. The one-shot
6
+ build entrypoints reach it through `buildArtifact` (build-or-die); the
7
+ incremental client build (build.ts) calls it directly on its conditional-exit
8
+ path. One reporter so build failure can't drift between them.
8
9
  */
9
10
  export function exitOnBuildFailure(result: BuildOutput): void {
10
11
  if (result.success) {
@@ -9,7 +9,9 @@ counterpart to loadEnvFile (which merges into process.env) and serializeEnv
9
9
  */
10
10
  export function parseEnv(text: string): Record<string, string> {
11
11
  const result: Record<string, string> = {}
12
- for (const line of text.split('\n')) {
12
+ // Split on CRLF or LF — a Windows-saved (or git autocrlf) .env otherwise leaves a
13
+ // trailing \r that breaks ENV_LINE's `$` anchor, silently dropping every line.
14
+ for (const line of text.split(/\r?\n/)) {
13
15
  if (!line || line.startsWith('#')) {
14
16
  continue
15
17
  }
@@ -19,11 +21,20 @@ export function parseEnv(text: string): Record<string, string> {
19
21
  }
20
22
  const [, key, rawValue] = match
21
23
  const trimmed = rawValue?.trim() ?? ''
22
- const unquoted =
23
- (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
24
- (trimmed.startsWith("'") && trimmed.endsWith("'"))
25
- ? trimmed.slice(1, -1)
26
- : trimmed
24
+ let unquoted: string
25
+ if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
26
+ // Double-quoted: unescape the sequences serializeEnv writes (`\\`, `\"`, `\n`, `\r`).
27
+ unquoted = trimmed
28
+ .slice(1, -1)
29
+ .replace(/\\([nr"\\])/g, (_, c: string) =>
30
+ c === 'n' ? '\n' : c === 'r' ? '\r' : c,
31
+ )
32
+ } else if (trimmed.length >= 2 && trimmed.startsWith("'") && trimmed.endsWith("'")) {
33
+ // Single-quoted: taken verbatim (no escaping applied on the way out).
34
+ unquoted = trimmed.slice(1, -1)
35
+ } else {
36
+ unquoted = trimmed
37
+ }
27
38
  result[key as string] = unquoted
28
39
  }
29
40
  return result
@@ -4,9 +4,9 @@ import type { CacheSelector } from './types/CacheSelector.ts'
4
4
  /*
5
5
  Refetch every cached read matching the selector, keeping the stale value visible
6
6
  until the fresh one swaps in (refreshing() true meanwhile) — the smart-call
7
- refetch. Because the smart read retains its value (SWR unconditional), a refresh
8
- always refetches-and-swaps; it never drops to a pending blank. Follows the shared
9
- selector grammar:
7
+ refetch. Because the smart read retains its value on the client (SWR
8
+ unconditional there), a refresh always refetches-and-swaps; it never drops to a
9
+ pending blank. Follows the shared selector grammar:
10
10
 
11
11
  refresh(getFoo, args) → that exact call
12
12
  refresh(getFoo) → every args-variant of that rpc
@@ -1,18 +1,30 @@
1
- // Quote only values that wouldn't round-trip bare through parseEnv empties and
2
- // anything carrying whitespace or a `#` (which would otherwise read as a comment).
1
+ // Quote any value that wouldn't round-trip bare through parseEnv: empties, anything
2
+ // carrying whitespace/`#` (a comment)/a quote/a backslash, or one that starts or ends with
3
+ // a quote char (parseEnv strips a surrounding quote pair). Everything else stays bare.
3
4
  function needsQuoting(value: string): boolean {
4
- return value === '' || /[\s#]/.test(value)
5
+ return value === '' || /[\s#"\\]/.test(value) || /^['"]|['"]$/.test(value)
6
+ }
7
+
8
+ // Escape a value for inside double quotes: backslash and quote so the closing quote can't
9
+ // be faked, and newlines so an embedded `\n` can't inject a whole new `KEY=value` line
10
+ // (the security fix — an unescaped token newline used to forge extra env entries).
11
+ function escapeQuoted(value: string): string {
12
+ return value
13
+ .replace(/\\/g, '\\\\')
14
+ .replace(/"/g, '\\"')
15
+ .replace(/\n/g, '\\n')
16
+ .replace(/\r/g, '\\r')
5
17
  }
6
18
 
7
19
  /*
8
20
  Serializes a key→value record to `.env` text — the inverse of parseEnv, used by
9
21
  the connect-screen config form to persist the user's answers to the data-dir
10
- `.env`. One `KEY=value` per line; values that need it are wrapped in double
11
- quotes so parseEnv reads them back unchanged.
22
+ `.env`. One `KEY=value` per line; values that need it are double-quoted and escaped
23
+ so parseEnv reads them back unchanged and a value can never inject extra lines.
12
24
  */
13
25
  export function serializeEnv(values: Record<string, string>): string {
14
26
  const lines = Object.entries(values).map(([key, value]) =>
15
- needsQuoting(value) ? `${key}="${value}"` : `${key}=${value}`,
27
+ needsQuoting(value) ? `${key}="${escapeQuoted(value)}"` : `${key}=${value}`,
16
28
  )
17
29
  return `${lines.join('\n')}\n`
18
30
  }
@@ -0,0 +1,14 @@
1
+ import { activeCacheStore } from './activeCacheStore.ts'
2
+ import { sharedCacheStoreSlot } from './sharedCacheStoreSlot.ts'
3
+ import type { CacheStore } from './types/CacheStore.ts'
4
+
5
+ /*
6
+ Resolves the process-level ("shared") CacheStore that `cache(fn, { shared: true })`
7
+ entries live in. The server entry registers a module-singleton resolver so the
8
+ store survives across requests; the client points it at the active tab store.
9
+ When no resolver is registered it falls back to the active store, so `shared`
10
+ degrades to request/tab-scoped rather than throwing.
11
+ */
12
+ export function sharedCacheStore(): CacheStore {
13
+ return sharedCacheStoreSlot.resolver?.() ?? activeCacheStore()
14
+ }
@@ -0,0 +1,10 @@
1
+ import { createResolverSlot } from './createResolverSlot.ts'
2
+ import type { CacheStore } from './types/CacheStore.ts'
3
+
4
+ /*
5
+ The process-level ("shared") CacheStore slot that `cache(fn, { shared: true })`
6
+ entries live in. The server entry registers a module-singleton store outliving
7
+ every request; the client points it at the active tab store. When no resolver
8
+ is registered, sharedCacheStore() falls back to the active store.
9
+ */
10
+ export const sharedCacheStoreSlot = createResolverSlot<CacheStore>()
@@ -1,18 +1,23 @@
1
1
  const SNIPPET = Symbol.for('abide.snippet')
2
2
 
3
- /* A `{#snippet name(args)}` snippet, called like a function: it carries a payload a
4
- `{expr}` interpolation mounts in place — a DOM builder `(host) => void` on the
3
+ /* The internal payload a snippet carries a DOM builder `(host) => void` on the
5
4
  client, the pre-rendered HTML string on the server. The brand is a registered
6
5
  Symbol so it survives across module/bundle copies (same idiom as `html\`\``). */
7
- export type Snippet<Payload> = { readonly [SNIPPET]: Payload }
6
+ export type SnippetValue = { readonly [SNIPPET]: unknown }
7
+
8
+ /* The author-facing snippet type: a builder invoked with its arguments, yielding a
9
+ mountable value. `children` is `Snippet` (no args, invoked `children()`); a row
10
+ renderer is `Snippet<[Item]>` (invoked `row(item)`). The side-specific payload is
11
+ hidden behind `SnippetValue`. */
12
+ export type Snippet<Args extends unknown[] = []> = (...args: Args) => SnippetValue
8
13
 
9
14
  /* Brands a snippet payload so a `{expr}` interpolation mounts it instead of
10
15
  inserting escaped text. The compiler wraps a snippet's body in this — the client
11
16
  builder closes over the defining component's scope, the server string is its SSR
12
17
  render — so a snippet value passes through props like any other value. */
13
18
  // @documentation templating
14
- export function snippet<Payload>(payload: Payload): Snippet<Payload> {
15
- return { [SNIPPET]: payload }
19
+ export function snippet<Payload>(payload: Payload): SnippetValue {
20
+ return { [SNIPPET]: payload } as SnippetValue
16
21
  }
17
22
 
18
23
  /* The payload of a snippet-branded value, or undefined for anything else — so a
@@ -20,6 +25,6 @@ export function snippet<Payload>(payload: Payload): Snippet<Payload> {
20
25
  reads a builder function; the server reads the rendered string. */
21
26
  export function snippetPayload(value: unknown): unknown {
22
27
  return value !== null && typeof value === 'object' && SNIPPET in value
23
- ? (value as Snippet<unknown>)[SNIPPET]
28
+ ? (value as SnippetValue)[SNIPPET]
24
29
  : undefined
25
30
  }
@@ -2,21 +2,25 @@
2
2
  Options for cache(). The key is always auto-derived (method+url+args for a remote
3
3
  function, producer-reference+args for a plain producer): hoist a producer to a
4
4
  stable reference to share its entry across calls. `ttl` is the
5
- milliseconds-past-resolve that the entry stays live: omitted = forever, 0 =
6
- dedupe only (entry dropped once the promise settles — the mutation idiom:
7
- in-flight coalescing and pending() visibility, nothing retained), any other
8
- number = TTL.
5
+ milliseconds-past-resolve that the entry stays live: 0 = dedupe only (entry
6
+ dropped once the promise settles — the mutation idiom: in-flight coalescing and
7
+ pending() visibility, nothing retained), any other number = TTL. Omitted = forever
8
+ for a producer and for a remote call on the client; a remote call on the SERVER
9
+ with neither `ttl` nor `swr` stated defaults to 0 (coalesce-only — the request is
10
+ the atomic unit, nothing is retained past it; pair with `shared` + an explicit
11
+ `ttl` to memoise across requests).
9
12
  `tags` is an array of free-form labels grouping unrelated calls so one
10
13
  `cache.invalidate({ tags })` drops every entry sharing any of them — list
11
14
  multiple when a call belongs to multiple invalidation groups. A unique tag (e.g.
12
15
  a uuid) shared by a set of calls gives them their own private invalidation group.
13
16
 
14
- `global` opts the entry into the process-level store instead of the default
15
- request-scoped one (server) — so a value computed in one request is reused by
16
- later requests, e.g. memoising an external endpoint the server calls. Omit it
17
- for per-request data: the default keeps a per-user response from leaking across
18
- requests. Write only `global: true`; there is no `false` form. On the client
19
- there is a single tab store, so the flag is a no-op there.
17
+ `shared` opts the entry into the process-level store instead of the default
18
+ request-scoped one (server) — a store that outlives every request. It selects
19
+ the store only; it does NOT retain (pair it with `ttl` to memoise across
20
+ requests). The shared store is keyed by method+url+args, never by user, so do
21
+ not put per-user data in it it would be served to other users. Omit `shared`
22
+ for per-request data. Write only `shared: true`; there is no `false` form. On
23
+ the client there is a single tab store, so the flag is a no-op there.
20
24
 
21
25
  `swr` is stale-while-revalidate: it changes what a `cache.invalidate` hit does
22
26
  to this key. Without it, an invalidate drops the entry and the next read shows
@@ -44,7 +48,7 @@ with the same wrap-time guard as the `swr` window: set one, not both.
44
48
  export type CacheOptions = {
45
49
  ttl?: number
46
50
  tags?: string[]
47
- global?: boolean
51
+ shared?: boolean
48
52
  swr?: boolean | { throttle?: number; debounce?: number }
49
53
  throttle?: number
50
54
  debounce?: number