@specbook/ui 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -2
  3. package/dist-viewer/THIRD-PARTY-NOTICES.txt +581 -0
  4. package/dist-viewer/apple-touch-icon.png +0 -0
  5. package/dist-viewer/assets/Host-CSrM9wY1.js +37 -0
  6. package/dist-viewer/assets/faker-B3c03STC.js +11 -0
  7. package/dist-viewer/assets/index-Ds6DZpGP.css +1 -0
  8. package/dist-viewer/assets/index-ez9c20v3.js +1 -0
  9. package/dist-viewer/assets/main-38QGGpNT.js +14 -0
  10. package/dist-viewer/assets/mount-CGL2tOpN.js +2 -0
  11. package/dist-viewer/assets/panel-C-vBZrep.css +1 -0
  12. package/dist-viewer/assets/panel-C8Ve3LeS.js +1 -0
  13. package/dist-viewer/assets/registry-ChKCFBt1.js +2 -0
  14. package/dist-viewer/assets/worker-ClGb0tE1.js +181 -0
  15. package/dist-viewer/favicon.svg +1 -0
  16. package/dist-viewer/index.html +19 -0
  17. package/dist-viewer/specbook.js +1 -0
  18. package/index.html +12 -0
  19. package/package.json +61 -3
  20. package/src/app.tsx +368 -0
  21. package/src/assets/mark.svg +1 -0
  22. package/src/badge.ts +14 -0
  23. package/src/branding.ts +92 -0
  24. package/src/components/Catalog.tsx +324 -0
  25. package/src/components/EnvBadge.tsx +48 -0
  26. package/src/components/EnvironmentMenu.tsx +262 -0
  27. package/src/components/Fields.tsx +220 -0
  28. package/src/components/GitHubStar.tsx +22 -0
  29. package/src/components/ListResizer.tsx +70 -0
  30. package/src/components/ModeHint.tsx +17 -0
  31. package/src/components/Palette.tsx +85 -0
  32. package/src/components/PoweredBy.tsx +15 -0
  33. package/src/components/ShareMenu.tsx +94 -0
  34. package/src/components/ThemeToggle.tsx +52 -0
  35. package/src/embed/panel.tsx +115 -0
  36. package/src/embed/styles.ts +27 -0
  37. package/src/embed.ts +65 -0
  38. package/src/engine.ts +574 -0
  39. package/src/extension.ts +101 -0
  40. package/src/fielddoc.ts +83 -0
  41. package/src/main.tsx +24 -0
  42. package/src/markdown.ts +133 -0
  43. package/src/nav.ts +118 -0
  44. package/src/oauth-callback.ts +87 -0
  45. package/src/oauth.ts +194 -0
  46. package/src/panel/Body.tsx +182 -0
  47. package/src/panel/BodyEditor.tsx +92 -0
  48. package/src/panel/CodeSample.tsx +238 -0
  49. package/src/panel/Combo.tsx +124 -0
  50. package/src/panel/ControlsGrid.tsx +189 -0
  51. package/src/panel/FieldDoc.tsx +42 -0
  52. package/src/panel/FieldTable.tsx +113 -0
  53. package/src/panel/Host.tsx +100 -0
  54. package/src/panel/OAuthSignIn.tsx +163 -0
  55. package/src/panel/OperationDocs.tsx +181 -0
  56. package/src/panel/Panel.tsx +197 -0
  57. package/src/panel/ParamGrid.tsx +396 -0
  58. package/src/panel/RequestPanel.tsx +627 -0
  59. package/src/panel/RequestView.tsx +108 -0
  60. package/src/panel/ResponseView.tsx +182 -0
  61. package/src/panel/SecretsBar.tsx +189 -0
  62. package/src/panel/SequenceDocs.tsx +132 -0
  63. package/src/panel/SequencePanel.tsx +247 -0
  64. package/src/panel/StepExchange.tsx +118 -0
  65. package/src/panel/completions.ts +114 -0
  66. package/src/panel/draft.ts +106 -0
  67. package/src/panel/fieldnotes-split.ts +55 -0
  68. package/src/panel/fieldnotes.ts +40 -0
  69. package/src/panel/graphql.ts +31 -0
  70. package/src/panel/icons.tsx +85 -0
  71. package/src/panel/jsonCursor.ts +115 -0
  72. package/src/panel/stepnotes-split.ts +38 -0
  73. package/src/panel/stepnotes.ts +58 -0
  74. package/src/routing.ts +120 -0
  75. package/src/runtime.ts +32 -0
  76. package/src/schema.ts +359 -0
  77. package/src/secrets.ts +61 -0
  78. package/src/status.ts +27 -0
  79. package/src/store.ts +12 -0
  80. package/src/styles/app.css +821 -0
  81. package/src/styles/panel.css +1939 -0
  82. package/src/styles/tokens.css +122 -0
  83. package/src/theme.ts +37 -0
  84. package/src/viewer/Opening.tsx +43 -0
  85. package/src/viewer/bundles.ts +74 -0
  86. package/src/viewer/client.ts +54 -0
  87. package/src/viewer/embed.ts +83 -0
  88. package/src/viewer/faker-en.ts +3 -0
  89. package/src/viewer/main.tsx +289 -0
  90. package/src/viewer/mount.ts +52 -0
  91. package/src/viewer/protocol.ts +54 -0
  92. package/src/viewer/recent.ts +22 -0
  93. package/src/viewer/registry-data.ts +8 -0
  94. package/src/viewer/registry.ts +163 -0
  95. package/src/viewer/share.ts +134 -0
  96. package/src/viewer/site.ts +3 -0
  97. package/src/viewer/uploads.ts +53 -0
  98. package/src/viewer/viewer.css +184 -0
  99. package/src/viewer/worker-factory.ts +14 -0
  100. package/src/viewer/worker.ts +176 -0
  101. package/src/virtual.d.ts +104 -0
@@ -0,0 +1,27 @@
1
+ import complete from 'prism-code-editor/autocomplete.css?inline'
2
+ import completeIcons from 'prism-code-editor/autocomplete-icons.css?inline'
3
+ import layout from 'prism-code-editor/layout.css?inline'
4
+ import app from '../styles/app.css?inline'
5
+ import panel from '../styles/panel.css?inline'
6
+ import tokens from '../styles/tokens.css?inline'
7
+
8
+ const sheet = (css: string) => {
9
+ const s = new CSSStyleSheet()
10
+ s.replaceSync(css)
11
+ return s
12
+ }
13
+
14
+ /** `tokens` first so its layer order wins; the host page's CSS can't reach in, ours can't leak out. */
15
+ export const playSheets = (css: string) => [sheet(tokens), sheet(css)]
16
+ export const panelSheets = () => [
17
+ sheet(tokens),
18
+ sheet(layout),
19
+ sheet(complete),
20
+ sheet(completeIcons),
21
+ sheet(app),
22
+ sheet(panel),
23
+ sheet(root),
24
+ ]
25
+
26
+ /** Inline `color`/`font` on the host would be beaten by host-page rules, so the root element sets them. */
27
+ const root = '.sb-root{font:14px/1.5 var(--sb-font-sans);color:var(--sb-text)}'
package/src/embed.ts ADDED
@@ -0,0 +1,65 @@
1
+ import type { Target } from './embed/panel'
2
+ import tokens from './styles/tokens.css?inline'
3
+
4
+ const css = `button {
5
+ font: 13px/1.4 var(--sb-font-sans);
6
+ margin-inline: 0.5em;
7
+ padding: 0 0.6em;
8
+ cursor: pointer;
9
+ border: 1px solid var(--sb-border);
10
+ border-radius: var(--sb-radius-control);
11
+ background: var(--sb-surface);
12
+ color: var(--sb-accent);
13
+ }
14
+ button:hover { background: var(--sb-surface-raised); }
15
+ button:focus-visible { outline: 2px solid var(--sb-focus-ring); }`
16
+
17
+ /** The panel (editor, grammars, runner) is a separate chunk fetched on first click. */
18
+ async function open(target: Target) {
19
+ const { show } = await import('./embed/panel')
20
+ show(target)
21
+ }
22
+
23
+ class SpecbookPlay extends HTMLElement {
24
+ target: Target = {}
25
+
26
+ connectedCallback() {
27
+ if (this.shadowRoot) return
28
+ const root = this.attachShadow({ mode: 'open' })
29
+ const sheet = new CSSStyleSheet()
30
+ sheet.replaceSync(`${tokens}\n@layer base{${css}}`)
31
+ root.adoptedStyleSheets = [sheet]
32
+ const b = document.createElement('button')
33
+ b.type = 'button'
34
+ b.part.add('play')
35
+ b.textContent = '▶ Play'
36
+ b.addEventListener('click', () => open(this.target))
37
+ root.append(b)
38
+ }
39
+ }
40
+
41
+ customElements.define('specbook-play', SpecbookPlay)
42
+
43
+ function attach(el: HTMLElement) {
44
+ if (el.dataset.specbookReady) return
45
+ el.dataset.specbookReady = ''
46
+ const play = document.createElement('specbook-play') as SpecbookPlay
47
+ play.target = {
48
+ service: el.dataset.specbook || undefined,
49
+ operation: el.dataset.operation,
50
+ sequence: el.dataset.sequence,
51
+ example: el.dataset.example,
52
+ tab: el.dataset.tab === 'docs' ? 'docs' : undefined,
53
+ }
54
+ el.append(play)
55
+ }
56
+
57
+ const scan = (root: ParentNode) =>
58
+ root.querySelectorAll<HTMLElement>('[data-specbook]:not([data-specbook-ready])').forEach(attach)
59
+
60
+ scan(document)
61
+ // SPA docs add content after load
62
+ new MutationObserver(() => scan(document)).observe(document.documentElement, {
63
+ childList: true,
64
+ subtree: true,
65
+ })
package/src/engine.ts ADDED
@@ -0,0 +1,574 @@
1
+ // Run orchestration for the UI. Pure TS (no DOM) so it can be unit-tested with a fake registry.
2
+ import {
3
+ type AuthProfile,
4
+ createFetchTransport,
5
+ createMockTransport,
6
+ type DiscoveredEndpoints,
7
+ deepMerge,
8
+ type Example,
9
+ installFaker,
10
+ isDiscovery,
11
+ type OpMeta,
12
+ parseDiscovery,
13
+ prepareFaker,
14
+ type RequestDef,
15
+ type RunEvent,
16
+ type RunResult,
17
+ runRequest,
18
+ runSequence,
19
+ type Sequence,
20
+ type SpecbookConfig,
21
+ type Transport,
22
+ usesFaker,
23
+ } from '@specbook/core'
24
+ import { type ExtensionBridge, extensionTransport } from './extension'
25
+ import { absoluteBase, routingTransport, withPageSecrets } from './routing'
26
+ import { originOf, type SecretStore, secretStore } from './secrets'
27
+
28
+ /**
29
+ * The reserved service name a sign-in's token exchange is addressed to. The dev server's proxy answers
30
+ * `auth:<profile>` from the profile's own `oauth.tokenUrl` (the same prefix lives in `@specbook/vite`).
31
+ */
32
+ const AUTH_SERVICE_PREFIX = 'auth:'
33
+ const authService = (profile: string) => `${AUTH_SERVICE_PREFIX}${encodeURIComponent(profile)}`
34
+ /** Likewise for an OpenID Connect discovery document, whose host may differ from the token endpoint's. */
35
+ const DISCOVERY_SERVICE_PREFIX = 'discovery:'
36
+ const discoveryService = (profile: string) =>
37
+ `${DISCOVERY_SERVICE_PREFIX}${encodeURIComponent(profile)}`
38
+
39
+ export interface ExampleEntry {
40
+ name: string
41
+ source: 'generated' | 'project'
42
+ /** Folder key; upload paths in the example are relative to it. */
43
+ dir: string
44
+ load: (v: number) => Promise<{ default: unknown }>
45
+ }
46
+
47
+ export interface DocEntry {
48
+ file: string
49
+ operation?: string
50
+ sequence?: string
51
+ title?: string
52
+ load: () => Promise<string>
53
+ }
54
+
55
+ export interface Registry {
56
+ manifests: Record<string, { operations: { id: string; statuses: number[] }[] }>
57
+ /** response examples: `service/Op.<status|default>` */
58
+ examples: Record<string, (v: number) => Promise<{ default: unknown }>>
59
+ /** request examples by `service/Op` */
60
+ requestExamples: Record<string, ExampleEntry[]>
61
+ /** files examples may upload: `<dir>/<name>` -> URL */
62
+ assets: Record<string, () => Promise<string>>
63
+ docs: DocEntry[]
64
+ operations: Record<string, (v: number) => Promise<Record<string, any>>>
65
+ loadConfig: (v: number) => Promise<{ default: any }>
66
+ /** Project's @faker-js/faker (ESM), when installed. */
67
+ loadFaker?: ((v: number) => Promise<Record<string, any>>) | null
68
+ mode?: 'dev' | 'static'
69
+ /** variables from `.specbook/environments/<env>.env`, baked in at build time */
70
+ environmentVars?: Record<string, Record<string, string>>
71
+ }
72
+
73
+ export interface RunInput {
74
+ env: string
75
+ version: number
76
+ /** Forced mock status per run (mock env only). */
77
+ mockStatus?: number
78
+ params?: Record<string, unknown>
79
+ vars?: Record<string, unknown>
80
+ onEvent?: (e: RunEvent) => void
81
+ onInput?: Parameters<typeof runSequence>[1]['onInput']
82
+ signal?: AbortSignal
83
+ proxyBase?: string
84
+ locale?: string
85
+ seed?: number
86
+ /** Folder upload paths are relative to when the request was not built from a named example. */
87
+ fileDir?: string
88
+ }
89
+
90
+ export interface EngineOptions {
91
+ /** The bridge to the browser extension when it is installed and enabled for this page. */
92
+ extension?: () => Promise<ExtensionBridge | undefined>
93
+ /** Where the person's typed secrets are kept (the page's store by default). */
94
+ secrets?: SecretStore
95
+ }
96
+
97
+ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
98
+ const mockLoader = {
99
+ statuses: (service: string, opId: string) =>
100
+ reg.manifests[service]?.operations.find((o) => o.id === opId)?.statuses ?? [],
101
+ async example(service: string, opId: string, status: number) {
102
+ const get = async (s: number | 'default') =>
103
+ (await reg.examples[`${service}/${opId}.${s}`]?.(0))?.default
104
+ return (await get(status)) ?? (await get('default'))
105
+ },
106
+ }
107
+
108
+ async function config(version: number): Promise<SpecbookConfig> {
109
+ return (await reg.loadConfig(version)).default as SpecbookConfig
110
+ }
111
+
112
+ /** Environments the config marks `production` or `sandbox`; the rest are judged by their name. */
113
+ async function environmentRisks(
114
+ version: number,
115
+ ): Promise<Record<string, 'production' | 'sandbox'>> {
116
+ const c = await config(version)
117
+ const out: Record<string, 'production' | 'sandbox'> = {}
118
+ for (const [name, e] of Object.entries(c.environments ?? {})) {
119
+ const risk = (e as { risk?: string } | undefined)?.risk
120
+ if (risk === 'production' || risk === 'sandbox') out[name] = risk
121
+ }
122
+ return out
123
+ }
124
+
125
+ /** One line per environment that has a description, for the pickers' tooltips. */
126
+ async function environmentNotes(version: number): Promise<Record<string, string>> {
127
+ const c = await config(version)
128
+ const out: Record<string, string> = {}
129
+ for (const [name, e] of Object.entries(c.environments ?? {})) {
130
+ const note = (e as { description?: string } | undefined)?.description
131
+ if (note) out[name] = note
132
+ }
133
+ return out
134
+ }
135
+
136
+ async function environments(version: number): Promise<string[]> {
137
+ const c = await config(version)
138
+ const envs = new Set<string>(['mock'])
139
+ for (const s of Object.values(c.services ?? {}))
140
+ for (const e of Object.keys(s.baseUrl ?? {})) envs.add(e)
141
+ for (const e of Object.keys(reg.environmentVars ?? {})) envs.add(e)
142
+ for (const e of Object.keys(c.environments ?? {})) envs.add(e)
143
+ return [...envs]
144
+ }
145
+
146
+ /** The environment's own variables: read live from the dev server, baked in for static builds. */
147
+ async function environmentVars(env: string): Promise<Record<string, string>> {
148
+ if (reg.mode === 'dev') {
149
+ try {
150
+ const r = await fetch(`/__specbook/environment?env=${encodeURIComponent(env)}`)
151
+ if (r.ok) return ((await r.json()) as { vars: Record<string, string> }).vars
152
+ } catch {
153
+ /* fall back to what was baked in */
154
+ }
155
+ }
156
+ return reg.environmentVars?.[env] ?? {}
157
+ }
158
+
159
+ /** Names of the variables the project already supplies in `env`: config, per-environment and env files. */
160
+ async function knownVars(env: string, version: number): Promise<string[]> {
161
+ const c = await config(version)
162
+ return [
163
+ ...new Set([
164
+ ...Object.keys(c.vars ?? {}),
165
+ ...Object.keys(c.environments?.[env]?.vars ?? {}),
166
+ ...Object.keys(await environmentVars(env)),
167
+ ]),
168
+ ]
169
+ }
170
+
171
+ /**
172
+ * One transport per run, routing each request by its service (`routeFor`). Typed values are bound to
173
+ * the API's origin first; placeholders left over are filled by the dev server or reported as missing.
174
+ */
175
+ async function transportFor(
176
+ env: string,
177
+ proxyBase: string,
178
+ version: number,
179
+ /** extra services by name -> base URL: a sign-in's token endpoint, which is not a configured service */
180
+ extra: Record<string, string> = {},
181
+ ): Promise<Transport> {
182
+ if (env === 'mock') return createMockTransport({ loader: mockLoader })
183
+ const cfg = await config(version)
184
+ const pageHref = globalThis.location?.href
185
+ const pageOrigin = globalThis.location?.origin
186
+ const baseOf = (service: string) => {
187
+ if (extra[service]) return extra[service]
188
+ const url = cfg.services?.[service]?.baseUrl?.[env]
189
+ if (!url) throw new Error(`No base URL for ${service} in the "${env}" environment`)
190
+ return absoluteBase(url, pageHref)
191
+ }
192
+ const hasProxy = reg.mode !== 'static'
193
+ const bridge = await opts.extension?.()
194
+ const ext = bridge ? extensionTransport(bridge, safeBase) : undefined
195
+ const store = opts.secrets ?? secretStore
196
+
197
+ const direct = createFetchTransport({ baseUrl: baseOf })
198
+ const viaProxy = hasProxy
199
+ ? createFetchTransport({ baseUrl: (service) => `${proxyBase}/${env}/${service}` })
200
+ : undefined
201
+ const routed = routingTransport({
202
+ direct: guardCors(withPageSecrets(direct, { store, originFor, unresolved: 'fail' })),
203
+ proxy: viaProxy
204
+ ? withPageSecrets(viaProxy, { store, originFor, unresolved: 'keep' })
205
+ : undefined,
206
+ extension: ext ? withPageSecrets(ext, { store, originFor, unresolved: 'keep' }) : undefined,
207
+ baseOf: safeBase,
208
+ pageOrigin,
209
+ })
210
+ return routed
211
+
212
+ function originFor(service: string) {
213
+ return originOf(safeBase(service))
214
+ }
215
+ // routing must not throw for a service with no base URL here: the proxy answers that with a 404
216
+ function safeBase(service: string) {
217
+ try {
218
+ return baseOf(service)
219
+ } catch {
220
+ return ''
221
+ }
222
+ }
223
+ function guardCors(inner: Transport): Transport {
224
+ return async (req, signal) => {
225
+ try {
226
+ return await inner(req, signal)
227
+ } catch (e) {
228
+ // a blocked cross-origin call surfaces as a bare TypeError("Failed to fetch")
229
+ if (
230
+ e instanceof TypeError &&
231
+ (req.service.startsWith(AUTH_SERVICE_PREFIX) ||
232
+ req.service.startsWith(DISCOVERY_SERVICE_PREFIX))
233
+ )
234
+ throw new Error(
235
+ `The browser could not reach the sign-in provider (${extra[req.service] ?? 'unknown'}). It probably does not allow calls from this page (CORS). Embedding the viewer on the API's origin does not help when the sign-in server is on another host; the browser extension can make the call. You can still paste an access token.`,
236
+ )
237
+ if (e instanceof TypeError)
238
+ throw new Error(
239
+ `The browser could not reach ${req.service} in "${env}". The API probably does not allow calls from this page (CORS). It works when the viewer runs on the API's own origin: embed it in the API's docs route ("Embed the viewer" in the README). Mock data keeps working.`,
240
+ )
241
+ throw e
242
+ }
243
+ }
244
+ }
245
+ }
246
+
247
+ /** Only the dev server knows its environment files; a hosted proxy would report its own (`managedSecrets`). */
248
+ async function managedSecrets(env: string): Promise<Record<string, string>> {
249
+ if (reg.mode !== 'dev' || env === 'mock') return {}
250
+ try {
251
+ const r = await fetch(`/__specbook/environment?env=${encodeURIComponent(env)}`)
252
+ if (!r.ok) return {}
253
+ const body = (await r.json()) as { secrets?: { name: string; source?: string }[] }
254
+ return Object.fromEntries(
255
+ (body.secrets ?? []).filter((s) => s.source).map((s) => [s.name, s.source as string]),
256
+ )
257
+ } catch {
258
+ return {}
259
+ }
260
+ }
261
+
262
+ type SignInEndpoints = DiscoveredEndpoints & { scopes: Record<string, string> }
263
+ const discovered = new Map<string, Promise<DiscoveredEndpoints>>()
264
+
265
+ /**
266
+ * Where a profile signs in: the endpoints its config writes out, or the ones its OpenID Connect
267
+ * discovery document names. The document is fetched the way any other call from the page is (through
268
+ * the dev server's proxy when there is one) and remembered for the life of the page.
269
+ */
270
+ async function oauthEndpoints(i: {
271
+ env: string
272
+ version: number
273
+ profile: string
274
+ proxyBase?: string
275
+ signal?: AbortSignal
276
+ }): Promise<SignInEndpoints> {
277
+ if (i.env === 'mock') throw new Error('Sign in needs a live environment, not mock')
278
+ const oauth = ((await config(i.version)).auth?.[i.profile] as AuthProfile | undefined)?.oauth
279
+ if (!oauth) throw new Error(`"${i.profile}" has no sign-in configured`)
280
+ if (!isDiscovery(oauth))
281
+ return {
282
+ authorizationUrl: oauth.authorizationUrl,
283
+ tokenUrl: oauth.tokenUrl,
284
+ scopes: oauth.scopes ?? {},
285
+ }
286
+
287
+ const url = new URL(oauth.openIdConnectUrl)
288
+ let found = discovered.get(url.href)
289
+ if (!found) {
290
+ found = (async () => {
291
+ const service = discoveryService(i.profile)
292
+ const send = await transportFor(i.env, i.proxyBase ?? '/__specbook/proxy', i.version, {
293
+ [service]: url.origin,
294
+ })
295
+ const res = await send(
296
+ {
297
+ service,
298
+ opId: 'oidc-discovery',
299
+ method: 'GET',
300
+ url: url.pathname,
301
+ query: Object.fromEntries(url.searchParams),
302
+ headers: { accept: 'application/json' },
303
+ encoding: 'none',
304
+ },
305
+ i.signal,
306
+ )
307
+ if (res.status >= 400)
308
+ throw new Error(`The provider's discovery document answered ${res.status}`)
309
+ return parseDiscovery(res.body)
310
+ })()
311
+ discovered.set(url.href, found)
312
+ // a failure is not remembered: the next click tries again
313
+ found.catch(() => discovered.delete(url.href))
314
+ }
315
+ const endpoints = await found
316
+ return {
317
+ ...endpoints,
318
+ scopes: oauth.scopes && Object.keys(oauth.scopes).length ? oauth.scopes : endpoints.scopes,
319
+ }
320
+ }
321
+
322
+ /**
323
+ * A sign-in's code exchange, sent the way any other call from this page is: through the dev server's
324
+ * proxy when there is one, else direct or through the extension. The token endpoint comes from the
325
+ * profile in the config (or its discovery document), never from the caller, so the proxy only ever
326
+ * forwards to the project's own.
327
+ */
328
+ async function oauthExchange(i: {
329
+ env: string
330
+ version: number
331
+ profile: string
332
+ fields: Record<string, string>
333
+ proxyBase?: string
334
+ signal?: AbortSignal
335
+ }): Promise<unknown> {
336
+ const { tokenUrl } = await oauthEndpoints(i)
337
+ const token = new URL(tokenUrl)
338
+ const service = authService(i.profile)
339
+ const send = await transportFor(i.env, i.proxyBase ?? '/__specbook/proxy', i.version, {
340
+ [service]: token.origin,
341
+ })
342
+ const res = await send(
343
+ {
344
+ service,
345
+ opId: 'oauth-token',
346
+ method: 'POST',
347
+ url: token.pathname,
348
+ query: Object.fromEntries(token.searchParams),
349
+ headers: { accept: 'application/json' },
350
+ body: i.fields,
351
+ encoding: 'form',
352
+ },
353
+ i.signal,
354
+ )
355
+ const body = res.body as { error?: unknown } | undefined
356
+ if (res.status >= 400 && typeof body?.error !== 'string')
357
+ throw new Error(`The token endpoint answered ${res.status}`)
358
+ return res.body
359
+ }
360
+
361
+ const norm = (p: string) => p.replace(/^\.\//, '')
362
+
363
+ /** Display order: the author's examples first, generated ones last. */
364
+ const examplesOf = (key: string): ExampleEntry[] => reg.requestExamples[key] ?? []
365
+
366
+ async function loadExample(
367
+ key: string,
368
+ name: string,
369
+ version: number,
370
+ ): Promise<Example | undefined> {
371
+ const entry = examplesOf(key).find((e) => e.name === name)
372
+ if (!entry) return undefined
373
+ const doc = (await entry.load(version)).default as Example
374
+ return { ...doc, source: entry.source, dir: entry.dir }
375
+ }
376
+
377
+ async function readFile(path: string, dir?: string): Promise<File> {
378
+ const url = await reg.assets[`${dir}/${norm(path)}`]?.()
379
+ if (!url) throw new Error(`File "${path}" not found next to the example`)
380
+ const res = await fetch(url)
381
+ if (!res.ok) throw new Error(`Could not read "${path}" (${res.status})`)
382
+ return new File([await res.blob()], norm(path).split('/').pop() as string, {
383
+ type: res.headers.get('content-type') ?? undefined,
384
+ })
385
+ }
386
+
387
+ async function baseOptions(i: RunInput) {
388
+ const c = await config(i.version)
389
+ const auth = (c.auth ?? {}) as Record<string, AuthProfile>
390
+ const serviceAuth = Object.fromEntries(
391
+ Object.entries(c.services ?? {})
392
+ .filter(([, s]) => s.auth)
393
+ .map(([n, s]) => [n, s.auth as string]),
394
+ )
395
+ return {
396
+ env: i.env,
397
+ vars: {
398
+ ...(c.vars ?? {}),
399
+ ...(c.environments?.[i.env]?.vars ?? {}),
400
+ ...(await environmentVars(i.env)),
401
+ },
402
+ overrides: i.vars,
403
+ params: i.params,
404
+ transport: await transportFor(i.env, i.proxyBase ?? '/__specbook/proxy', i.version),
405
+ // placeholders stay in the request: the dev server's proxy or a static page's transport fills them
406
+ deferSecrets: true,
407
+ auth,
408
+ serviceAuth,
409
+ onEvent: i.onEvent,
410
+ onInput: i.onInput,
411
+ signal: i.signal,
412
+ example: (op: { service: string; id: string }, name: string) =>
413
+ loadExample(`${op.service}/${op.id}`, name, i.version),
414
+ readFile: (path: string, dir?: string) => readFile(path, dir ?? i.fileDir),
415
+ }
416
+ }
417
+
418
+ /** Sequences and requests with init generators or lazy data may call faker; everything else is visible. */
419
+ async function mayUseFaker(
420
+ target: RequestDef<any, any, any> | Sequence<any, any>,
421
+ i: RunInput,
422
+ ): Promise<boolean> {
423
+ if (target.kind === 'sequence') return true
424
+ const { init, data, example, path, query, headers } = target.input
425
+ if (
426
+ Object.values(init ?? {}).some((v) => typeof v === 'function') ||
427
+ typeof data === 'function'
428
+ )
429
+ return true
430
+ const base = example
431
+ ? await loadExample(`${target.op.service}/${target.op.id}`, example, i.version)
432
+ : undefined
433
+ return usesFaker([base, data, target.dataPatches, path, query, headers, i.vars])
434
+ }
435
+
436
+ async function run(
437
+ target: RequestDef<any, any, any> | Sequence<any, any>,
438
+ i: RunInput,
439
+ ): Promise<RunResult> {
440
+ // `init` generators call faker, so it must be ready before any run unless we can see it isn't used.
441
+ if (reg.loadFaker && (await mayUseFaker(target, i))) {
442
+ const [mod, cfg] = await Promise.all([reg.loadFaker(i.version), config(i.version)])
443
+ // a bundled module offers each locale as its own chunk; fetch only the ones this run falls back through
444
+ const ready = await prepareFaker(mod, cfg.faker, i.locale)
445
+ installFaker(ready as never, cfg.faker, { locale: i.locale, seed: i.seed })
446
+ }
447
+ const o = await baseOptions(i)
448
+ if (target.kind === 'sequence') return runSequence(target, o)
449
+ const def =
450
+ i.mockStatus !== undefined && i.env === 'mock'
451
+ ? target.with({ mock: { status: i.mockStatus } })
452
+ : target
453
+ return runRequest(def, o)
454
+ }
455
+
456
+ /** `base` keeps its init/capture/assert/mock; `overrides` replace path/query/headers/data. */
457
+ async function operationRequest(
458
+ key: string,
459
+ version: number,
460
+ overrides: { path?: object; query?: object; headers?: Record<string, string>; data?: unknown },
461
+ base?: RequestDef<any, any, any>,
462
+ ): Promise<RequestDef<any, any, any>> {
463
+ const load = reg.operations[key]
464
+ if (!load) throw new Error(`Unknown operation ${key}`)
465
+ const mod = await load(version)
466
+ const op = mod[key.split('/')[1] as string]
467
+ if (!op) throw new Error(`Operation module for ${key} has no export`)
468
+ const defined = Object.fromEntries(Object.entries(overrides).filter(([, v]) => v !== undefined))
469
+ return op.request({ ...(base?.input ?? {}), ...defined })
470
+ }
471
+
472
+ async function operationMeta(key: string, version: number): Promise<OpMeta | undefined> {
473
+ const mod = await reg.operations[key]?.(version)
474
+ return mod?.[key.split('/')[1] as string]?.meta
475
+ }
476
+
477
+ /** What a saved request sends, as an example: its example (if any) with its own fields merged over. */
478
+ async function savedExample(def: RequestDef<any, any, any>, version: number): Promise<Example> {
479
+ const key = `${def.op.service}/${def.op.id}`
480
+ const base = def.input.example ? await loadExample(key, def.input.example, version) : undefined
481
+ let data: unknown = def.input.data
482
+ if (typeof data === 'function')
483
+ data = (await (data as () => Promise<{ default: unknown }>)()).default
484
+ let body = deepMerge<unknown>(base?.body, data)
485
+ for (const patch of def.dataPatches) body = deepMerge(body, patch)
486
+ return {
487
+ name: def.input.name ?? def.op.id,
488
+ source: 'project',
489
+ dir: base?.dir,
490
+ path: deepMerge(base?.path ?? {}, (def.input.path ?? {}) as Record<string, unknown>),
491
+ query: deepMerge(base?.query ?? {}, (def.input.query ?? {}) as Record<string, unknown>),
492
+ headers: { ...(base?.headers ?? {}), ...(def.input.headers ?? {}) },
493
+ body,
494
+ }
495
+ }
496
+
497
+ async function loadVariants(
498
+ entries: { file: string; load: (v: number) => Promise<Record<string, any>> }[],
499
+ version: number,
500
+ ): Promise<Record<string, Variant[]>> {
501
+ const out: Record<string, Variant[]> = {}
502
+ await Promise.all(
503
+ entries.map(async (e) => {
504
+ try {
505
+ const def = pickRunnable(await e.load(version))
506
+ if (def?.kind !== 'request') return
507
+ const key = `${def.op.service}/${def.op.id}`
508
+ const name = def.input.name ?? e.file.replace(/^.*\//, '').replace(/\.req\.ts$/, '')
509
+ out[key] ??= []
510
+ out[key]?.push({ file: e.file, name, def })
511
+ } catch {
512
+ /* a broken request file must not break the list */
513
+ }
514
+ }),
515
+ )
516
+ for (const list of Object.values(out)) list.sort((a, b) => a.name.localeCompare(b.name))
517
+ return out
518
+ }
519
+
520
+ async function loadSequences(
521
+ entries: { file: string; load: (v: number) => Promise<Record<string, any>> }[],
522
+ version: number,
523
+ ): Promise<SequenceInfo[]> {
524
+ const list: SequenceInfo[] = []
525
+ await Promise.all(
526
+ entries.map(async (e) => {
527
+ try {
528
+ const seq = pickRunnable(await e.load(version))
529
+ if (seq?.kind === 'sequence') list.push({ file: e.file, seq })
530
+ } catch {
531
+ /* ignore broken files */
532
+ }
533
+ }),
534
+ )
535
+ return list.sort((a, b) => a.seq.meta.title.localeCompare(b.seq.meta.title))
536
+ }
537
+
538
+ return {
539
+ config,
540
+ environments,
541
+ environmentRisks,
542
+ environmentNotes,
543
+ managedSecrets,
544
+ oauthEndpoints,
545
+ oauthExchange,
546
+ run,
547
+ operationRequest,
548
+ operationMeta,
549
+ examplesOf,
550
+ knownVars,
551
+ loadExample,
552
+ savedExample,
553
+ loadVariants,
554
+ loadSequences,
555
+ }
556
+ }
557
+
558
+ export interface Variant {
559
+ file: string
560
+ name: string
561
+ def: RequestDef<any, any, any>
562
+ }
563
+
564
+ export interface SequenceInfo {
565
+ file: string
566
+ seq: Sequence<any, any>
567
+ }
568
+
569
+ /** Default export, else the first runnable named export. */
570
+ export function pickRunnable(mod: Record<string, any>) {
571
+ const isRun = (v: any) => v && (v.kind === 'request' || v.kind === 'sequence')
572
+ if (isRun(mod.default)) return mod.default
573
+ return Object.values(mod).find(isRun)
574
+ }