@specbook/ui 0.0.0-stage → 0.1.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 (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -2
  3. package/dist-viewer/apple-touch-icon.png +0 -0
  4. package/dist-viewer/assets/Host-DdTTHeXU.js +37 -0
  5. package/dist-viewer/assets/faker-B3c03STC.js +11 -0
  6. package/dist-viewer/assets/index-DnYdlDXQ.js +1 -0
  7. package/dist-viewer/assets/index-Ds6DZpGP.css +1 -0
  8. package/dist-viewer/assets/main-uV4JzQPd.js +14 -0
  9. package/dist-viewer/assets/mount-B-qFOcWp.js +2 -0
  10. package/dist-viewer/assets/panel-CoQ6iQpX.js +1 -0
  11. package/dist-viewer/assets/panel-Coi9H1nv.css +1 -0
  12. package/dist-viewer/assets/registry-DI74Wtj8.js +2 -0
  13. package/dist-viewer/assets/worker-Dw3Ps3hk.js +181 -0
  14. package/dist-viewer/favicon.svg +1 -0
  15. package/dist-viewer/index.html +19 -0
  16. package/dist-viewer/specbook.js +1 -0
  17. package/index.html +12 -0
  18. package/package.json +60 -3
  19. package/src/app.tsx +363 -0
  20. package/src/assets/mark.svg +1 -0
  21. package/src/badge.ts +14 -0
  22. package/src/branding.ts +92 -0
  23. package/src/components/Catalog.tsx +324 -0
  24. package/src/components/EnvBadge.tsx +46 -0
  25. package/src/components/EnvironmentMenu.tsx +262 -0
  26. package/src/components/Fields.tsx +220 -0
  27. package/src/components/GitHubStar.tsx +22 -0
  28. package/src/components/ListResizer.tsx +70 -0
  29. package/src/components/ModeHint.tsx +17 -0
  30. package/src/components/Palette.tsx +85 -0
  31. package/src/components/PoweredBy.tsx +15 -0
  32. package/src/components/ShareMenu.tsx +94 -0
  33. package/src/components/ThemeToggle.tsx +52 -0
  34. package/src/embed/panel.tsx +114 -0
  35. package/src/embed/styles.ts +27 -0
  36. package/src/embed.ts +65 -0
  37. package/src/engine.ts +432 -0
  38. package/src/extension.ts +101 -0
  39. package/src/fielddoc.ts +83 -0
  40. package/src/main.tsx +16 -0
  41. package/src/markdown.ts +133 -0
  42. package/src/nav.ts +118 -0
  43. package/src/panel/Body.tsx +182 -0
  44. package/src/panel/BodyEditor.tsx +92 -0
  45. package/src/panel/CodeSample.tsx +237 -0
  46. package/src/panel/Combo.tsx +124 -0
  47. package/src/panel/ControlsGrid.tsx +189 -0
  48. package/src/panel/FieldDoc.tsx +42 -0
  49. package/src/panel/FieldTable.tsx +113 -0
  50. package/src/panel/Host.tsx +100 -0
  51. package/src/panel/OperationDocs.tsx +181 -0
  52. package/src/panel/Panel.tsx +193 -0
  53. package/src/panel/ParamGrid.tsx +396 -0
  54. package/src/panel/RequestPanel.tsx +625 -0
  55. package/src/panel/RequestView.tsx +108 -0
  56. package/src/panel/ResponseView.tsx +182 -0
  57. package/src/panel/SecretsBar.tsx +156 -0
  58. package/src/panel/SequenceDocs.tsx +132 -0
  59. package/src/panel/SequencePanel.tsx +247 -0
  60. package/src/panel/StepExchange.tsx +118 -0
  61. package/src/panel/completions.ts +114 -0
  62. package/src/panel/draft.ts +106 -0
  63. package/src/panel/fieldnotes-split.ts +55 -0
  64. package/src/panel/fieldnotes.ts +40 -0
  65. package/src/panel/graphql.ts +31 -0
  66. package/src/panel/icons.tsx +85 -0
  67. package/src/panel/jsonCursor.ts +115 -0
  68. package/src/panel/stepnotes-split.ts +38 -0
  69. package/src/panel/stepnotes.ts +58 -0
  70. package/src/routing.ts +120 -0
  71. package/src/runtime.ts +32 -0
  72. package/src/schema.ts +359 -0
  73. package/src/secrets.ts +61 -0
  74. package/src/status.ts +27 -0
  75. package/src/store.ts +10 -0
  76. package/src/styles/app.css +821 -0
  77. package/src/styles/panel.css +1897 -0
  78. package/src/styles/tokens.css +122 -0
  79. package/src/theme.ts +37 -0
  80. package/src/viewer/Opening.tsx +43 -0
  81. package/src/viewer/bundles.ts +74 -0
  82. package/src/viewer/client.ts +54 -0
  83. package/src/viewer/embed.ts +78 -0
  84. package/src/viewer/faker-en.ts +3 -0
  85. package/src/viewer/main.tsx +285 -0
  86. package/src/viewer/mount.ts +52 -0
  87. package/src/viewer/protocol.ts +51 -0
  88. package/src/viewer/recent.ts +22 -0
  89. package/src/viewer/registry-data.ts +4 -0
  90. package/src/viewer/registry.ts +154 -0
  91. package/src/viewer/share.ts +134 -0
  92. package/src/viewer/site.ts +3 -0
  93. package/src/viewer/uploads.ts +53 -0
  94. package/src/viewer/viewer.css +184 -0
  95. package/src/viewer/worker-factory.ts +14 -0
  96. package/src/viewer/worker.ts +171 -0
  97. 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,432 @@
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
+ deepMerge,
7
+ type Example,
8
+ installFaker,
9
+ type OpMeta,
10
+ prepareFaker,
11
+ type RequestDef,
12
+ type RunEvent,
13
+ type RunResult,
14
+ runRequest,
15
+ runSequence,
16
+ type Sequence,
17
+ type SpecbookConfig,
18
+ type Transport,
19
+ usesFaker,
20
+ } from '@specbook/core'
21
+ import { type ExtensionBridge, extensionTransport } from './extension'
22
+ import { absoluteBase, routingTransport, withPageSecrets } from './routing'
23
+ import { originOf, type SecretStore, secretStore } from './secrets'
24
+
25
+ export interface ExampleEntry {
26
+ name: string
27
+ source: 'generated' | 'project'
28
+ /** Folder key; upload paths in the example are relative to it. */
29
+ dir: string
30
+ load: (v: number) => Promise<{ default: unknown }>
31
+ }
32
+
33
+ export interface DocEntry {
34
+ file: string
35
+ operation?: string
36
+ sequence?: string
37
+ title?: string
38
+ load: () => Promise<string>
39
+ }
40
+
41
+ export interface Registry {
42
+ manifests: Record<string, { operations: { id: string; statuses: number[] }[] }>
43
+ /** response examples: `service/Op.<status|default>` */
44
+ examples: Record<string, (v: number) => Promise<{ default: unknown }>>
45
+ /** request examples by `service/Op` */
46
+ requestExamples: Record<string, ExampleEntry[]>
47
+ /** files examples may upload: `<dir>/<name>` -> URL */
48
+ assets: Record<string, () => Promise<string>>
49
+ docs: DocEntry[]
50
+ operations: Record<string, (v: number) => Promise<Record<string, any>>>
51
+ loadConfig: (v: number) => Promise<{ default: any }>
52
+ /** Project's @faker-js/faker (ESM), when installed. */
53
+ loadFaker?: ((v: number) => Promise<Record<string, any>>) | null
54
+ mode?: 'dev' | 'static'
55
+ /** variables from `.specbook/environments/<env>.env`, baked in at build time */
56
+ environmentVars?: Record<string, Record<string, string>>
57
+ }
58
+
59
+ export interface RunInput {
60
+ env: string
61
+ version: number
62
+ /** Forced mock status per run (mock env only). */
63
+ mockStatus?: number
64
+ params?: Record<string, unknown>
65
+ vars?: Record<string, unknown>
66
+ onEvent?: (e: RunEvent) => void
67
+ onInput?: Parameters<typeof runSequence>[1]['onInput']
68
+ signal?: AbortSignal
69
+ proxyBase?: string
70
+ locale?: string
71
+ seed?: number
72
+ /** Folder upload paths are relative to when the request was not built from a named example. */
73
+ fileDir?: string
74
+ }
75
+
76
+ export interface EngineOptions {
77
+ /** The bridge to the browser extension when it is installed and enabled for this page. */
78
+ extension?: () => Promise<ExtensionBridge | undefined>
79
+ /** Where the person's typed secrets are kept (the page's store by default). */
80
+ secrets?: SecretStore
81
+ }
82
+
83
+ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
84
+ const mockLoader = {
85
+ statuses: (service: string, opId: string) =>
86
+ reg.manifests[service]?.operations.find((o) => o.id === opId)?.statuses ?? [],
87
+ async example(service: string, opId: string, status: number) {
88
+ const get = async (s: number | 'default') =>
89
+ (await reg.examples[`${service}/${opId}.${s}`]?.(0))?.default
90
+ return (await get(status)) ?? (await get('default'))
91
+ },
92
+ }
93
+
94
+ async function config(version: number): Promise<SpecbookConfig> {
95
+ return (await reg.loadConfig(version)).default as SpecbookConfig
96
+ }
97
+
98
+ /** Environments the config marks `production` or `sandbox`; the rest are judged by their name. */
99
+ async function environmentRisks(
100
+ version: number,
101
+ ): Promise<Record<string, 'production' | 'sandbox'>> {
102
+ const c = await config(version)
103
+ const out: Record<string, 'production' | 'sandbox'> = {}
104
+ for (const [name, e] of Object.entries(c.environments ?? {})) {
105
+ const risk = (e as { risk?: string } | undefined)?.risk
106
+ if (risk === 'production' || risk === 'sandbox') out[name] = risk
107
+ }
108
+ return out
109
+ }
110
+
111
+ async function environments(version: number): Promise<string[]> {
112
+ const c = await config(version)
113
+ const envs = new Set<string>(['mock'])
114
+ for (const s of Object.values(c.services ?? {}))
115
+ for (const e of Object.keys(s.baseUrl ?? {})) envs.add(e)
116
+ for (const e of Object.keys(reg.environmentVars ?? {})) envs.add(e)
117
+ for (const e of Object.keys(c.environments ?? {})) envs.add(e)
118
+ return [...envs]
119
+ }
120
+
121
+ /** The environment's own variables: read live from the dev server, baked in for static builds. */
122
+ async function environmentVars(env: string): Promise<Record<string, string>> {
123
+ if (reg.mode === 'dev') {
124
+ try {
125
+ const r = await fetch(`/__specbook/environment?env=${encodeURIComponent(env)}`)
126
+ if (r.ok) return ((await r.json()) as { vars: Record<string, string> }).vars
127
+ } catch {
128
+ /* fall back to what was baked in */
129
+ }
130
+ }
131
+ return reg.environmentVars?.[env] ?? {}
132
+ }
133
+
134
+ /** Names of the variables the project already supplies in `env`: config, per-environment and env files. */
135
+ async function knownVars(env: string, version: number): Promise<string[]> {
136
+ const c = await config(version)
137
+ return [
138
+ ...new Set([
139
+ ...Object.keys(c.vars ?? {}),
140
+ ...Object.keys(c.environments?.[env]?.vars ?? {}),
141
+ ...Object.keys(await environmentVars(env)),
142
+ ]),
143
+ ]
144
+ }
145
+
146
+ /**
147
+ * One transport per run, routing each request by its service (`routeFor`). Typed values are bound to
148
+ * the API's origin first; placeholders left over are filled by the dev server or reported as missing.
149
+ */
150
+ async function transportFor(env: string, proxyBase: string, version: number): Promise<Transport> {
151
+ if (env === 'mock') return createMockTransport({ loader: mockLoader })
152
+ const cfg = await config(version)
153
+ const pageHref = globalThis.location?.href
154
+ const pageOrigin = globalThis.location?.origin
155
+ const baseOf = (service: string) => {
156
+ const url = cfg.services?.[service]?.baseUrl?.[env]
157
+ if (!url) throw new Error(`No base URL for ${service} in the "${env}" environment`)
158
+ return absoluteBase(url, pageHref)
159
+ }
160
+ const hasProxy = reg.mode !== 'static'
161
+ const bridge = await opts.extension?.()
162
+ const ext = bridge ? extensionTransport(bridge, safeBase) : undefined
163
+ const store = opts.secrets ?? secretStore
164
+
165
+ const direct = createFetchTransport({ baseUrl: baseOf })
166
+ const viaProxy = hasProxy
167
+ ? createFetchTransport({ baseUrl: (service) => `${proxyBase}/${env}/${service}` })
168
+ : undefined
169
+ const routed = routingTransport({
170
+ direct: guardCors(withPageSecrets(direct, { store, originFor, unresolved: 'fail' })),
171
+ proxy: viaProxy
172
+ ? withPageSecrets(viaProxy, { store, originFor, unresolved: 'keep' })
173
+ : undefined,
174
+ extension: ext ? withPageSecrets(ext, { store, originFor, unresolved: 'keep' }) : undefined,
175
+ baseOf: safeBase,
176
+ pageOrigin,
177
+ })
178
+ return routed
179
+
180
+ function originFor(service: string) {
181
+ return originOf(safeBase(service))
182
+ }
183
+ // routing must not throw for a service with no base URL here: the proxy answers that with a 404
184
+ function safeBase(service: string) {
185
+ try {
186
+ return baseOf(service)
187
+ } catch {
188
+ return ''
189
+ }
190
+ }
191
+ function guardCors(inner: Transport): Transport {
192
+ return async (req, signal) => {
193
+ try {
194
+ return await inner(req, signal)
195
+ } catch (e) {
196
+ // a blocked cross-origin call surfaces as a bare TypeError("Failed to fetch")
197
+ if (e instanceof TypeError)
198
+ throw new Error(
199
+ `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.`,
200
+ )
201
+ throw e
202
+ }
203
+ }
204
+ }
205
+ }
206
+
207
+ /** Only the dev server knows its environment files; a hosted proxy would report its own (`managedSecrets`). */
208
+ async function managedSecrets(env: string): Promise<Record<string, string>> {
209
+ if (reg.mode !== 'dev' || env === 'mock') return {}
210
+ try {
211
+ const r = await fetch(`/__specbook/environment?env=${encodeURIComponent(env)}`)
212
+ if (!r.ok) return {}
213
+ const body = (await r.json()) as { secrets?: { name: string; source?: string }[] }
214
+ return Object.fromEntries(
215
+ (body.secrets ?? []).filter((s) => s.source).map((s) => [s.name, s.source as string]),
216
+ )
217
+ } catch {
218
+ return {}
219
+ }
220
+ }
221
+
222
+ const norm = (p: string) => p.replace(/^\.\//, '')
223
+
224
+ /** Display order: the author's examples first, generated ones last. */
225
+ const examplesOf = (key: string): ExampleEntry[] => reg.requestExamples[key] ?? []
226
+
227
+ async function loadExample(
228
+ key: string,
229
+ name: string,
230
+ version: number,
231
+ ): Promise<Example | undefined> {
232
+ const entry = examplesOf(key).find((e) => e.name === name)
233
+ if (!entry) return undefined
234
+ const doc = (await entry.load(version)).default as Example
235
+ return { ...doc, source: entry.source, dir: entry.dir }
236
+ }
237
+
238
+ async function readFile(path: string, dir?: string): Promise<File> {
239
+ const url = await reg.assets[`${dir}/${norm(path)}`]?.()
240
+ if (!url) throw new Error(`File "${path}" not found next to the example`)
241
+ const res = await fetch(url)
242
+ if (!res.ok) throw new Error(`Could not read "${path}" (${res.status})`)
243
+ return new File([await res.blob()], norm(path).split('/').pop() as string, {
244
+ type: res.headers.get('content-type') ?? undefined,
245
+ })
246
+ }
247
+
248
+ async function baseOptions(i: RunInput) {
249
+ const c = await config(i.version)
250
+ const auth = (c.auth ?? {}) as Record<string, AuthProfile>
251
+ const serviceAuth = Object.fromEntries(
252
+ Object.entries(c.services ?? {})
253
+ .filter(([, s]) => s.auth)
254
+ .map(([n, s]) => [n, s.auth as string]),
255
+ )
256
+ return {
257
+ env: i.env,
258
+ vars: {
259
+ ...(c.vars ?? {}),
260
+ ...(c.environments?.[i.env]?.vars ?? {}),
261
+ ...(await environmentVars(i.env)),
262
+ },
263
+ overrides: i.vars,
264
+ params: i.params,
265
+ transport: await transportFor(i.env, i.proxyBase ?? '/__specbook/proxy', i.version),
266
+ // placeholders stay in the request: the dev server's proxy or a static page's transport fills them
267
+ deferSecrets: true,
268
+ auth,
269
+ serviceAuth,
270
+ onEvent: i.onEvent,
271
+ onInput: i.onInput,
272
+ signal: i.signal,
273
+ example: (op: { service: string; id: string }, name: string) =>
274
+ loadExample(`${op.service}/${op.id}`, name, i.version),
275
+ readFile: (path: string, dir?: string) => readFile(path, dir ?? i.fileDir),
276
+ }
277
+ }
278
+
279
+ /** Sequences and requests with init generators or lazy data may call faker; everything else is visible. */
280
+ async function mayUseFaker(
281
+ target: RequestDef<any, any, any> | Sequence<any, any>,
282
+ i: RunInput,
283
+ ): Promise<boolean> {
284
+ if (target.kind === 'sequence') return true
285
+ const { init, data, example, path, query, headers } = target.input
286
+ if (
287
+ Object.values(init ?? {}).some((v) => typeof v === 'function') ||
288
+ typeof data === 'function'
289
+ )
290
+ return true
291
+ const base = example
292
+ ? await loadExample(`${target.op.service}/${target.op.id}`, example, i.version)
293
+ : undefined
294
+ return usesFaker([base, data, target.dataPatches, path, query, headers, i.vars])
295
+ }
296
+
297
+ async function run(
298
+ target: RequestDef<any, any, any> | Sequence<any, any>,
299
+ i: RunInput,
300
+ ): Promise<RunResult> {
301
+ // `init` generators call faker, so it must be ready before any run unless we can see it isn't used.
302
+ if (reg.loadFaker && (await mayUseFaker(target, i))) {
303
+ const [mod, cfg] = await Promise.all([reg.loadFaker(i.version), config(i.version)])
304
+ // a bundled module offers each locale as its own chunk; fetch only the ones this run falls back through
305
+ const ready = await prepareFaker(mod, cfg.faker, i.locale)
306
+ installFaker(ready as never, cfg.faker, { locale: i.locale, seed: i.seed })
307
+ }
308
+ const o = await baseOptions(i)
309
+ if (target.kind === 'sequence') return runSequence(target, o)
310
+ const def =
311
+ i.mockStatus !== undefined && i.env === 'mock'
312
+ ? target.with({ mock: { status: i.mockStatus } })
313
+ : target
314
+ return runRequest(def, o)
315
+ }
316
+
317
+ /** `base` keeps its init/capture/assert/mock; `overrides` replace path/query/headers/data. */
318
+ async function operationRequest(
319
+ key: string,
320
+ version: number,
321
+ overrides: { path?: object; query?: object; headers?: Record<string, string>; data?: unknown },
322
+ base?: RequestDef<any, any, any>,
323
+ ): Promise<RequestDef<any, any, any>> {
324
+ const load = reg.operations[key]
325
+ if (!load) throw new Error(`Unknown operation ${key}`)
326
+ const mod = await load(version)
327
+ const op = mod[key.split('/')[1] as string]
328
+ if (!op) throw new Error(`Operation module for ${key} has no export`)
329
+ const defined = Object.fromEntries(Object.entries(overrides).filter(([, v]) => v !== undefined))
330
+ return op.request({ ...(base?.input ?? {}), ...defined })
331
+ }
332
+
333
+ async function operationMeta(key: string, version: number): Promise<OpMeta | undefined> {
334
+ const mod = await reg.operations[key]?.(version)
335
+ return mod?.[key.split('/')[1] as string]?.meta
336
+ }
337
+
338
+ /** What a saved request sends, as an example: its example (if any) with its own fields merged over. */
339
+ async function savedExample(def: RequestDef<any, any, any>, version: number): Promise<Example> {
340
+ const key = `${def.op.service}/${def.op.id}`
341
+ const base = def.input.example ? await loadExample(key, def.input.example, version) : undefined
342
+ let data: unknown = def.input.data
343
+ if (typeof data === 'function')
344
+ data = (await (data as () => Promise<{ default: unknown }>)()).default
345
+ let body = deepMerge<unknown>(base?.body, data)
346
+ for (const patch of def.dataPatches) body = deepMerge(body, patch)
347
+ return {
348
+ name: def.input.name ?? def.op.id,
349
+ source: 'project',
350
+ dir: base?.dir,
351
+ path: deepMerge(base?.path ?? {}, (def.input.path ?? {}) as Record<string, unknown>),
352
+ query: deepMerge(base?.query ?? {}, (def.input.query ?? {}) as Record<string, unknown>),
353
+ headers: { ...(base?.headers ?? {}), ...(def.input.headers ?? {}) },
354
+ body,
355
+ }
356
+ }
357
+
358
+ async function loadVariants(
359
+ entries: { file: string; load: (v: number) => Promise<Record<string, any>> }[],
360
+ version: number,
361
+ ): Promise<Record<string, Variant[]>> {
362
+ const out: Record<string, Variant[]> = {}
363
+ await Promise.all(
364
+ entries.map(async (e) => {
365
+ try {
366
+ const def = pickRunnable(await e.load(version))
367
+ if (def?.kind !== 'request') return
368
+ const key = `${def.op.service}/${def.op.id}`
369
+ const name = def.input.name ?? e.file.replace(/^.*\//, '').replace(/\.req\.ts$/, '')
370
+ out[key] ??= []
371
+ out[key]?.push({ file: e.file, name, def })
372
+ } catch {
373
+ /* a broken request file must not break the list */
374
+ }
375
+ }),
376
+ )
377
+ for (const list of Object.values(out)) list.sort((a, b) => a.name.localeCompare(b.name))
378
+ return out
379
+ }
380
+
381
+ async function loadSequences(
382
+ entries: { file: string; load: (v: number) => Promise<Record<string, any>> }[],
383
+ version: number,
384
+ ): Promise<SequenceInfo[]> {
385
+ const list: SequenceInfo[] = []
386
+ await Promise.all(
387
+ entries.map(async (e) => {
388
+ try {
389
+ const seq = pickRunnable(await e.load(version))
390
+ if (seq?.kind === 'sequence') list.push({ file: e.file, seq })
391
+ } catch {
392
+ /* ignore broken files */
393
+ }
394
+ }),
395
+ )
396
+ return list.sort((a, b) => a.seq.meta.title.localeCompare(b.seq.meta.title))
397
+ }
398
+
399
+ return {
400
+ config,
401
+ environments,
402
+ environmentRisks,
403
+ managedSecrets,
404
+ run,
405
+ operationRequest,
406
+ operationMeta,
407
+ examplesOf,
408
+ knownVars,
409
+ loadExample,
410
+ savedExample,
411
+ loadVariants,
412
+ loadSequences,
413
+ }
414
+ }
415
+
416
+ export interface Variant {
417
+ file: string
418
+ name: string
419
+ def: RequestDef<any, any, any>
420
+ }
421
+
422
+ export interface SequenceInfo {
423
+ file: string
424
+ seq: Sequence<any, any>
425
+ }
426
+
427
+ /** Default export, else the first runnable named export. */
428
+ export function pickRunnable(mod: Record<string, any>) {
429
+ const isRun = (v: any) => v && (v.kind === 'request' || v.kind === 'sequence')
430
+ if (isRun(mod.default)) return mod.default
431
+ return Object.values(mod).find(isRun)
432
+ }
@@ -0,0 +1,101 @@
1
+ import {
2
+ createFetchTransport,
3
+ requestToWire,
4
+ type Transport,
5
+ WIRE_VERSION,
6
+ type WireCall,
7
+ type WireReply,
8
+ wireToResponse,
9
+ } from '@specbook/core'
10
+
11
+ /**
12
+ * The page's side of the browser extension. The extension (its own package, not built yet) makes the
13
+ * outbound call from its service worker with the user's grant, using `handleWireFetch` from core, so the
14
+ * policy and secret substitution are the ones the dev server and a hosted proxy run.
15
+ *
16
+ * A bridge is anything that can carry a `WireCall` to it and bring a `WireReply` back: a content script
17
+ * relaying `postMessage`, or a test double.
18
+ */
19
+ export interface ExtensionBridge {
20
+ /** Protocol version the extension speaks. */
21
+ version: number
22
+ send(call: WireCall, signal?: AbortSignal): Promise<WireReply>
23
+ }
24
+
25
+ declare global {
26
+ // a content script (or a test) may place its bridge here
27
+ var __SPECBOOK_EXTENSION__: ExtensionBridge | undefined
28
+ }
29
+
30
+ const CHANNEL = 'specbook'
31
+
32
+ /** `postMessage` to a content script that answers on the same window. */
33
+ function windowBridge(version: number): ExtensionBridge {
34
+ return {
35
+ version,
36
+ send(call, signal) {
37
+ return new Promise<WireReply>((resolve, reject) => {
38
+ const id = (call as { id?: string }).id
39
+ const on = (e: MessageEvent) => {
40
+ const m = e.data as { channel?: string; reply?: WireReply } | undefined
41
+ if (e.source !== window || m?.channel !== `${CHANNEL}:reply` || !m.reply) return
42
+ const r = m.reply as { id?: string; type: string }
43
+ if (id !== undefined && r.id !== id) return
44
+ cleanup()
45
+ resolve(m.reply)
46
+ }
47
+ const abort = () => {
48
+ cleanup()
49
+ window.postMessage({ channel: `${CHANNEL}:call`, call: { v: 1, type: 'abort', id } }, '*')
50
+ reject(new DOMException('aborted', 'AbortError'))
51
+ }
52
+ const cleanup = () => {
53
+ window.removeEventListener('message', on)
54
+ signal?.removeEventListener('abort', abort)
55
+ }
56
+ window.addEventListener('message', on)
57
+ signal?.addEventListener('abort', abort)
58
+ window.postMessage({ channel: `${CHANNEL}:call`, call }, '*')
59
+ })
60
+ },
61
+ }
62
+ }
63
+
64
+ /** The bridge when an extension is installed and enabled for this page, within a short wait. */
65
+ export async function detectExtension(waitMs = 250): Promise<ExtensionBridge | undefined> {
66
+ const injected = globalThis.__SPECBOOK_EXTENSION__
67
+ if (injected) return injected
68
+ if (typeof window === 'undefined') return undefined
69
+ const bridge = windowBridge(WIRE_VERSION)
70
+ const timeout = new Promise<undefined>((r) => setTimeout(() => r(undefined), waitMs))
71
+ const pong = await Promise.race([
72
+ bridge.send({ v: 1, type: 'ping' }).catch(() => undefined),
73
+ timeout,
74
+ ])
75
+ return pong?.type === 'pong' ? { ...bridge, version: pong.version } : undefined
76
+ }
77
+
78
+ let counter = 0
79
+
80
+ /**
81
+ * A `Transport` that sends each request to the extension. Built on the ordinary fetch transport with a
82
+ * `fetch` that carries the request over the bridge, so bodies, query strings and response parsing are
83
+ * the same as for a direct call.
84
+ */
85
+ export function extensionTransport(
86
+ bridge: ExtensionBridge,
87
+ baseOf: (service: string) => string,
88
+ ): Transport {
89
+ const carry: typeof fetch = async (input, init) => {
90
+ const req = new Request(input as RequestInfo, init)
91
+ const id = `c${Date.now().toString(36)}${counter++}`
92
+ const reply = await bridge.send(
93
+ { v: 1, type: 'fetch', id, req: await requestToWire(req) },
94
+ init?.signal ?? undefined,
95
+ )
96
+ if (reply.type === 'error') throw new Error(reply.message)
97
+ if (reply.type !== 'response') throw new Error('unexpected reply from the extension')
98
+ return wireToResponse(reply.res)
99
+ }
100
+ return createFetchTransport({ baseUrl: baseOf, fetch: carry })
101
+ }