@yolk-sdk/emulators 0.1.0-canary.96
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/LICENSE +21 -0
- package/README.md +2135 -0
- package/dist/anthropic.d.mts +269 -0
- package/dist/anthropic.d.mts.map +1 -0
- package/dist/anthropic.mjs +177 -0
- package/dist/anthropic.mjs.map +1 -0
- package/dist/chat-completions.d.mts +288 -0
- package/dist/chat-completions.d.mts.map +1 -0
- package/dist/chat-completions.mjs +451 -0
- package/dist/chat-completions.mjs.map +1 -0
- package/dist/codex.d.mts +265 -0
- package/dist/codex.d.mts.map +1 -0
- package/dist/codex.mjs +199 -0
- package/dist/codex.mjs.map +1 -0
- package/dist/dropbox/api.d.mts +63 -0
- package/dist/dropbox/api.d.mts.map +1 -0
- package/dist/dropbox/api.mjs +565 -0
- package/dist/dropbox/api.mjs.map +1 -0
- package/dist/dropbox/state.d.mts +136 -0
- package/dist/dropbox/state.d.mts.map +1 -0
- package/dist/dropbox/state.mjs +209 -0
- package/dist/dropbox/state.mjs.map +1 -0
- package/dist/dropbox.d.mts +79 -0
- package/dist/dropbox.d.mts.map +1 -0
- package/dist/dropbox.mjs +124 -0
- package/dist/dropbox.mjs.map +1 -0
- package/dist/email-fixtures.d.mts +36 -0
- package/dist/email-fixtures.d.mts.map +1 -0
- package/dist/email-fixtures.mjs +1080 -0
- package/dist/email-fixtures.mjs.map +1 -0
- package/dist/email.d.mts +160 -0
- package/dist/email.d.mts.map +1 -0
- package/dist/email.mjs +608 -0
- package/dist/email.mjs.map +1 -0
- package/dist/emulator-compose.d.mts +40 -0
- package/dist/emulator-compose.d.mts.map +1 -0
- package/dist/emulator-compose.mjs +91 -0
- package/dist/emulator-compose.mjs.map +1 -0
- package/dist/emulator-http.d.mts +53 -0
- package/dist/emulator-http.d.mts.map +1 -0
- package/dist/emulator-http.mjs +117 -0
- package/dist/emulator-http.mjs.map +1 -0
- package/dist/emulator-kernel.d.mts +176 -0
- package/dist/emulator-kernel.d.mts.map +1 -0
- package/dist/emulator-kernel.mjs +413 -0
- package/dist/emulator-kernel.mjs.map +1 -0
- package/dist/fixture-route.d.mts +129 -0
- package/dist/fixture-route.d.mts.map +1 -0
- package/dist/fixture-route.mjs +257 -0
- package/dist/fixture-route.mjs.map +1 -0
- package/dist/fortnox/api.d.mts +92 -0
- package/dist/fortnox/api.d.mts.map +1 -0
- package/dist/fortnox/api.mjs +750 -0
- package/dist/fortnox/api.mjs.map +1 -0
- package/dist/fortnox/state.d.mts +533 -0
- package/dist/fortnox/state.d.mts.map +1 -0
- package/dist/fortnox/state.mjs +612 -0
- package/dist/fortnox/state.mjs.map +1 -0
- package/dist/fortnox.d.mts +128 -0
- package/dist/fortnox.d.mts.map +1 -0
- package/dist/fortnox.mjs +403 -0
- package/dist/fortnox.mjs.map +1 -0
- package/dist/gateway-evaluate-recordings.d.mts +12 -0
- package/dist/gateway-evaluate-recordings.d.mts.map +1 -0
- package/dist/gateway-evaluate-recordings.mjs +128 -0
- package/dist/gateway-evaluate-recordings.mjs.map +1 -0
- package/dist/gateway.d.mts +272 -0
- package/dist/gateway.d.mts.map +1 -0
- package/dist/gateway.mjs +400 -0
- package/dist/gateway.mjs.map +1 -0
- package/dist/github/api.d.mts +63 -0
- package/dist/github/api.d.mts.map +1 -0
- package/dist/github/api.mjs +527 -0
- package/dist/github/api.mjs.map +1 -0
- package/dist/github/state.d.mts +272 -0
- package/dist/github/state.d.mts.map +1 -0
- package/dist/github/state.mjs +303 -0
- package/dist/github/state.mjs.map +1 -0
- package/dist/github.d.mts +77 -0
- package/dist/github.d.mts.map +1 -0
- package/dist/github.mjs +180 -0
- package/dist/github.mjs.map +1 -0
- package/dist/google/calendar.d.mts +10 -0
- package/dist/google/calendar.d.mts.map +1 -0
- package/dist/google/calendar.mjs +252 -0
- package/dist/google/calendar.mjs.map +1 -0
- package/dist/google/drive.d.mts +8 -0
- package/dist/google/drive.d.mts.map +1 -0
- package/dist/google/drive.mjs +252 -0
- package/dist/google/drive.mjs.map +1 -0
- package/dist/google/gmail.d.mts +10 -0
- package/dist/google/gmail.d.mts.map +1 -0
- package/dist/google/gmail.mjs +591 -0
- package/dist/google/gmail.mjs.map +1 -0
- package/dist/google/shared.d.mts +107 -0
- package/dist/google/shared.d.mts.map +1 -0
- package/dist/google/shared.mjs +146 -0
- package/dist/google/shared.mjs.map +1 -0
- package/dist/google/state.d.mts +385 -0
- package/dist/google/state.d.mts.map +1 -0
- package/dist/google/state.mjs +496 -0
- package/dist/google/state.mjs.map +1 -0
- package/dist/google.d.mts +75 -0
- package/dist/google.d.mts.map +1 -0
- package/dist/google.mjs +210 -0
- package/dist/google.mjs.map +1 -0
- package/dist/linkedin-search/api.d.mts +45 -0
- package/dist/linkedin-search/api.d.mts.map +1 -0
- package/dist/linkedin-search/api.mjs +181 -0
- package/dist/linkedin-search/api.mjs.map +1 -0
- package/dist/linkedin-search/state.d.mts +123 -0
- package/dist/linkedin-search/state.d.mts.map +1 -0
- package/dist/linkedin-search/state.mjs +261 -0
- package/dist/linkedin-search/state.mjs.map +1 -0
- package/dist/linkedin-search.d.mts +69 -0
- package/dist/linkedin-search.d.mts.map +1 -0
- package/dist/linkedin-search.mjs +158 -0
- package/dist/linkedin-search.mjs.map +1 -0
- package/dist/mcp/api.d.mts +44 -0
- package/dist/mcp/api.d.mts.map +1 -0
- package/dist/mcp/api.mjs +557 -0
- package/dist/mcp/api.mjs.map +1 -0
- package/dist/mcp/recordings.d.mts +40 -0
- package/dist/mcp/recordings.d.mts.map +1 -0
- package/dist/mcp/recordings.mjs +2390 -0
- package/dist/mcp/recordings.mjs.map +1 -0
- package/dist/mcp/state.d.mts +71 -0
- package/dist/mcp/state.d.mts.map +1 -0
- package/dist/mcp/state.mjs +83 -0
- package/dist/mcp/state.mjs.map +1 -0
- package/dist/mcp.d.mts +84 -0
- package/dist/mcp.d.mts.map +1 -0
- package/dist/mcp.mjs +241 -0
- package/dist/mcp.mjs.map +1 -0
- package/dist/messages.d.mts +183 -0
- package/dist/messages.d.mts.map +1 -0
- package/dist/messages.mjs +532 -0
- package/dist/messages.mjs.map +1 -0
- package/dist/microsoft/api.d.mts +47 -0
- package/dist/microsoft/api.d.mts.map +1 -0
- package/dist/microsoft/api.mjs +178 -0
- package/dist/microsoft/api.mjs.map +1 -0
- package/dist/microsoft/calendar.d.mts +30 -0
- package/dist/microsoft/calendar.d.mts.map +1 -0
- package/dist/microsoft/calendar.mjs +271 -0
- package/dist/microsoft/calendar.mjs.map +1 -0
- package/dist/microsoft/drive.d.mts +36 -0
- package/dist/microsoft/drive.d.mts.map +1 -0
- package/dist/microsoft/drive.mjs +298 -0
- package/dist/microsoft/drive.mjs.map +1 -0
- package/dist/microsoft/graph.d.mts +198 -0
- package/dist/microsoft/graph.d.mts.map +1 -0
- package/dist/microsoft/graph.mjs +264 -0
- package/dist/microsoft/graph.mjs.map +1 -0
- package/dist/microsoft/mail.d.mts +48 -0
- package/dist/microsoft/mail.d.mts.map +1 -0
- package/dist/microsoft/mail.mjs +488 -0
- package/dist/microsoft/mail.mjs.map +1 -0
- package/dist/microsoft/state.d.mts +480 -0
- package/dist/microsoft/state.d.mts.map +1 -0
- package/dist/microsoft/state.mjs +625 -0
- package/dist/microsoft/state.mjs.map +1 -0
- package/dist/microsoft.d.mts +168 -0
- package/dist/microsoft.d.mts.map +1 -0
- package/dist/microsoft.mjs +483 -0
- package/dist/microsoft.mjs.map +1 -0
- package/dist/node.d.mts +47 -0
- package/dist/node.d.mts.map +1 -0
- package/dist/node.mjs +178 -0
- package/dist/node.mjs.map +1 -0
- package/dist/notion/api.d.mts +51 -0
- package/dist/notion/api.d.mts.map +1 -0
- package/dist/notion/api.mjs +507 -0
- package/dist/notion/api.mjs.map +1 -0
- package/dist/notion/state.d.mts +329 -0
- package/dist/notion/state.d.mts.map +1 -0
- package/dist/notion/state.mjs +432 -0
- package/dist/notion/state.mjs.map +1 -0
- package/dist/notion.d.mts +72 -0
- package/dist/notion.d.mts.map +1 -0
- package/dist/notion.mjs +127 -0
- package/dist/notion.mjs.map +1 -0
- package/dist/openai.d.mts +165 -0
- package/dist/openai.d.mts.map +1 -0
- package/dist/openai.mjs +141 -0
- package/dist/openai.mjs.map +1 -0
- package/dist/opencode-recordings.d.mts +7 -0
- package/dist/opencode-recordings.d.mts.map +1 -0
- package/dist/opencode-recordings.mjs +222 -0
- package/dist/opencode-recordings.mjs.map +1 -0
- package/dist/opencode.d.mts +70 -0
- package/dist/opencode.d.mts.map +1 -0
- package/dist/opencode.mjs +217 -0
- package/dist/opencode.mjs.map +1 -0
- package/dist/r2-fixtures.d.mts +36 -0
- package/dist/r2-fixtures.d.mts.map +1 -0
- package/dist/r2-fixtures.mjs +245 -0
- package/dist/r2-fixtures.mjs.map +1 -0
- package/dist/r2-guard.d.mts +43 -0
- package/dist/r2-guard.d.mts.map +1 -0
- package/dist/r2-guard.mjs +215 -0
- package/dist/r2-guard.mjs.map +1 -0
- package/dist/r2.d.mts +166 -0
- package/dist/r2.d.mts.map +1 -0
- package/dist/r2.mjs +517 -0
- package/dist/r2.mjs.map +1 -0
- package/dist/responses.d.mts +231 -0
- package/dist/responses.d.mts.map +1 -0
- package/dist/responses.mjs +556 -0
- package/dist/responses.mjs.map +1 -0
- package/dist/route-evidence.d.mts +45 -0
- package/dist/route-evidence.d.mts.map +1 -0
- package/dist/route-evidence.mjs +56 -0
- package/dist/route-evidence.mjs.map +1 -0
- package/dist/router.d.mts +93 -0
- package/dist/router.d.mts.map +1 -0
- package/dist/router.mjs +259 -0
- package/dist/router.mjs.map +1 -0
- package/dist/stateful-core.d.mts +17 -0
- package/dist/stateful-core.d.mts.map +1 -0
- package/dist/stateful-core.mjs +41 -0
- package/dist/stateful-core.mjs.map +1 -0
- package/dist/stateful-emulator.d.mts +565 -0
- package/dist/stateful-emulator.d.mts.map +1 -0
- package/dist/stateful-emulator.mjs +1228 -0
- package/dist/stateful-emulator.mjs.map +1 -0
- package/dist/stateful-secrets.d.mts +84 -0
- package/dist/stateful-secrets.d.mts.map +1 -0
- package/dist/stateful-secrets.mjs +216 -0
- package/dist/stateful-secrets.mjs.map +1 -0
- package/dist/subscription-usage-recordings.d.mts +9 -0
- package/dist/subscription-usage-recordings.d.mts.map +1 -0
- package/dist/subscription-usage-recordings.mjs +53 -0
- package/dist/subscription-usage-recordings.mjs.map +1 -0
- package/dist/subscription-usage.d.mts +53 -0
- package/dist/subscription-usage.d.mts.map +1 -0
- package/dist/subscription-usage.mjs +21 -0
- package/dist/subscription-usage.mjs.map +1 -0
- package/dist/telegram/api.d.mts +34 -0
- package/dist/telegram/api.d.mts.map +1 -0
- package/dist/telegram/api.mjs +257 -0
- package/dist/telegram/api.mjs.map +1 -0
- package/dist/telegram/state.d.mts +128 -0
- package/dist/telegram/state.d.mts.map +1 -0
- package/dist/telegram/state.mjs +154 -0
- package/dist/telegram/state.mjs.map +1 -0
- package/dist/telegram.d.mts +62 -0
- package/dist/telegram.d.mts.map +1 -0
- package/dist/telegram.mjs +127 -0
- package/dist/telegram.mjs.map +1 -0
- package/dist/todoist/api.d.mts +55 -0
- package/dist/todoist/api.d.mts.map +1 -0
- package/dist/todoist/api.mjs +415 -0
- package/dist/todoist/api.mjs.map +1 -0
- package/dist/todoist/state.d.mts +275 -0
- package/dist/todoist/state.d.mts.map +1 -0
- package/dist/todoist/state.mjs +345 -0
- package/dist/todoist/state.mjs.map +1 -0
- package/dist/todoist.d.mts +68 -0
- package/dist/todoist.d.mts.map +1 -0
- package/dist/todoist.mjs +181 -0
- package/dist/todoist.mjs.map +1 -0
- package/dist/xai.d.mts +267 -0
- package/dist/xai.d.mts.map +1 -0
- package/dist/xai.mjs +252 -0
- package/dist/xai.mjs.map +1 -0
- package/package.json +157 -0
- package/src/anthropic.ts +289 -0
- package/src/chat-completions.ts +924 -0
- package/src/codex.ts +296 -0
- package/src/dropbox/api.ts +1084 -0
- package/src/dropbox/state.ts +364 -0
- package/src/dropbox.ts +203 -0
- package/src/email-fixtures.ts +1297 -0
- package/src/email.ts +1108 -0
- package/src/emulator-compose.ts +147 -0
- package/src/emulator-http.ts +184 -0
- package/src/emulator-kernel.ts +844 -0
- package/src/fixture-route.ts +561 -0
- package/src/fortnox/api.ts +1352 -0
- package/src/fortnox/state.ts +798 -0
- package/src/fortnox.ts +801 -0
- package/src/gateway-evaluate-recordings.ts +153 -0
- package/src/gateway.ts +530 -0
- package/src/github/api.ts +986 -0
- package/src/github/state.ts +439 -0
- package/src/github.ts +271 -0
- package/src/google/calendar.ts +486 -0
- package/src/google/drive.ts +471 -0
- package/src/google/gmail.ts +1011 -0
- package/src/google/shared.ts +321 -0
- package/src/google/state.ts +686 -0
- package/src/google.ts +298 -0
- package/src/linkedin-search/api.ts +363 -0
- package/src/linkedin-search/state.ts +373 -0
- package/src/linkedin-search.ts +241 -0
- package/src/mcp/api.ts +995 -0
- package/src/mcp/recordings.ts +2665 -0
- package/src/mcp/state.ts +125 -0
- package/src/mcp.ts +319 -0
- package/src/messages.ts +844 -0
- package/src/microsoft/api.ts +426 -0
- package/src/microsoft/calendar.ts +491 -0
- package/src/microsoft/drive.ts +541 -0
- package/src/microsoft/graph.ts +550 -0
- package/src/microsoft/mail.ts +833 -0
- package/src/microsoft/state.ts +822 -0
- package/src/microsoft.ts +982 -0
- package/src/node.ts +293 -0
- package/src/notion/api.ts +976 -0
- package/src/notion/state.ts +512 -0
- package/src/notion.ts +210 -0
- package/src/openai.ts +198 -0
- package/src/opencode-recordings.ts +257 -0
- package/src/opencode.ts +271 -0
- package/src/r2-fixtures.ts +295 -0
- package/src/r2-guard.ts +323 -0
- package/src/r2.ts +890 -0
- package/src/responses.ts +901 -0
- package/src/route-evidence.ts +90 -0
- package/src/router.ts +490 -0
- package/src/stateful-core.ts +70 -0
- package/src/stateful-emulator.ts +2571 -0
- package/src/stateful-secrets.ts +299 -0
- package/src/subscription-usage-recordings.ts +77 -0
- package/src/subscription-usage.ts +68 -0
- package/src/telegram/api.ts +437 -0
- package/src/telegram/state.ts +229 -0
- package/src/telegram.ts +227 -0
- package/src/todoist/api.ts +736 -0
- package/src/todoist/state.ts +456 -0
- package/src/todoist.ts +316 -0
- package/src/xai.ts +341 -0
|
@@ -0,0 +1,844 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared emulator kernel (internal; not a package export).
|
|
3
|
+
*
|
|
4
|
+
* The wire-independent half of every fetch-handler emulator: fault and scripted-turn state, the
|
|
5
|
+
* request ledger, pull-driven bodies with chunk faults, evidence tagging, route binding, and the
|
|
6
|
+
* `/_emulate/*` control plane. Each wire core (`chat-completions.ts`, `messages.ts`) supplies its
|
|
7
|
+
* route handlers, fault and turn schemas, ledger entry shape, and error bodies.
|
|
8
|
+
*
|
|
9
|
+
* Also holds small wire-independent helpers both cores use: JSON field access, synthetic values
|
|
10
|
+
* for a tool's JSON Schema, fragment splitting, and a rough token estimate.
|
|
11
|
+
*
|
|
12
|
+
* Runtime-portable Web APIs only (`Request`, `Response`, `ReadableStream`, `TextEncoder`, `URL`);
|
|
13
|
+
* no Node builtins and no SDK imports.
|
|
14
|
+
*/
|
|
15
|
+
import { Predicate, Result } from 'effect'
|
|
16
|
+
import * as Schema from 'effect/Schema'
|
|
17
|
+
import { EmulatorHeaderRecord, EmulatorResponseStatus } from './emulator-http.ts'
|
|
18
|
+
import {
|
|
19
|
+
bindRouteHandlers,
|
|
20
|
+
emulatorEvidenceHeader,
|
|
21
|
+
type EmulatorEvidence,
|
|
22
|
+
type EmulatorRouteEvidence
|
|
23
|
+
} from './route-evidence.ts'
|
|
24
|
+
|
|
25
|
+
export const ChunkCount = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
|
|
26
|
+
|
|
27
|
+
const FaultCount = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))
|
|
28
|
+
|
|
29
|
+
/** Optional fault filter; an omitted field matches every request. `path` ending in `*` is a prefix. */
|
|
30
|
+
export const EmulatorFaultMatch = Schema.Struct({
|
|
31
|
+
path: Schema.optionalKey(Schema.String),
|
|
32
|
+
model: Schema.optionalKey(Schema.String)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
export type EmulatorFaultMatch = typeof EmulatorFaultMatch.Type
|
|
36
|
+
|
|
37
|
+
/** Fields every fault kind carries. */
|
|
38
|
+
export const emulatorFaultFields = {
|
|
39
|
+
match: Schema.optionalKey(EmulatorFaultMatch),
|
|
40
|
+
/** How many matching requests the fault answers. Omitted: every matching request. */
|
|
41
|
+
count: Schema.optionalKey(FaultCount)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Answer with this status, headers, and body instead of the planned response. */
|
|
45
|
+
export const EmulatorStatusFault = Schema.Struct({
|
|
46
|
+
kind: Schema.Literal('status'),
|
|
47
|
+
status: EmulatorResponseStatus,
|
|
48
|
+
headers: Schema.optionalKey(EmulatorHeaderRecord),
|
|
49
|
+
body: Schema.optionalKey(Schema.Json),
|
|
50
|
+
...emulatorFaultFields
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
/** Send `chunks` body chunks, then error the body stream (a dropped connection). */
|
|
54
|
+
export const EmulatorErrorAfterChunksFault = Schema.Struct({
|
|
55
|
+
kind: Schema.Literal('error-after-chunks'),
|
|
56
|
+
chunks: ChunkCount,
|
|
57
|
+
...emulatorFaultFields
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
/** Send `chunks` body chunks, then close the body cleanly (a truncated response). */
|
|
61
|
+
export const EmulatorTruncateAfterChunksFault = Schema.Struct({
|
|
62
|
+
kind: Schema.Literal('truncate-after-chunks'),
|
|
63
|
+
chunks: ChunkCount,
|
|
64
|
+
...emulatorFaultFields
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
/** A scripted error response: status, body (a string is sent as is), and optional headers. */
|
|
68
|
+
export const EmulatorScriptedError = Schema.Struct({
|
|
69
|
+
error: Schema.Struct({
|
|
70
|
+
status: EmulatorResponseStatus,
|
|
71
|
+
body: Schema.Json,
|
|
72
|
+
headers: Schema.optionalKey(EmulatorHeaderRecord)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
export type EmulatorScriptedError = typeof EmulatorScriptedError.Type
|
|
77
|
+
|
|
78
|
+
/** What the kernel needs from every fault: its kind and optional filter and count. */
|
|
79
|
+
export type KernelFault = {
|
|
80
|
+
readonly kind: string
|
|
81
|
+
readonly match?: EmulatorFaultMatch | undefined
|
|
82
|
+
readonly count?: number | undefined
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type BodyChunkFault =
|
|
86
|
+
| typeof EmulatorErrorAfterChunksFault.Type
|
|
87
|
+
| typeof EmulatorTruncateAfterChunksFault.Type
|
|
88
|
+
|
|
89
|
+
export type EmulatorFaultState<Fault> = {
|
|
90
|
+
readonly id: number
|
|
91
|
+
readonly fault: Fault
|
|
92
|
+
/** Remaining matching requests; `undefined` for an unlimited fault. */
|
|
93
|
+
readonly remaining: number | undefined
|
|
94
|
+
readonly applied: number
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
type MutableFaultState<Fault> = {
|
|
98
|
+
readonly id: number
|
|
99
|
+
readonly fault: Fault
|
|
100
|
+
remaining: number | undefined
|
|
101
|
+
applied: number
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type EmulatorRouteCoverage = EmulatorRouteEvidence & {
|
|
105
|
+
/** Ledger requests answered by this route since the last ledger clear or reset. */
|
|
106
|
+
readonly requests: number
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type EmulatorCoverage = {
|
|
110
|
+
readonly routes: ReadonlyArray<EmulatorRouteCoverage>
|
|
111
|
+
/** Ledger requests to unknown routes (failed closed). */
|
|
112
|
+
readonly unknownRouteRequests: number
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Fields an emulator lists first in `/_emulate/state`. */
|
|
116
|
+
export type EmulatorStateFields = {
|
|
117
|
+
readonly knownModels: ReadonlyArray<string>
|
|
118
|
+
/** Only for emulators that emulate streamed reasoning. */
|
|
119
|
+
readonly reasoningModels?: ReadonlyArray<string>
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The ledger fields the kernel owns; each wire core extends it with its request fields. */
|
|
123
|
+
export type KernelLedgerEntry<FaultKind extends string> = {
|
|
124
|
+
seq: number
|
|
125
|
+
method: string
|
|
126
|
+
path: string
|
|
127
|
+
fault?: FaultKind
|
|
128
|
+
faultError?: string
|
|
129
|
+
responseError?: string
|
|
130
|
+
evidence: EmulatorEvidence | 'unknown-route'
|
|
131
|
+
status: number
|
|
132
|
+
bodyChunks: number
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The public shape of every emulator built on the kernel. */
|
|
136
|
+
export type EmulatorApi<Turn, Fault, Entry> = {
|
|
137
|
+
/** The fetch handler (emulated API plus the `/_emulate/*` control plane). Never rejects. */
|
|
138
|
+
readonly fetch: (request: Request) => Promise<Response>
|
|
139
|
+
readonly ledger: {
|
|
140
|
+
readonly entries: () => ReadonlyArray<Entry>
|
|
141
|
+
readonly clear: () => void
|
|
142
|
+
}
|
|
143
|
+
/** Clear the ledger, faults, and scripted turns. */
|
|
144
|
+
readonly reset: () => void
|
|
145
|
+
readonly faults: {
|
|
146
|
+
/** Add a fault; throws the emulator's input-invalid error for an invalid fault. */
|
|
147
|
+
readonly add: (fault: Fault) => EmulatorFaultState<Fault>
|
|
148
|
+
readonly list: () => ReadonlyArray<EmulatorFaultState<Fault>>
|
|
149
|
+
readonly clear: () => void
|
|
150
|
+
}
|
|
151
|
+
readonly script: {
|
|
152
|
+
/** Queue a turn for the next emulated request; throws for an invalid turn. */
|
|
153
|
+
readonly enqueue: (turn: Turn) => void
|
|
154
|
+
readonly pending: () => number
|
|
155
|
+
readonly clear: () => void
|
|
156
|
+
}
|
|
157
|
+
readonly coverage: () => EmulatorCoverage
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export type EmulatorKernelConfig<
|
|
161
|
+
Fault extends KernelFault,
|
|
162
|
+
Turn,
|
|
163
|
+
Entry extends KernelLedgerEntry<Fault['kind']>,
|
|
164
|
+
Snapshot
|
|
165
|
+
> = {
|
|
166
|
+
/** Route evidence manifest; `serve` needs exactly one handler per route. */
|
|
167
|
+
readonly routes: ReadonlyArray<EmulatorRouteEvidence>
|
|
168
|
+
/** Fault schema (decoded strictly). */
|
|
169
|
+
readonly faultSchema: Schema.Decoder<Fault>
|
|
170
|
+
/** Scripted-turn schema (decoded strictly). */
|
|
171
|
+
readonly turnSchema: Schema.Decoder<Turn>
|
|
172
|
+
/** Adds the wire core's own ledger fields to a fresh kernel entry. */
|
|
173
|
+
readonly newEntry: (base: KernelLedgerEntry<Fault['kind']>) => Entry
|
|
174
|
+
/** Copies an entry for the ledger API (never shares mutable state). */
|
|
175
|
+
readonly snapshotEntry: (entry: Entry) => Snapshot
|
|
176
|
+
/**
|
|
177
|
+
* Response for an unknown emulated route (fails closed, still written to the ledger with the
|
|
178
|
+
* status it answers); the core may add its own ledger fields to `entry`.
|
|
179
|
+
*/
|
|
180
|
+
readonly unknownRoute: (entry: Entry) => Response
|
|
181
|
+
/** Fields listed first in `/_emulate/state`. */
|
|
182
|
+
readonly stateFields: () => EmulatorStateFields
|
|
183
|
+
/** Builds the error thrown by the JS API (`faults.add`, `script.enqueue`) for invalid input. */
|
|
184
|
+
readonly inputInvalid: (input: 'fault' | 'turn', reason: string) => Error
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export type EmulatorRouteHandler<Entry> = (
|
|
188
|
+
request: Request,
|
|
189
|
+
entry: Entry,
|
|
190
|
+
path: string
|
|
191
|
+
) => Promise<Response>
|
|
192
|
+
|
|
193
|
+
// Control inputs are strict: an unknown key (a typo, or an `error` turn with a bad field) is
|
|
194
|
+
// rejected instead of silently decoding as a different, all-optional shape.
|
|
195
|
+
const strict = { onExcessProperty: 'error' } as const
|
|
196
|
+
|
|
197
|
+
const decodeJsonText = Schema.decodeUnknownResult(Schema.fromJsonString(Schema.Json))
|
|
198
|
+
|
|
199
|
+
const issueMessage = (issue: Schema.SchemaError['issue']): string =>
|
|
200
|
+
new Schema.SchemaError(issue).message
|
|
201
|
+
|
|
202
|
+
export const isJsonObject = (value: Schema.Json | undefined): value is Schema.JsonObject =>
|
|
203
|
+
value !== null && value !== undefined && Predicate.isObject(value) && !Array.isArray(value)
|
|
204
|
+
|
|
205
|
+
export const jsonField = (value: Schema.Json | undefined, key: string): Schema.Json | undefined =>
|
|
206
|
+
isJsonObject(value) ? value[key] : undefined
|
|
207
|
+
|
|
208
|
+
export const stringField = (value: Schema.Json | undefined, key: string): string | undefined => {
|
|
209
|
+
const field = jsonField(value, key)
|
|
210
|
+
|
|
211
|
+
return Predicate.isString(field) ? field : undefined
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const textEncoder = new TextEncoder()
|
|
215
|
+
|
|
216
|
+
/** A JSON response; `body` is any JSON-serializable value (ledger snapshots, fault states, envelopes). */
|
|
217
|
+
export const jsonResponse = (
|
|
218
|
+
status: number,
|
|
219
|
+
body: unknown,
|
|
220
|
+
headers: HeadersInit = {}
|
|
221
|
+
): Response => {
|
|
222
|
+
const responseHeaders = new Headers(headers)
|
|
223
|
+
|
|
224
|
+
responseHeaders.set('content-type', 'application/json')
|
|
225
|
+
|
|
226
|
+
return new Response(JSON.stringify(body), { status, headers: responseHeaders })
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** An emulator (not emulated-service) error: control-plane problems and unbuildable responses. */
|
|
230
|
+
export const controlError = (
|
|
231
|
+
status: number,
|
|
232
|
+
message: string,
|
|
233
|
+
headers: HeadersInit = {}
|
|
234
|
+
): Response => jsonResponse(status, { error: { message, type: 'emulator_error' } }, headers)
|
|
235
|
+
|
|
236
|
+
/** A scripted or fault body: a string is sent as is, anything else as JSON. */
|
|
237
|
+
export const bodyText = (body: Schema.Json): string =>
|
|
238
|
+
Predicate.isString(body) ? body : JSON.stringify(body)
|
|
239
|
+
|
|
240
|
+
/** Headers for a scripted or fault body, with a `content-type` matching the body when unset. */
|
|
241
|
+
const bodyHeaders = (headers: Readonly<Record<string, string>> | undefined, body: Schema.Json) => {
|
|
242
|
+
const result = new Headers(headers ?? {})
|
|
243
|
+
|
|
244
|
+
if (!result.has('content-type')) {
|
|
245
|
+
result.set('content-type', Predicate.isString(body) ? 'text/plain' : 'application/json')
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return result
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export const readText = (request: Request): Promise<string | undefined> =>
|
|
252
|
+
request.text().then(
|
|
253
|
+
text => text,
|
|
254
|
+
() => undefined
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
export const parseJson = (text: string): Schema.Json | undefined => {
|
|
258
|
+
const result = decodeJsonText(text)
|
|
259
|
+
|
|
260
|
+
return Result.isSuccess(result) ? result.success : undefined
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const pathMatches = (pattern: string, path: string): boolean =>
|
|
264
|
+
pattern.endsWith('*') ? path.startsWith(pattern.slice(0, -1)) : pattern === path
|
|
265
|
+
|
|
266
|
+
const faultMatches = (fault: KernelFault, path: string, model: string | undefined): boolean =>
|
|
267
|
+
(fault.match?.path === undefined || pathMatches(fault.match.path, path)) &&
|
|
268
|
+
(fault.match?.model === undefined || fault.match.model === model)
|
|
269
|
+
|
|
270
|
+
const isBodyChunkFault = (fault: KernelFault): fault is BodyChunkFault =>
|
|
271
|
+
fault.kind === 'error-after-chunks' || fault.kind === 'truncate-after-chunks'
|
|
272
|
+
|
|
273
|
+
const chunkFaultProblem = (fault: BodyChunkFault, chunks: number): string | undefined => {
|
|
274
|
+
const applies =
|
|
275
|
+
fault.kind === 'error-after-chunks' ? fault.chunks <= chunks : fault.chunks < chunks
|
|
276
|
+
|
|
277
|
+
return applies
|
|
278
|
+
? undefined
|
|
279
|
+
: `${fault.kind} after ${fault.chunks} chunk(s) cannot apply to a response with ${chunks} chunk(s)`
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Strictly pull-driven body: chunk `k` is produced only when the consumer
|
|
284
|
+
* pulls it, and `entry.bodyChunks` counts the chunks handed over. The fault
|
|
285
|
+
* (already validated against the chunk count) applies when the consumer pulls
|
|
286
|
+
* past `fault.chunks` chunks.
|
|
287
|
+
*/
|
|
288
|
+
const chunkedBody = (
|
|
289
|
+
chunks: ReadonlyArray<string>,
|
|
290
|
+
fault: BodyChunkFault | undefined,
|
|
291
|
+
entry: { bodyChunks: number }
|
|
292
|
+
): ReadableStream<Uint8Array> => {
|
|
293
|
+
let next = 0
|
|
294
|
+
|
|
295
|
+
return new ReadableStream<Uint8Array>(
|
|
296
|
+
{
|
|
297
|
+
pull: controller => {
|
|
298
|
+
if (fault !== undefined && next === fault.chunks) {
|
|
299
|
+
if (fault.kind === 'error-after-chunks') {
|
|
300
|
+
controller.error(new Error(`synthetic mid-stream failure after ${next} chunk(s)`))
|
|
301
|
+
} else {
|
|
302
|
+
controller.close()
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const chunk = chunks[next]
|
|
309
|
+
|
|
310
|
+
if (chunk === undefined) {
|
|
311
|
+
controller.close()
|
|
312
|
+
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
next += 1
|
|
317
|
+
entry.bodyChunks += 1
|
|
318
|
+
controller.enqueue(textEncoder.encode(chunk))
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
{ highWaterMark: 0 }
|
|
322
|
+
)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const withEvidence = (response: Response, evidence: EmulatorEvidence): Response => {
|
|
326
|
+
if (evidence === 'unverified') {
|
|
327
|
+
response.headers.set(emulatorEvidenceHeader, 'unverified')
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return response
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const maxSchemaDepth = 8
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* A synthetic value for a JSON Schema: required object properties only,
|
|
337
|
+
* non-empty strings (or the first `enum` / `const` value), the minimum for
|
|
338
|
+
* numbers, `true` for booleans, and `minItems` synthetic items for arrays.
|
|
339
|
+
*/
|
|
340
|
+
export const synthesizeValue = (
|
|
341
|
+
schema: Schema.Json | undefined,
|
|
342
|
+
name: string,
|
|
343
|
+
depth: number
|
|
344
|
+
): Schema.Json => {
|
|
345
|
+
const constant = jsonField(schema, 'const')
|
|
346
|
+
|
|
347
|
+
if (constant !== undefined) {
|
|
348
|
+
return constant
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const choices = jsonField(schema, 'enum')
|
|
352
|
+
|
|
353
|
+
if (Array.isArray(choices) && choices.length > 0) {
|
|
354
|
+
return choices[0] ?? null
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
for (const key of ['anyOf', 'oneOf', 'allOf']) {
|
|
358
|
+
const options = jsonField(schema, key)
|
|
359
|
+
|
|
360
|
+
if (Array.isArray(options) && options.length > 0) {
|
|
361
|
+
const preferred = options.find(option => stringField(option, 'type') !== 'null')
|
|
362
|
+
|
|
363
|
+
return synthesizeValue(preferred ?? options[0], name, depth + 1)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const declared = jsonField(schema, 'type')
|
|
368
|
+
|
|
369
|
+
const type = Array.isArray(declared)
|
|
370
|
+
? declared.find(entry => Predicate.isString(entry) && entry !== 'null')
|
|
371
|
+
: declared
|
|
372
|
+
|
|
373
|
+
if (depth > maxSchemaDepth) {
|
|
374
|
+
return null
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
switch (type) {
|
|
378
|
+
case 'object': {
|
|
379
|
+
const properties = jsonField(schema, 'properties')
|
|
380
|
+
const required = jsonField(schema, 'required')
|
|
381
|
+
const value: Record<string, Schema.Json> = {}
|
|
382
|
+
|
|
383
|
+
for (const key of Array.isArray(required) ? required : []) {
|
|
384
|
+
if (Predicate.isString(key)) {
|
|
385
|
+
value[key] = synthesizeValue(jsonField(properties, key), key, depth + 1)
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return value
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
case 'array': {
|
|
393
|
+
const minItems = jsonField(schema, 'minItems')
|
|
394
|
+
const count = Predicate.isNumber(minItems) && minItems > 0 ? Math.min(minItems, 3) : 0
|
|
395
|
+
|
|
396
|
+
return Array.from({ length: count }, () =>
|
|
397
|
+
synthesizeValue(jsonField(schema, 'items'), name, depth + 1)
|
|
398
|
+
)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
case 'integer':
|
|
402
|
+
case 'number': {
|
|
403
|
+
const minimum = jsonField(schema, 'minimum')
|
|
404
|
+
|
|
405
|
+
return Predicate.isNumber(minimum) ? minimum : 1
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
case 'boolean':
|
|
409
|
+
return true
|
|
410
|
+
case 'null':
|
|
411
|
+
return null
|
|
412
|
+
default:
|
|
413
|
+
return `synthetic ${name}`
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Split text into up to `parts` non-empty fragments (at least one). */
|
|
418
|
+
export const splitFragments = (text: string, parts: number): ReadonlyArray<string> => {
|
|
419
|
+
if (text.length <= 1) {
|
|
420
|
+
return [text]
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const size = Math.ceil(text.length / Math.min(parts, text.length))
|
|
424
|
+
const fragments: Array<string> = []
|
|
425
|
+
|
|
426
|
+
for (let index = 0; index < text.length; index += size) {
|
|
427
|
+
fragments.push(text.slice(index, index + size))
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return fragments
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** A rough synthetic token count (about four characters per token, at least one). */
|
|
434
|
+
export const approximateTokens = (text: string): number => Math.max(1, Math.ceil(text.length / 4))
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Create the kernel of an emulator. Each call has independent ledger, fault,
|
|
438
|
+
* and script state. Handlers use the returned helpers, then `serve` binds
|
|
439
|
+
* them to the manifest (throwing `EmulatorRouteUnmapped` on a mismatch) and
|
|
440
|
+
* returns the emulator API.
|
|
441
|
+
*/
|
|
442
|
+
export const makeEmulatorKernel = <
|
|
443
|
+
Fault extends KernelFault,
|
|
444
|
+
Turn,
|
|
445
|
+
Entry extends KernelLedgerEntry<Fault['kind']>,
|
|
446
|
+
Snapshot
|
|
447
|
+
>(
|
|
448
|
+
config: EmulatorKernelConfig<Fault, Turn, Entry, Snapshot>
|
|
449
|
+
) => {
|
|
450
|
+
const decodeFault = Schema.decodeUnknownResult(config.faultSchema, strict)
|
|
451
|
+
|
|
452
|
+
const decodeFaultList = Schema.decodeUnknownResult(
|
|
453
|
+
Schema.Union([config.faultSchema, Schema.Struct({ faults: Schema.Array(config.faultSchema) })]),
|
|
454
|
+
strict
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
const decodeTurn = Schema.decodeUnknownResult(config.turnSchema, strict)
|
|
458
|
+
|
|
459
|
+
const decodeTurnList = Schema.decodeUnknownResult(
|
|
460
|
+
Schema.Union([Schema.Struct({ turns: Schema.Array(config.turnSchema) }), config.turnSchema]),
|
|
461
|
+
strict
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
let entries: Array<Entry> = []
|
|
465
|
+
let faultStates: Array<MutableFaultState<Fault>> = []
|
|
466
|
+
let turns: Array<Turn> = []
|
|
467
|
+
let nextSeq = 1
|
|
468
|
+
let nextFaultId = 1
|
|
469
|
+
|
|
470
|
+
const snapshotFault = (state: MutableFaultState<Fault>): EmulatorFaultState<Fault> => ({
|
|
471
|
+
...state
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
const clearLedger = () => {
|
|
475
|
+
entries = []
|
|
476
|
+
nextSeq = 1
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const addFault = (input: unknown): EmulatorFaultState<Fault> | string => {
|
|
480
|
+
const decoded = decodeFault(input)
|
|
481
|
+
|
|
482
|
+
if (Result.isFailure(decoded)) {
|
|
483
|
+
return issueMessage(decoded.failure.issue)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const state: MutableFaultState<Fault> = {
|
|
487
|
+
id: nextFaultId++,
|
|
488
|
+
fault: decoded.success,
|
|
489
|
+
remaining: decoded.success.count,
|
|
490
|
+
applied: 0
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
faultStates.push(state)
|
|
494
|
+
|
|
495
|
+
return snapshotFault(state)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const enqueueTurn = (input: unknown): string | undefined => {
|
|
499
|
+
const decoded = decodeTurn(input)
|
|
500
|
+
|
|
501
|
+
if (Result.isFailure(decoded)) {
|
|
502
|
+
return issueMessage(decoded.failure.issue)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
turns.push(decoded.success)
|
|
506
|
+
|
|
507
|
+
return undefined
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** The first matching fault with uses left, in insertion order (not yet consumed). */
|
|
511
|
+
const takeFault = (
|
|
512
|
+
path: string,
|
|
513
|
+
model: string | undefined
|
|
514
|
+
): MutableFaultState<Fault> | undefined =>
|
|
515
|
+
faultStates.find(
|
|
516
|
+
state =>
|
|
517
|
+
(state.remaining === undefined || state.remaining > 0) &&
|
|
518
|
+
faultMatches(state.fault, path, model)
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
/** Use up one application of a fault and record its kind in the ledger entry. */
|
|
522
|
+
const consumeFault = (state: MutableFaultState<Fault>, entry: Entry) => {
|
|
523
|
+
state.applied += 1
|
|
524
|
+
entry.fault = state.fault.kind
|
|
525
|
+
|
|
526
|
+
if (state.remaining !== undefined) {
|
|
527
|
+
state.remaining -= 1
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** The next scripted turn, used up when its request arrives. */
|
|
532
|
+
const nextTurn = (): Turn | undefined => turns.shift()
|
|
533
|
+
|
|
534
|
+
const coverage = (): EmulatorCoverage => ({
|
|
535
|
+
routes: config.routes.map(route => ({
|
|
536
|
+
...route,
|
|
537
|
+
requests: entries.filter(
|
|
538
|
+
entry =>
|
|
539
|
+
entry.evidence !== 'unknown-route' &&
|
|
540
|
+
entry.method === route.method &&
|
|
541
|
+
entry.path === route.path
|
|
542
|
+
).length
|
|
543
|
+
})),
|
|
544
|
+
unknownRouteRequests: entries.filter(entry => entry.evidence === 'unknown-route').length
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Answer with a pull-driven body. A matching `error-after-chunks` or
|
|
549
|
+
* `truncate-after-chunks` fault shapes it; one that cannot take effect
|
|
550
|
+
* answers 500 and is not consumed. Other fault kinds are left to the caller.
|
|
551
|
+
*/
|
|
552
|
+
const respondWithBody = (
|
|
553
|
+
entry: Entry,
|
|
554
|
+
status: number,
|
|
555
|
+
headers: HeadersInit,
|
|
556
|
+
chunks: ReadonlyArray<string>,
|
|
557
|
+
fault: MutableFaultState<Fault> | undefined
|
|
558
|
+
): Response => {
|
|
559
|
+
const chunkFault =
|
|
560
|
+
fault === undefined || !isBodyChunkFault(fault.fault) ? undefined : fault.fault
|
|
561
|
+
|
|
562
|
+
if (chunkFault !== undefined) {
|
|
563
|
+
const problem = chunkFaultProblem(chunkFault, chunks.length)
|
|
564
|
+
|
|
565
|
+
if (problem !== undefined) {
|
|
566
|
+
entry.faultError = problem
|
|
567
|
+
entry.status = 500
|
|
568
|
+
|
|
569
|
+
return controlError(500, `emulator fault cannot apply: ${problem}`)
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Build the response before using up the fault: a response that cannot be built must not
|
|
574
|
+
// consume it.
|
|
575
|
+
const response = new Response(chunkedBody(chunks, chunkFault, entry), { status, headers })
|
|
576
|
+
|
|
577
|
+
if (chunkFault !== undefined && fault !== undefined) {
|
|
578
|
+
consumeFault(fault, entry)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
entry.status = response.status
|
|
582
|
+
|
|
583
|
+
return response
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** Answer a `status` fault with its status, headers, and body (or `defaultBody`). */
|
|
587
|
+
const respondWithStatusFault = (
|
|
588
|
+
entry: Entry,
|
|
589
|
+
state: MutableFaultState<Fault>,
|
|
590
|
+
fault: typeof EmulatorStatusFault.Type,
|
|
591
|
+
defaultBody: (status: number) => Schema.Json
|
|
592
|
+
): Response => {
|
|
593
|
+
const faultBody = fault.body ?? defaultBody(fault.status)
|
|
594
|
+
|
|
595
|
+
// Built first: a status fault whose response cannot be built is not used up.
|
|
596
|
+
const response = new Response(bodyText(faultBody), {
|
|
597
|
+
status: fault.status,
|
|
598
|
+
headers: bodyHeaders(fault.headers, faultBody)
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
consumeFault(state, entry)
|
|
602
|
+
entry.status = response.status
|
|
603
|
+
|
|
604
|
+
return response
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** Answer a scripted error turn; a matching chunk fault still shapes its body. */
|
|
608
|
+
const respondWithScriptedError = (
|
|
609
|
+
entry: Entry,
|
|
610
|
+
error: EmulatorScriptedError['error'],
|
|
611
|
+
fault: MutableFaultState<Fault> | undefined
|
|
612
|
+
): Response =>
|
|
613
|
+
respondWithBody(
|
|
614
|
+
entry,
|
|
615
|
+
error.status,
|
|
616
|
+
bodyHeaders(error.headers, error.body),
|
|
617
|
+
[bodyText(error.body)],
|
|
618
|
+
fault
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
const reset = () => {
|
|
622
|
+
clearLedger()
|
|
623
|
+
faultStates = []
|
|
624
|
+
turns = []
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const state = () => ({
|
|
628
|
+
...config.stateFields(),
|
|
629
|
+
pendingTurns: turns.length,
|
|
630
|
+
faults: faultStates.map(snapshotFault),
|
|
631
|
+
ledgerEntries: entries.length
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
const controlPlane = async (request: Request, path: string): Promise<Response> => {
|
|
635
|
+
const method = request.method
|
|
636
|
+
const allow = (methods: string) => controlError(405, 'method not allowed', { allow: methods })
|
|
637
|
+
|
|
638
|
+
const jsonBody = async (): Promise<Schema.Json | undefined> => {
|
|
639
|
+
const text = await readText(request)
|
|
640
|
+
|
|
641
|
+
return text === undefined ? undefined : parseJson(text)
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
switch (path) {
|
|
645
|
+
case '/_emulate/ledger':
|
|
646
|
+
if (method === 'GET') {
|
|
647
|
+
return jsonResponse(200, { entries: entries.map(config.snapshotEntry) })
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (method === 'DELETE') {
|
|
651
|
+
const cleared = entries.length
|
|
652
|
+
|
|
653
|
+
clearLedger()
|
|
654
|
+
|
|
655
|
+
return jsonResponse(200, { cleared })
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return allow('GET, DELETE')
|
|
659
|
+
|
|
660
|
+
case '/_emulate/faults': {
|
|
661
|
+
if (method === 'GET') {
|
|
662
|
+
return jsonResponse(200, { faults: faultStates.map(snapshotFault) })
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (method === 'DELETE') {
|
|
666
|
+
const cleared = faultStates.length
|
|
667
|
+
|
|
668
|
+
faultStates = []
|
|
669
|
+
|
|
670
|
+
return jsonResponse(200, { cleared })
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (method !== 'POST') {
|
|
674
|
+
return allow('GET, POST, DELETE')
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const decoded = decodeFaultList(await jsonBody())
|
|
678
|
+
|
|
679
|
+
if (Result.isFailure(decoded)) {
|
|
680
|
+
return controlError(400, `invalid fault: ${issueMessage(decoded.failure.issue)}`)
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const added = (
|
|
684
|
+
'faults' in decoded.success ? decoded.success.faults : [decoded.success]
|
|
685
|
+
).map(fault => addFault(fault))
|
|
686
|
+
|
|
687
|
+
return jsonResponse(201, { faults: added })
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
case '/_emulate/script': {
|
|
691
|
+
if (method !== 'POST') {
|
|
692
|
+
return allow('POST')
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const decoded = decodeTurnList(await jsonBody())
|
|
696
|
+
|
|
697
|
+
if (Result.isFailure(decoded)) {
|
|
698
|
+
return controlError(400, `invalid turn: ${issueMessage(decoded.failure.issue)}`)
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const queued =
|
|
702
|
+
Predicate.isObject(decoded.success) && 'turns' in decoded.success
|
|
703
|
+
? decoded.success.turns
|
|
704
|
+
: [decoded.success]
|
|
705
|
+
|
|
706
|
+
for (const turn of queued) {
|
|
707
|
+
enqueueTurn(turn)
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
return jsonResponse(201, { pending: turns.length })
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
case '/_emulate/reset':
|
|
714
|
+
if (method !== 'POST') {
|
|
715
|
+
return allow('POST')
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
reset()
|
|
719
|
+
|
|
720
|
+
return jsonResponse(200, { reset: true })
|
|
721
|
+
|
|
722
|
+
case '/_emulate/state':
|
|
723
|
+
if (method !== 'GET') {
|
|
724
|
+
return allow('GET')
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
return jsonResponse(200, state())
|
|
728
|
+
|
|
729
|
+
case '/_emulate/coverage':
|
|
730
|
+
if (method !== 'GET') {
|
|
731
|
+
return allow('GET')
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
return jsonResponse(200, coverage())
|
|
735
|
+
|
|
736
|
+
default:
|
|
737
|
+
return controlError(404, 'unknown control-plane route')
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Bind one handler per manifest route (keyed by `emulatorRouteKey`) and
|
|
743
|
+
* return the emulator API. Throws `EmulatorRouteUnmapped` when a manifest
|
|
744
|
+
* route has no handler or a handler has no manifest route.
|
|
745
|
+
*/
|
|
746
|
+
const serve = (
|
|
747
|
+
handlers: ReadonlyMap<string, EmulatorRouteHandler<Entry>>
|
|
748
|
+
): EmulatorApi<Turn, Fault, Snapshot> => {
|
|
749
|
+
const routes = bindRouteHandlers(config.routes, handlers)
|
|
750
|
+
|
|
751
|
+
const emulatedApi = async (request: Request, path: string): Promise<Response> => {
|
|
752
|
+
const bound = routes.find(
|
|
753
|
+
candidate => candidate.route.method === request.method && candidate.route.path === path
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
const entry = config.newEntry({
|
|
757
|
+
seq: nextSeq++,
|
|
758
|
+
method: request.method,
|
|
759
|
+
path,
|
|
760
|
+
evidence: bound?.route.evidence ?? 'unknown-route',
|
|
761
|
+
status: 0,
|
|
762
|
+
bodyChunks: 0
|
|
763
|
+
})
|
|
764
|
+
|
|
765
|
+
entries.push(entry)
|
|
766
|
+
|
|
767
|
+
if (bound === undefined) {
|
|
768
|
+
const response = config.unknownRoute(entry)
|
|
769
|
+
|
|
770
|
+
entry.status = response.status
|
|
771
|
+
|
|
772
|
+
return response
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Error recovery still answers through the route: the fallback 500 is evidence-tagged and
|
|
776
|
+
// the ledger records the status actually sent.
|
|
777
|
+
const response = await bound.handler(request, entry, path).catch(() => {
|
|
778
|
+
entry.status = 500
|
|
779
|
+
entry.responseError = 'the emulator could not build the planned response'
|
|
780
|
+
|
|
781
|
+
return controlError(500, 'emulator could not build the response')
|
|
782
|
+
})
|
|
783
|
+
|
|
784
|
+
return withEvidence(response, bound.route.evidence)
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const handle = (request: Request): Promise<Response> => {
|
|
788
|
+
const path = URL.canParse(request.url) ? new URL(request.url).pathname : '/'
|
|
789
|
+
|
|
790
|
+
return path === '/_emulate' || path.startsWith('/_emulate/')
|
|
791
|
+
? controlPlane(request, path)
|
|
792
|
+
: emulatedApi(request, path)
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
return {
|
|
796
|
+
fetch: request =>
|
|
797
|
+
handle(request).catch(() => controlError(500, 'emulator failed to handle the request')),
|
|
798
|
+
ledger: {
|
|
799
|
+
entries: () => entries.map(config.snapshotEntry),
|
|
800
|
+
clear: clearLedger
|
|
801
|
+
},
|
|
802
|
+
reset,
|
|
803
|
+
faults: {
|
|
804
|
+
add: fault => {
|
|
805
|
+
const added = addFault(fault)
|
|
806
|
+
|
|
807
|
+
if (Predicate.isString(added)) {
|
|
808
|
+
throw config.inputInvalid('fault', added)
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
return added
|
|
812
|
+
},
|
|
813
|
+
list: () => faultStates.map(snapshotFault),
|
|
814
|
+
clear: () => {
|
|
815
|
+
faultStates = []
|
|
816
|
+
}
|
|
817
|
+
},
|
|
818
|
+
script: {
|
|
819
|
+
enqueue: turn => {
|
|
820
|
+
const problem = enqueueTurn(turn)
|
|
821
|
+
|
|
822
|
+
if (problem !== undefined) {
|
|
823
|
+
throw config.inputInvalid('turn', problem)
|
|
824
|
+
}
|
|
825
|
+
},
|
|
826
|
+
pending: () => turns.length,
|
|
827
|
+
clear: () => {
|
|
828
|
+
turns = []
|
|
829
|
+
}
|
|
830
|
+
},
|
|
831
|
+
coverage
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
return {
|
|
836
|
+
takeFault,
|
|
837
|
+
consumeFault,
|
|
838
|
+
nextTurn,
|
|
839
|
+
respondWithBody,
|
|
840
|
+
respondWithStatusFault,
|
|
841
|
+
respondWithScriptedError,
|
|
842
|
+
serve
|
|
843
|
+
}
|
|
844
|
+
}
|