@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.
- package/AGENTS.md +8 -6
- package/CHANGELOG.md +64 -0
- package/README.md +171 -181
- package/package.json +2 -1
- package/src/abideResolverPlugin.ts +6 -1
- package/src/devEntry.ts +43 -9
- package/src/lib/cli/parseArgvForRpc.ts +21 -24
- package/src/lib/cli/runCli.ts +9 -24
- package/src/lib/cli/tokenizeArgvFlags.ts +84 -0
- package/src/lib/server/error.ts +48 -53
- package/src/lib/server/json.ts +4 -3
- package/src/lib/server/jsonl.ts +1 -1
- package/src/lib/server/rpc/defineRpc.ts +11 -9
- package/src/lib/server/rpc/types/RemoteHandler.ts +5 -7
- package/src/lib/server/rpc/types/RpcHelper.ts +125 -35
- package/src/lib/server/rpc/types/TypedError.ts +18 -0
- package/src/lib/server/rpc/validationError.ts +3 -3
- package/src/lib/server/runtime/STATUS_TEXT.ts +22 -0
- package/src/lib/server/runtime/createServer.ts +36 -12
- package/src/lib/server/runtime/devHotModuleResponse.ts +1 -1
- package/src/lib/server/runtime/gzipResponse.ts +14 -16
- package/src/lib/server/runtime/installAmbientScopeStore.ts +33 -0
- package/src/lib/server/runtime/runWithRequestScope.ts +4 -1
- package/src/lib/server/runtime/snapshotEntryFromCache.ts +2 -0
- package/src/lib/server/runtime/typedErrorResponse.ts +26 -0
- package/src/lib/server/runtime/types/RequestStore.ts +25 -0
- package/src/lib/server/runtime/withResponseDefaults.ts +8 -3
- package/src/lib/server/sockets/defineSocket.ts +5 -1
- package/src/lib/server/sse.ts +1 -1
- package/src/lib/shared/HttpError.ts +1 -1
- package/src/lib/shared/RPC_ARGS_TYPE.ts +7 -0
- package/src/lib/shared/activeCacheStore.ts +5 -14
- package/src/lib/shared/activePage.ts +5 -19
- package/src/lib/shared/augmentModule.ts +18 -0
- package/src/lib/shared/basePath.ts +6 -5
- package/src/lib/shared/baseResolver.ts +10 -0
- package/src/lib/shared/baseSlot.ts +6 -12
- package/src/lib/shared/cache.ts +46 -4
- package/src/lib/shared/cacheEntryFromSnapshot.ts +21 -1
- package/src/lib/shared/cacheKeyOf.ts +7 -0
- package/src/lib/shared/cacheKeyStore.ts +8 -0
- package/src/lib/shared/cacheStoreResolver.ts +12 -0
- package/src/lib/shared/cacheStoreSlot.ts +5 -13
- package/src/lib/shared/changeAffectsClient.ts +35 -0
- package/src/lib/shared/createResolverSlot.ts +37 -0
- package/src/lib/shared/debugGate.ts +90 -0
- package/src/lib/shared/emitLogRecord.ts +18 -4
- package/src/lib/shared/globalCacheStoreResolver.ts +12 -0
- package/src/lib/shared/globalCacheStoreSlot.ts +6 -11
- package/src/lib/shared/httpErrorFor.ts +1 -1
- package/src/lib/shared/isDebugEnabled.ts +3 -10
- package/src/lib/shared/isDebugNegated.ts +4 -6
- package/src/lib/shared/pageResolver.ts +16 -0
- package/src/lib/shared/pageSlot.ts +5 -14
- package/src/lib/shared/recordCacheKey.ts +6 -0
- package/src/lib/shared/requestScopeResolver.ts +12 -0
- package/src/lib/shared/requestScopeSlot.ts +7 -12
- package/src/lib/shared/responseBodyKind.ts +35 -0
- package/src/lib/shared/setBaseResolver.ts +2 -4
- package/src/lib/shared/setCacheStoreResolver.ts +3 -5
- package/src/lib/shared/setGlobalCacheStoreResolver.ts +3 -5
- package/src/lib/shared/setPageResolver.ts +2 -5
- package/src/lib/shared/setRequestScopeResolver.ts +3 -5
- package/src/lib/shared/toTagSet.ts +3 -3
- package/src/lib/shared/types/CacheEntry.ts +15 -0
- package/src/lib/shared/types/CacheOptions.ts +5 -5
- package/src/lib/shared/types/CacheSnapshotEntry.ts +5 -0
- package/src/lib/shared/types/DebugGate.ts +10 -0
- package/src/lib/shared/types/ErrorSpec.ts +6 -6
- package/src/lib/shared/types/RemoteFunction.ts +8 -4
- package/src/lib/shared/types/ResolverSlot.ts +10 -0
- package/src/lib/shared/writeDts.ts +8 -1
- package/src/lib/shared/writePublicAssetsDts.ts +5 -9
- package/src/lib/shared/writeRoutesDts.ts +12 -16
- package/src/lib/shared/writeRpcDts.ts +13 -12
- package/src/lib/shared/writeTestRpcDts.ts +11 -11
- package/src/lib/shared/writeTestSocketsDts.ts +9 -9
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
- package/src/lib/ui/compile/abideUiPlugin.ts +9 -7
- package/src/lib/ui/compile/assertRuntimeHelpersBound.ts +24 -12
- package/src/lib/ui/compile/compileModule.ts +37 -16
- package/src/lib/ui/compile/compileShadow.ts +75 -1
- package/src/lib/ui/compile/createShadowLanguageService.ts +29 -3
- package/src/lib/ui/compile/generateBuild.ts +7 -1
- package/src/lib/ui/compile/generateSSR.ts +10 -3
- package/src/lib/ui/compile/parseTemplate.ts +4 -1
- package/src/lib/ui/compile/types/TemplateNode.ts +1 -1
- package/src/lib/ui/createScope.ts +11 -4
- package/src/lib/ui/deferResume.ts +29 -0
- package/src/lib/ui/dom/appendText.ts +19 -4
- package/src/lib/ui/dom/awaitBlock.ts +74 -26
- package/src/lib/ui/dom/eachAsync.ts +11 -18
- package/src/lib/ui/dom/mountSwappableRange.ts +79 -0
- package/src/lib/ui/dom/skeleton.ts +10 -1
- package/src/lib/ui/dom/switchBlock.ts +15 -63
- package/src/lib/ui/dom/when.ts +11 -64
- package/src/lib/ui/installHotBridge.ts +2 -0
- package/src/lib/ui/matchRoute.ts +18 -1
- package/src/lib/ui/remoteProxy.ts +11 -1
- package/src/lib/ui/renderToStream.ts +13 -8
- package/src/lib/ui/resumeSeedScript.ts +2 -2
- package/src/lib/ui/runtime/CURRENT_SCOPE.ts +14 -1
- package/src/lib/ui/runtime/RESUME.ts +6 -0
- package/src/lib/ui/runtime/ambientScopeBacking.ts +26 -0
- package/src/lib/ui/runtime/createDoc.ts +102 -12
- package/src/lib/ui/runtime/createEffectNode.ts +7 -1
- package/src/lib/ui/runtime/flushEffects.ts +18 -0
- package/src/lib/ui/runtime/generationGuard.ts +40 -0
- package/src/lib/ui/runtime/runNode.ts +9 -1
- package/src/lib/ui/runtime/types/SsrRender.ts +4 -3
- package/src/lib/ui/scope.ts +6 -8
- package/src/lib/ui/tryEncodeResume.ts +5 -2
- package/src/lib/ui/types/Scope.ts +3 -4
- package/src/zodCjsPlugin.ts +16 -1
- package/src/lib/server/rpc/buildErrorConstructors.ts +0 -19
- package/src/lib/shared/matchesDebugPattern.ts +0 -16
- package/src/lib/shared/parseDebugPatterns.ts +0 -21
- package/src/lib/shared/types/ErrorConstructors.ts +0 -17
- package/src/lib/shared/types/ErrorDescriptor.ts +0 -10
package/src/devEntry.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { DEV_READY_MESSAGE } from './lib/server/runtime/DEV_READY_MESSAGE.ts'
|
|
|
6
6
|
import { DEV_REBUILD_MESSAGE } from './lib/server/runtime/DEV_REBUILD_MESSAGE.ts'
|
|
7
7
|
import { findOpenPort } from './lib/server/runtime/findOpenPort.ts'
|
|
8
8
|
import { abideLog } from './lib/shared/abideLog.ts'
|
|
9
|
+
import { changeAffectsClient } from './lib/shared/changeAffectsClient.ts'
|
|
9
10
|
|
|
10
11
|
/*
|
|
11
12
|
Dev orchestrator. Replaces `bun --watch` (which only watches the import graph,
|
|
@@ -174,30 +175,51 @@ async function replaceServer(port: number): Promise<void> {
|
|
|
174
175
|
|
|
175
176
|
let building = false
|
|
176
177
|
let queued = false
|
|
178
|
+
// True once any change collapsed into the queued run needs the client rebuilt;
|
|
179
|
+
// a client-affecting change can never be downgraded to a server-only restart.
|
|
180
|
+
let queuedNeedsClient = false
|
|
177
181
|
|
|
178
182
|
/*
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
183
|
+
Apply a change: rebuild the client (unless `skipClientBuild` — a server/MCP-only
|
|
184
|
+
change leaves the client bundle byte-identical), then on success swap in a fresh
|
|
185
|
+
server child. The worker restart always runs: SSR renders through Bun's module
|
|
186
|
+
cache, so a new process is the only reliable way to reflect any source edit,
|
|
187
|
+
client-affecting or not.
|
|
188
|
+
|
|
189
|
+
Serialized: a change arriving mid-build sets `queued` so exactly one more run
|
|
190
|
+
follows, collapsing further changes in between; `queuedNeedsClient` records
|
|
191
|
+
whether any of them needs the client, so the collapsed run never skips a client
|
|
192
|
+
rebuild a queued change required. A failed build leaves the current child
|
|
193
|
+
untouched — the error is logged and the last-good server keeps serving.
|
|
184
194
|
*/
|
|
185
|
-
async function rebuild(port: number): Promise<void> {
|
|
195
|
+
async function rebuild(port: number, skipClientBuild = false): Promise<void> {
|
|
186
196
|
if (building) {
|
|
187
197
|
queued = true
|
|
198
|
+
queuedNeedsClient ||= !skipClientBuild
|
|
188
199
|
return
|
|
189
200
|
}
|
|
190
201
|
building = true
|
|
191
202
|
try {
|
|
192
|
-
const succeeded = await build(buildOptions)
|
|
203
|
+
const succeeded = skipClientBuild ? true : await build(buildOptions)
|
|
193
204
|
if (succeeded) {
|
|
194
205
|
await replaceServer(port)
|
|
195
206
|
}
|
|
207
|
+
/* A server/MCP-only change reuses the existing client bundle. That's correct for
|
|
208
|
+
an rpc/socket handler BODY edit, but changing an rpc's method, its export name,
|
|
209
|
+
or `outbox: true` changes the client proxy stub too — the stale bundle then
|
|
210
|
+
calls with the old shape (e.g. a 405). Flag the skip so that case isn't silent. */
|
|
211
|
+
if (skipClientBuild) {
|
|
212
|
+
abideLog.info(
|
|
213
|
+
'server-only change — kept the existing client bundle; if you changed an rpc method, export name, or outbox, save a client file (or restart dev) for a full rebuild',
|
|
214
|
+
)
|
|
215
|
+
}
|
|
196
216
|
} finally {
|
|
197
217
|
building = false
|
|
198
218
|
if (queued) {
|
|
199
219
|
queued = false
|
|
200
|
-
|
|
220
|
+
const needsClient = queuedNeedsClient
|
|
221
|
+
queuedNeedsClient = false
|
|
222
|
+
void rebuild(port, !needsClient)
|
|
201
223
|
}
|
|
202
224
|
}
|
|
203
225
|
}
|
|
@@ -223,14 +245,26 @@ an agent editing the app's own source — isn't yanked mid-run by a save.
|
|
|
223
245
|
const manualRebuild = Bun.env.ABIDE_DEV_NO_WATCH === '1'
|
|
224
246
|
|
|
225
247
|
let debounce: ReturnType<typeof setTimeout> | undefined
|
|
248
|
+
/*
|
|
249
|
+
True only while every change collapsed into the pending debounce is server/MCP-
|
|
250
|
+
only; one client-affecting change in the burst flips it false (latches until the
|
|
251
|
+
debounce fires), so a multi-file save that touches the client never skips the
|
|
252
|
+
client rebuild.
|
|
253
|
+
*/
|
|
254
|
+
let pendingSkipClientBuild = true
|
|
226
255
|
const watcher = manualRebuild
|
|
227
256
|
? undefined
|
|
228
257
|
: watch(SOURCE_DIR, { recursive: true }, (_event, filename) => {
|
|
229
258
|
if (!filename || isGenerated(filename)) {
|
|
230
259
|
return
|
|
231
260
|
}
|
|
261
|
+
pendingSkipClientBuild &&= !changeAffectsClient(filename)
|
|
232
262
|
clearTimeout(debounce)
|
|
233
|
-
debounce = setTimeout(() =>
|
|
263
|
+
debounce = setTimeout(() => {
|
|
264
|
+
const skipClientBuild = pendingSkipClientBuild
|
|
265
|
+
pendingSkipClientBuild = true
|
|
266
|
+
void rebuild(port, skipClientBuild)
|
|
267
|
+
}, REBUILD_DEBOUNCE_MS)
|
|
234
268
|
})
|
|
235
269
|
if (manualRebuild) {
|
|
236
270
|
abideLog.info(
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { tokenizeArgvFlags } from './tokenizeArgvFlags.ts'
|
|
2
|
+
|
|
1
3
|
/*
|
|
2
4
|
Parses an argv tail into the JSON args bag for an RPC. The JSON Schema
|
|
3
5
|
on the manifest entry (when present) drives flag typing:
|
|
@@ -47,44 +49,39 @@ export async function parseArgvForRpc(
|
|
|
47
49
|
}
|
|
48
50
|
}
|
|
49
51
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
/* The shared tokenizer owns the flag-consumption grammar (boolean / inline /
|
|
53
|
+
`--json` / `--no-` negation). This loop layers RPC value semantics on each
|
|
54
|
+
yielded token: JSON-blob merge, Number coercion, array accumulation. */
|
|
55
|
+
for (const token of tokenizeArgvFlags(argv, jsonSchema)) {
|
|
56
|
+
if (token.positional !== undefined) {
|
|
57
|
+
throw new Error(`unexpected positional argument: ${token.positional}`)
|
|
58
|
+
}
|
|
59
|
+
if (token.isJson) {
|
|
60
|
+
if (token.missingValue || token.value === undefined) {
|
|
55
61
|
throw new Error('--json requires a value')
|
|
56
62
|
}
|
|
57
|
-
const parsed = JSON.parse(
|
|
63
|
+
const parsed = JSON.parse(token.value)
|
|
58
64
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
59
65
|
throw new Error('--json value must be a JSON object')
|
|
60
66
|
}
|
|
61
67
|
Object.assign(args, parsed)
|
|
62
68
|
continue
|
|
63
69
|
}
|
|
64
|
-
|
|
65
|
-
|
|
70
|
+
// The tokenizer treats `--help` / `-h` as a help token; in RPC parsing
|
|
71
|
+
// they are an unrecognised flag, surfaced loudly like any other.
|
|
72
|
+
if (token.isHelp || token.name === undefined) {
|
|
73
|
+
throw new Error('unexpected --help flag')
|
|
66
74
|
}
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
? [stripped.slice(0, stripped.indexOf('=')), stripped.slice(stripped.indexOf('=') + 1)]
|
|
70
|
-
: [stripped, undefined]
|
|
71
|
-
/* `--no-x` negates only a known boolean property x; otherwise the literal
|
|
72
|
-
name wins, so a property legitimately named `no-…` stays reachable. */
|
|
73
|
-
const negatedName = literalName.startsWith('no-')
|
|
74
|
-
? literalName.slice('no-'.length)
|
|
75
|
-
: undefined
|
|
76
|
-
const isNegated = negatedName !== undefined && properties[negatedName]?.type === 'boolean'
|
|
77
|
-
const name = isNegated ? (negatedName as string) : literalName
|
|
78
|
-
const prop = properties[name]
|
|
79
|
-
const propType = prop?.type
|
|
75
|
+
const name = token.name
|
|
76
|
+
const propType = properties[name]?.type
|
|
80
77
|
if (propType === 'boolean') {
|
|
81
|
-
args[name] = !
|
|
78
|
+
args[name] = !token.negated
|
|
82
79
|
continue
|
|
83
80
|
}
|
|
84
|
-
|
|
85
|
-
if (value === undefined) {
|
|
81
|
+
if (token.missingValue || token.value === undefined) {
|
|
86
82
|
throw new Error(`--${name} requires a value`)
|
|
87
83
|
}
|
|
84
|
+
const value = token.value
|
|
88
85
|
if (propType === 'number' || propType === 'integer') {
|
|
89
86
|
// Reject a blank value explicitly — `Number('')` / `Number(' ')` is 0,
|
|
90
87
|
// not NaN, so the NaN guard alone would silently coerce it to zero.
|
package/src/lib/cli/runCli.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { printTrimmed } from './printTrimmed.ts'
|
|
|
9
9
|
import { resolveCliTarget } from './resolveCliTarget.ts'
|
|
10
10
|
import { runSession } from './runSession.ts'
|
|
11
11
|
import { startLocalInstance } from './startLocalInstance.ts'
|
|
12
|
+
import { tokenizeArgvFlags } from './tokenizeArgvFlags.ts'
|
|
12
13
|
import type { CliManifest } from './types/CliManifest.ts'
|
|
13
14
|
import type { CliTarget } from './types/CliTarget.ts'
|
|
14
15
|
|
|
@@ -16,37 +17,21 @@ const isHelpFlag = (arg: string): boolean => arg === '--help' || arg === '-h'
|
|
|
16
17
|
|
|
17
18
|
/*
|
|
18
19
|
Whether a command's argv tail genuinely requests help, i.e. a help flag appears
|
|
19
|
-
at a flag position rather than as the value of a value-expecting flag.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
at a flag position rather than as the value of a value-expecting flag. Shares
|
|
21
|
+
tokenizeArgvFlags' grammar with parseArgvForRpc so the two can't drift: the
|
|
22
|
+
tokenizer applies the flag-consumption rule (boolean / `--name=…` / `--json`
|
|
23
|
+
consume their value), so a `--help` that was a flag's value is already swallowed
|
|
24
|
+
and never surfaces as a help token — `--title --help` passes `--help` as the
|
|
25
|
+
title value, not a help request.
|
|
23
26
|
*/
|
|
24
27
|
function commandArgvRequestsHelp(
|
|
25
28
|
argvTail: string[],
|
|
26
29
|
jsonSchema: Record<string, unknown> | undefined,
|
|
27
30
|
): boolean {
|
|
28
|
-
const
|
|
29
|
-
(
|
|
30
|
-
for (let index = 0; index < argvTail.length; index += 1) {
|
|
31
|
-
const token = argvTail[index] as string
|
|
32
|
-
if (isHelpFlag(token)) {
|
|
31
|
+
for (const token of tokenizeArgvFlags(argvTail, jsonSchema)) {
|
|
32
|
+
if (token.isHelp) {
|
|
33
33
|
return true
|
|
34
34
|
}
|
|
35
|
-
if (!token.startsWith('--')) {
|
|
36
|
-
continue
|
|
37
|
-
}
|
|
38
|
-
if (token === '--json') {
|
|
39
|
-
index += 1 // consumes its value
|
|
40
|
-
continue
|
|
41
|
-
}
|
|
42
|
-
const rawName = token.slice('--'.length)
|
|
43
|
-
if (rawName.includes('=')) {
|
|
44
|
-
continue // inline value, consumes no following token
|
|
45
|
-
}
|
|
46
|
-
const name = rawName.startsWith('no-') ? rawName.slice('no-'.length) : rawName
|
|
47
|
-
if (properties[name]?.type !== 'boolean') {
|
|
48
|
-
index += 1 // a value-expecting flag consumes the next token
|
|
49
|
-
}
|
|
50
35
|
}
|
|
51
36
|
return false
|
|
52
37
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The single argv-tail flag grammar shared by help-detection (runCli) and RPC arg
|
|
3
|
+
parsing (parseArgvForRpc) so the two can never drift. Walks the tail and yields
|
|
4
|
+
one token per flag occurrence, honouring the schema-driven consumption rule:
|
|
5
|
+
|
|
6
|
+
- `--help` / `-h` → { isHelp: true }, consumes no following token
|
|
7
|
+
- `--json <blob>` → { isJson: true, value }, consumes the next token
|
|
8
|
+
(the value is the verbatim JSON args bag)
|
|
9
|
+
- boolean props (and `--no-x`) → { name, value: undefined }, consumes no token
|
|
10
|
+
- `--name=value` → { name, value }, inline, consumes no token
|
|
11
|
+
- everything else → { name, value }, consumes the next token
|
|
12
|
+
|
|
13
|
+
`--no-x` negates only a known boolean property x; otherwise the literal `no-…`
|
|
14
|
+
name is kept so a property legitimately named `no-…` stays reachable. Reports
|
|
15
|
+
`negated` and `missingValue` so the parser can apply boolean/coercion semantics
|
|
16
|
+
and throw precise errors. A bare positional (no leading `--`) yields
|
|
17
|
+
{ positional: token } so each consumer rejects or ignores it as it sees fit.
|
|
18
|
+
|
|
19
|
+
Pure; reads the schema's `properties` only to classify boolean props.
|
|
20
|
+
*/
|
|
21
|
+
export function* tokenizeArgvFlags(
|
|
22
|
+
argvTail: string[],
|
|
23
|
+
jsonSchema: Record<string, unknown> | undefined,
|
|
24
|
+
): Generator<{
|
|
25
|
+
isHelp?: boolean
|
|
26
|
+
isJson?: boolean
|
|
27
|
+
positional?: string
|
|
28
|
+
name?: string
|
|
29
|
+
value?: string
|
|
30
|
+
negated?: boolean
|
|
31
|
+
missingValue?: boolean
|
|
32
|
+
}> {
|
|
33
|
+
const properties =
|
|
34
|
+
(jsonSchema?.properties as Record<string, { type?: string }> | undefined) ?? {}
|
|
35
|
+
for (let index = 0; index < argvTail.length; index += 1) {
|
|
36
|
+
const token = argvTail[index] as string
|
|
37
|
+
if (token === '--help' || token === '-h') {
|
|
38
|
+
yield { isHelp: true }
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
if (!token.startsWith('--')) {
|
|
42
|
+
yield { positional: token }
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
// `--json <blob>`: the next token is the verbatim JSON args bag.
|
|
46
|
+
if (token === '--json') {
|
|
47
|
+
const value = argvTail[index + 1]
|
|
48
|
+
if (value === undefined) {
|
|
49
|
+
yield { isJson: true, missingValue: true }
|
|
50
|
+
} else {
|
|
51
|
+
index += 1
|
|
52
|
+
yield { isJson: true, value }
|
|
53
|
+
}
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
const stripped = token.slice('--'.length)
|
|
57
|
+
const [literalName, inlineValue] = stripped.includes('=')
|
|
58
|
+
? [stripped.slice(0, stripped.indexOf('=')), stripped.slice(stripped.indexOf('=') + 1)]
|
|
59
|
+
: [stripped, undefined]
|
|
60
|
+
/* `--no-x` negates only a known boolean property x; otherwise the literal
|
|
61
|
+
name wins, so a property legitimately named `no-…` stays reachable. */
|
|
62
|
+
const negatedName = literalName.startsWith('no-')
|
|
63
|
+
? literalName.slice('no-'.length)
|
|
64
|
+
: undefined
|
|
65
|
+
const negated = negatedName !== undefined && properties[negatedName]?.type === 'boolean'
|
|
66
|
+
const name = negated ? (negatedName as string) : literalName
|
|
67
|
+
// Boolean props and inline `--name=value` consume no following token.
|
|
68
|
+
if (properties[name]?.type === 'boolean') {
|
|
69
|
+
yield { name, negated }
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
if (inlineValue !== undefined) {
|
|
73
|
+
yield { name, value: inlineValue }
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
const value = argvTail[index + 1]
|
|
77
|
+
if (value === undefined) {
|
|
78
|
+
yield { name, missingValue: true }
|
|
79
|
+
} else {
|
|
80
|
+
index += 1
|
|
81
|
+
yield { name, value }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/lib/server/error.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { NO_STORE } from '../shared/CACHE_CONTROL_VALUES.ts'
|
|
2
2
|
import { TEXT_PLAIN } from '../shared/TEXT_PLAIN.ts'
|
|
3
|
-
import type {
|
|
3
|
+
import type { StandardSchemaV1 } from '../shared/types/StandardSchemaV1.ts'
|
|
4
|
+
import type { TypedError } from './rpc/types/TypedError.ts'
|
|
4
5
|
import type { TypedResponse } from './rpc/types/TypedResponse.ts'
|
|
6
|
+
import { STATUS_TEXT } from './runtime/STATUS_TEXT.ts'
|
|
7
|
+
import { typedErrorResponse } from './runtime/typedErrorResponse.ts'
|
|
5
8
|
import { withResponseDefaults } from './runtime/withResponseDefaults.ts'
|
|
6
9
|
|
|
7
10
|
/*
|
|
@@ -18,37 +21,15 @@ text/plain so intermediaries don't try to render or sniff it. A final
|
|
|
18
21
|
`ResponseInit` adds headers (e.g. `Retry-After` on a 429); the positional
|
|
19
22
|
`status` always wins over any `init.status`.
|
|
20
23
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
For a NAMED, typed error the client can branch on, declare a constructor with
|
|
25
|
+
`error.typed(name, status, schema?)` and return it (see below). To short-circuit
|
|
26
|
+
a handler instead of returning, `throw new Error(...)` or
|
|
27
|
+
`throw new HttpError(error(...))` — the framework's `app.handleError` hook
|
|
28
|
+
catches thrown errors. This helper deliberately returns a Response rather than
|
|
29
|
+
throwing one so a single `return error(...)` is the expected pattern, with the
|
|
30
|
+
same control flow as `return json(...)`.
|
|
26
31
|
*/
|
|
27
32
|
|
|
28
|
-
/*
|
|
29
|
-
Standard reason phrases for the statuses error() is realistically called
|
|
30
|
-
with. Maintained explicitly because Bun's `Response` does not populate
|
|
31
|
-
`statusText` from the status code, so there's no platform table to read.
|
|
32
|
-
Unlisted codes fall back to `HTTP <status>`.
|
|
33
|
-
*/
|
|
34
|
-
const STATUS_TEXT: Record<number, string> = {
|
|
35
|
-
400: 'Bad Request',
|
|
36
|
-
401: 'Unauthorized',
|
|
37
|
-
403: 'Forbidden',
|
|
38
|
-
404: 'Not Found',
|
|
39
|
-
405: 'Method Not Allowed',
|
|
40
|
-
409: 'Conflict',
|
|
41
|
-
410: 'Gone',
|
|
42
|
-
413: 'Content Too Large',
|
|
43
|
-
422: 'Unprocessable Content',
|
|
44
|
-
429: 'Too Many Requests',
|
|
45
|
-
500: 'Internal Server Error',
|
|
46
|
-
501: 'Not Implemented',
|
|
47
|
-
502: 'Bad Gateway',
|
|
48
|
-
503: 'Service Unavailable',
|
|
49
|
-
504: 'Gateway Timeout',
|
|
50
|
-
}
|
|
51
|
-
|
|
52
33
|
/*
|
|
53
34
|
Body type is `never` because `error()` only travels the non-2xx path on
|
|
54
35
|
the wire — the caller's `await fn(args)` throws `HttpError` and never
|
|
@@ -57,32 +38,11 @@ the union of branches in a handler narrow to whatever the success
|
|
|
57
38
|
branch carries (`TypedResponse<{user}> | TypedResponse<never>` → Return
|
|
58
39
|
= {user}).
|
|
59
40
|
*/
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
export function error(status: number, message?: string, init?: ResponseInit): TypedResponse<never>
|
|
63
|
-
export function error(
|
|
64
|
-
statusOrDescriptor: number | ErrorDescriptor,
|
|
41
|
+
function errorResponse(
|
|
42
|
+
status: number,
|
|
65
43
|
message?: string,
|
|
66
44
|
init?: ResponseInit,
|
|
67
45
|
): TypedResponse<never> {
|
|
68
|
-
/* A typed-error descriptor (`error(errors.invalidCoupon({…}))`) serializes as a
|
|
69
|
-
JSON `{ $abideError, data }` body at the descriptor's status — the client
|
|
70
|
-
parses it back onto the thrown HttpError's `.kind` / `.data`. */
|
|
71
|
-
if (typeof statusOrDescriptor === 'object') {
|
|
72
|
-
const descriptor = statusOrDescriptor
|
|
73
|
-
return new Response(
|
|
74
|
-
JSON.stringify({ $abideError: descriptor.$abideError, data: descriptor.data }),
|
|
75
|
-
withStatusText(
|
|
76
|
-
withResponseDefaults(
|
|
77
|
-
undefined,
|
|
78
|
-
{ 'Content-Type': 'application/json', 'Cache-Control': NO_STORE },
|
|
79
|
-
descriptor.status,
|
|
80
|
-
),
|
|
81
|
-
descriptor.status,
|
|
82
|
-
),
|
|
83
|
-
) as TypedResponse<never>
|
|
84
|
-
}
|
|
85
|
-
const status = statusOrDescriptor
|
|
86
46
|
const body = message ?? STATUS_TEXT[status] ?? `HTTP ${status}`
|
|
87
47
|
return new Response(
|
|
88
48
|
body,
|
|
@@ -113,3 +73,38 @@ function withStatusText(init: ResponseInit, status: number): ResponseInit {
|
|
|
113
73
|
}
|
|
114
74
|
return init
|
|
115
75
|
}
|
|
76
|
+
|
|
77
|
+
/*
|
|
78
|
+
Declares a reusable, typed error as a single constructor. With a `data` schema
|
|
79
|
+
the constructor requires that input; without one it's nullary. Returning the
|
|
80
|
+
constructor from a handler IS the error — it serializes a `{ $abideError, data }`
|
|
81
|
+
body at `status`, and the rpc reads the constructor's branded return type to
|
|
82
|
+
expose the error on `rpc.isError(e, 'name')` (`.kind` and typed `.data`). No
|
|
83
|
+
`errors:` option, no set to register — compose by returning whichever you want:
|
|
84
|
+
|
|
85
|
+
const duplicateSlug = error.typed('duplicateSlug', 409, z.object({ slug: z.string() }))
|
|
86
|
+
const rateLimited = error.typed('rateLimited', 429) // nullary
|
|
87
|
+
|
|
88
|
+
export const createPost = POST(({ slug }) =>
|
|
89
|
+
taken(slug) ? duplicateSlug({ slug }) : json(save(slug)),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
`name` is the wire identity and the `isError` key, so it's an explicit string
|
|
93
|
+
(a const can't read its own variable name); `schema` types `.data` and is never
|
|
94
|
+
validated at runtime here.
|
|
95
|
+
*/
|
|
96
|
+
function typed<Name extends string, Schema extends StandardSchemaV1>(
|
|
97
|
+
name: Name,
|
|
98
|
+
status: number,
|
|
99
|
+
schema: Schema,
|
|
100
|
+
): (data: StandardSchemaV1.InferInput<Schema>) => TypedError<Name, { status: number; data: Schema }>
|
|
101
|
+
function typed<Name extends string>(
|
|
102
|
+
name: Name,
|
|
103
|
+
status: number,
|
|
104
|
+
): () => TypedError<Name, { status: number; data?: undefined }>
|
|
105
|
+
function typed(name: string, status: number, _schema?: StandardSchemaV1) {
|
|
106
|
+
return (data?: unknown) => typedErrorResponse(name, status, data)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// @documentation response
|
|
110
|
+
export const error = Object.assign(errorResponse, { typed })
|
package/src/lib/server/json.ts
CHANGED
|
@@ -9,13 +9,14 @@ unless the caller overrides it. Intermediary caches (browsers, CDNs,
|
|
|
9
9
|
shared proxies) shouldn't cache rpc replies by default; the framework's
|
|
10
10
|
own per-request cache handles in-process dedupe.
|
|
11
11
|
|
|
12
|
-
export const getOrder = GET
|
|
12
|
+
export const getOrder = GET(async ({ id }: { id: string }) =>
|
|
13
13
|
json(await db.getOrder(id)),
|
|
14
14
|
)
|
|
15
15
|
|
|
16
16
|
The return type carries `T` as a phantom brand so the rpc helper can
|
|
17
|
-
infer the caller-facing `Return` from the handler body —
|
|
18
|
-
|
|
17
|
+
infer the caller-facing `Return` from the handler body — you type the
|
|
18
|
+
handler's parameter and let the body infer; there are no `<Args, Return>`
|
|
19
|
+
call generics (a stray one is a compile error).
|
|
19
20
|
|
|
20
21
|
For non-default cache policy pass `init.headers`; explicit
|
|
21
22
|
`cache-control` wins over the default.
|
package/src/lib/server/jsonl.ts
CHANGED
|
@@ -4,7 +4,7 @@ Wraps an AsyncIterable<Frame> in a Response whose body is JSON Lines
|
|
|
4
4
|
inside an rpc handler to turn a generator into a streaming HTTP response
|
|
5
5
|
that `tail(fn.stream(args))` consumes frame-by-frame on the client.
|
|
6
6
|
|
|
7
|
-
export const orderFeed = GET
|
|
7
|
+
export const orderFeed = GET((args: Args) =>
|
|
8
8
|
jsonl(async function* () {
|
|
9
9
|
for await (const order of db.watchOrders(args)) yield order
|
|
10
10
|
}())
|
|
@@ -5,12 +5,10 @@ import { forwardHeaders } from '../../shared/forwardHeaders.ts'
|
|
|
5
5
|
import { isReadOnlyMethod } from '../../shared/isReadOnlyMethod.ts'
|
|
6
6
|
import { resolveClientFlags } from '../../shared/resolveClientFlags.ts'
|
|
7
7
|
import type { ClientFlags } from '../../shared/types/ClientFlags.ts'
|
|
8
|
-
import type { ErrorSpec } from '../../shared/types/ErrorSpec.ts'
|
|
9
8
|
import type { HttpMethod } from '../../shared/types/HttpMethod.ts'
|
|
10
9
|
import type { RemoteFunction } from '../../shared/types/RemoteFunction.ts'
|
|
11
10
|
import type { StandardSchemaV1 } from '../../shared/types/StandardSchemaV1.ts'
|
|
12
11
|
import { requestContext } from '../runtime/requestContext.ts'
|
|
13
|
-
import { buildErrorConstructors } from './buildErrorConstructors.ts'
|
|
14
12
|
import { parseArgs } from './parseArgs.ts'
|
|
15
13
|
import { registerRpc } from './registerRpc.ts'
|
|
16
14
|
import { runWithRpcTimeout } from './runWithRpcTimeout.ts'
|
|
@@ -55,7 +53,6 @@ export function defineRpc<Args, Return>(
|
|
|
55
53
|
inputSchema?: StandardSchemaV1
|
|
56
54
|
outputSchema?: StandardSchemaV1
|
|
57
55
|
filesSchema?: StandardSchemaV1
|
|
58
|
-
errors?: ErrorSpec
|
|
59
56
|
clients?: Partial<ClientFlags>
|
|
60
57
|
crossOrigin?: boolean
|
|
61
58
|
/* Per-rpc cap on actual received body bytes (413 past it); omitted = Bun's server-wide maxRequestBodySize. */
|
|
@@ -81,8 +78,6 @@ export function defineRpc<Args, Return>(
|
|
|
81
78
|
const inputSchema = opts?.inputSchema
|
|
82
79
|
const outputSchema = opts?.outputSchema
|
|
83
80
|
const filesSchema = opts?.filesSchema
|
|
84
|
-
/* The declared error constructors handed to the handler as its `{ errors }` ctx. */
|
|
85
|
-
const errors = buildErrorConstructors(opts?.errors ?? {})
|
|
86
81
|
/*
|
|
87
82
|
An input schema makes the handler safe to advertise to non-browser
|
|
88
83
|
surfaces. CLI flips on for any rpc with one (a human/script invokes it
|
|
@@ -100,7 +95,15 @@ export function defineRpc<Args, Return>(
|
|
|
100
95
|
function buildRequest(args: Args | undefined): Request {
|
|
101
96
|
const store = requestContext.getStore()
|
|
102
97
|
const baseUrl = store ? store.url.href : 'http://localhost/'
|
|
103
|
-
|
|
98
|
+
/* The forwarded allowlist is request-invariant — derive it once per scope
|
|
99
|
+
and clone per call (buildRpcRequest mutates the Headers). */
|
|
100
|
+
let headers: Headers
|
|
101
|
+
if (store) {
|
|
102
|
+
store.forwardedHeaders ??= forwardHeaders(store.req.headers)
|
|
103
|
+
headers = new Headers(store.forwardedHeaders)
|
|
104
|
+
} else {
|
|
105
|
+
headers = new Headers()
|
|
106
|
+
}
|
|
104
107
|
return buildRpcRequest({ method, url, args, baseUrl, headers })
|
|
105
108
|
}
|
|
106
109
|
|
|
@@ -115,7 +118,7 @@ export function defineRpc<Args, Return>(
|
|
|
115
118
|
async function runHandler(args: Args | undefined): Promise<Response> {
|
|
116
119
|
return rpcLog.trace(
|
|
117
120
|
`rpc ${method} ${url}`,
|
|
118
|
-
() => handler(args as Args
|
|
121
|
+
() => handler(args as Args) as unknown as Response,
|
|
119
122
|
)
|
|
120
123
|
}
|
|
121
124
|
|
|
@@ -158,8 +161,7 @@ export function defineRpc<Args, Return>(
|
|
|
158
161
|
/* Abort the controller parseArgsForFetch stashed on store.req; a no-op when none was stashed (SSR cache reads). */
|
|
159
162
|
function abortRpcTimeout(): void {
|
|
160
163
|
const req = requestContext.getStore()?.req as
|
|
161
|
-
|
|
162
|
-
| undefined
|
|
164
|
+
(Request & { [RPC_TIMEOUT_ABORT]?: AbortController }) | undefined
|
|
163
165
|
req?.[RPC_TIMEOUT_ABORT]?.abort(new DOMException('handler timeout', 'TimeoutError'))
|
|
164
166
|
}
|
|
165
167
|
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import type { ErrorConstructors } from '../../../shared/types/ErrorConstructors.ts'
|
|
2
|
-
import type { ErrorSpec } from '../../../shared/types/ErrorSpec.ts'
|
|
3
1
|
import type { TypedResponse } from './TypedResponse.ts'
|
|
4
2
|
|
|
5
3
|
/*
|
|
@@ -23,11 +21,11 @@ untagged Responses fall back to `Return = unknown`.
|
|
|
23
21
|
Handlers that need the inbound Request (headers, `request.signal`, …) read
|
|
24
22
|
it via `request()` from `abide/server` rather than a handler parameter, so
|
|
25
23
|
the signature stays a single parsed-`args` bag.
|
|
24
|
+
|
|
25
|
+
A typed error is raised by returning an `error.typed(name, status, schema?)`
|
|
26
|
+
constructor (`return outOfStock({…})`) — there is no `ctx` param; the rpc reads
|
|
27
|
+
the error surface off the returned constructors' brands.
|
|
26
28
|
*/
|
|
27
|
-
export type RemoteHandler<Args, Return
|
|
29
|
+
export type RemoteHandler<Args, Return> = (
|
|
28
30
|
args: Args,
|
|
29
|
-
/* The rpc's declared error constructors (`error(errors.invalidCoupon({…}))`), typed
|
|
30
|
-
from its `errors` opt; an empty object when none declared. A handler that takes only
|
|
31
|
-
`args` is still assignable here (fewer params is assignable to more). */
|
|
32
|
-
ctx: { errors: ErrorConstructors<Errors> },
|
|
33
31
|
) => TypedResponse<Return> | Promise<TypedResponse<Return>>
|