@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.
- package/AGENTS.md +26 -13
- package/CHANGELOG.md +53 -0
- package/README.md +9 -3
- package/package.json +3 -1
- package/src/buildCli.ts +3 -5
- package/src/buildDisconnected.ts +2 -3
- package/src/bundleApp.ts +2 -3
- package/src/compile.ts +2 -4
- package/src/lib/bundle/installDownloads.ts +13 -3
- package/src/lib/bundle/installMacMenu.ts +13 -3
- package/src/lib/cli/parseArgvForRpc.ts +36 -9
- package/src/lib/cli/tokenizeArgvFlags.ts +4 -2
- package/src/lib/mcp/createMcpResourceServer.ts +13 -2
- package/src/lib/mcp/toolResultFromResponse.ts +40 -12
- package/src/lib/server/rpc/parseArgs.ts +15 -1
- package/src/lib/server/rpc/runWithRpcTimeout.ts +12 -1
- package/src/lib/server/runtime/buildCacheSnapshot.ts +6 -6
- package/src/lib/server/runtime/finalizeResponse.ts +11 -1
- package/src/lib/server/runtime/snapshotEntryFromCache.ts +10 -2
- package/src/lib/server/runtime/types/InspectorCacheEntry.ts +1 -1
- package/src/lib/server/runtime/types/InspectorCacheSnapshot.ts +4 -4
- package/src/lib/server/runtime/types/InspectorContext.ts +1 -1
- package/src/lib/shared/buildArtifact.ts +17 -0
- package/src/lib/shared/buildRpcRequest.ts +6 -2
- package/src/lib/shared/cache.ts +66 -28
- package/src/lib/shared/cacheStores.ts +3 -3
- package/src/lib/shared/canonicalJson.ts +24 -1
- package/src/lib/shared/createChannelLog.ts +24 -0
- package/src/lib/shared/exitOnBuildFailure.ts +4 -3
- package/src/lib/shared/parseEnv.ts +17 -6
- package/src/lib/shared/refresh.ts +3 -3
- package/src/lib/shared/serializeEnv.ts +18 -6
- package/src/lib/shared/sharedCacheStore.ts +14 -0
- package/src/lib/shared/sharedCacheStoreSlot.ts +10 -0
- package/src/lib/shared/snippet.ts +11 -6
- package/src/lib/shared/types/CacheOptions.ts +15 -11
- package/src/lib/shared/types/CacheStats.ts +1 -1
- package/src/lib/shared/types/SmartReadOptions.ts +15 -11
- package/src/lib/shared/url.ts +5 -0
- package/src/lib/test/createTestApp.ts +6 -6
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
- package/src/lib/ui/compile/bindListenEvent.ts +5 -6
- package/src/lib/ui/compile/compileSSR.ts +1 -1
- package/src/lib/ui/compile/compileShadow.ts +121 -25
- package/src/lib/ui/compile/composeProps.ts +3 -2
- package/src/lib/ui/compile/desugarSignals.ts +1 -1
- package/src/lib/ui/compile/generateBuild.ts +23 -9
- package/src/lib/ui/compile/generateSSR.ts +49 -37
- package/src/lib/ui/compile/isWhitespaceText.ts +10 -2
- package/src/lib/ui/compile/lowerDocAccess.ts +39 -1
- package/src/lib/ui/compile/parseTemplate.ts +3 -1
- package/src/lib/ui/compile/renameSignalRefs.ts +5 -18
- package/src/lib/ui/compile/resolveReactiveExport.ts +4 -7
- package/src/lib/ui/compile/scopeCss.ts +27 -4
- package/src/lib/ui/dom/awaitBlock.ts +67 -29
- package/src/lib/ui/dom/discardBoundary.ts +24 -6
- package/src/lib/ui/dom/each.ts +15 -8
- package/src/lib/ui/dom/fillBefore.ts +6 -7
- package/src/lib/ui/dom/mergeProps.ts +1 -1
- package/src/lib/ui/dom/mountSlot.ts +1 -1
- package/src/lib/ui/dom/mutateDocArray.ts +38 -0
- package/src/lib/ui/dom/restProps.ts +2 -2
- package/src/lib/ui/dom/spreadProps.ts +3 -4
- package/src/lib/ui/dom/tryBlock.ts +10 -11
- package/src/lib/ui/dom/withScope.ts +7 -0
- package/src/lib/ui/history.ts +14 -7
- package/src/lib/ui/installHotBridge.ts +2 -0
- package/src/lib/ui/props.ts +17 -0
- package/src/lib/ui/renderChain.ts +7 -3
- package/src/lib/ui/router.ts +23 -17
- package/src/lib/ui/runtime/CHILD_PRESENT.ts +2 -2
- package/src/lib/ui/runtime/applyPatchToTree.ts +16 -3
- package/src/lib/ui/runtime/captureModelDoc.ts +22 -7
- package/src/lib/ui/runtime/createDoc.ts +28 -12
- package/src/lib/ui/runtime/flushEffects.ts +23 -1
- package/src/lib/ui/runtime/scope.ts +11 -0
- package/src/lib/ui/runtime/toTeardown.ts +10 -3
- package/src/lib/ui/runtime/types/UiProps.ts +6 -5
- package/src/lib/ui/runtime/withoutHydration.ts +22 -0
- package/src/lib/ui/startClient.ts +3 -3
- package/src/lib/ui/state.ts +9 -3
- package/src/serverEntry.ts +11 -9
- package/src/lib/shared/globalCacheStore.ts +0 -15
- package/src/lib/shared/globalCacheStoreSlot.ts +0 -12
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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, {
|
|
52
|
-
true })` writes to) for the inspector. Read at call time, so it
|
|
53
|
-
store as it stands; request-scoped stores are deliberately
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (`
|
|
5
|
-
current entries. That's the persistent store —
|
|
6
|
-
die with their request, so they surface as
|
|
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 (
|
|
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
|
-
|
|
72
|
-
|
|
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)
|
package/src/lib/shared/cache.ts
CHANGED
|
@@ -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
|
|
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.
|
|
66
|
-
|
|
65
|
+
they can't collide with arg shapes. ttl = 0 → dedupe 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.
|
|
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
|
|
176
|
-
|
|
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
|
-
/*
|
|
204
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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?.
|
|
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?.
|
|
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?.
|
|
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
|
|
506
|
-
|
|
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 &&
|
|
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
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
entry =
|
|
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 {
|
|
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
|
|
9
|
-
return active ===
|
|
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,
|
|
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.
|
|
6
|
-
build
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
(
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
8
|
-
always refetches-and-swaps; it never drops to a
|
|
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
|
|
2
|
-
//
|
|
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
|
|
11
|
-
|
|
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
|
-
/*
|
|
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
|
|
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):
|
|
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
|
|
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:
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
`
|
|
15
|
-
request-scoped one (server) —
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
51
|
+
shared?: boolean
|
|
48
52
|
swr?: boolean | { throttle?: number; debounce?: number }
|
|
49
53
|
throttle?: number
|
|
50
54
|
debounce?: number
|