@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.
- package/LICENSE +21 -0
- package/README.md +6 -2
- package/dist-viewer/apple-touch-icon.png +0 -0
- package/dist-viewer/assets/Host-DdTTHeXU.js +37 -0
- package/dist-viewer/assets/faker-B3c03STC.js +11 -0
- package/dist-viewer/assets/index-DnYdlDXQ.js +1 -0
- package/dist-viewer/assets/index-Ds6DZpGP.css +1 -0
- package/dist-viewer/assets/main-uV4JzQPd.js +14 -0
- package/dist-viewer/assets/mount-B-qFOcWp.js +2 -0
- package/dist-viewer/assets/panel-CoQ6iQpX.js +1 -0
- package/dist-viewer/assets/panel-Coi9H1nv.css +1 -0
- package/dist-viewer/assets/registry-DI74Wtj8.js +2 -0
- package/dist-viewer/assets/worker-Dw3Ps3hk.js +181 -0
- package/dist-viewer/favicon.svg +1 -0
- package/dist-viewer/index.html +19 -0
- package/dist-viewer/specbook.js +1 -0
- package/index.html +12 -0
- package/package.json +60 -3
- package/src/app.tsx +363 -0
- package/src/assets/mark.svg +1 -0
- package/src/badge.ts +14 -0
- package/src/branding.ts +92 -0
- package/src/components/Catalog.tsx +324 -0
- package/src/components/EnvBadge.tsx +46 -0
- package/src/components/EnvironmentMenu.tsx +262 -0
- package/src/components/Fields.tsx +220 -0
- package/src/components/GitHubStar.tsx +22 -0
- package/src/components/ListResizer.tsx +70 -0
- package/src/components/ModeHint.tsx +17 -0
- package/src/components/Palette.tsx +85 -0
- package/src/components/PoweredBy.tsx +15 -0
- package/src/components/ShareMenu.tsx +94 -0
- package/src/components/ThemeToggle.tsx +52 -0
- package/src/embed/panel.tsx +114 -0
- package/src/embed/styles.ts +27 -0
- package/src/embed.ts +65 -0
- package/src/engine.ts +432 -0
- package/src/extension.ts +101 -0
- package/src/fielddoc.ts +83 -0
- package/src/main.tsx +16 -0
- package/src/markdown.ts +133 -0
- package/src/nav.ts +118 -0
- package/src/panel/Body.tsx +182 -0
- package/src/panel/BodyEditor.tsx +92 -0
- package/src/panel/CodeSample.tsx +237 -0
- package/src/panel/Combo.tsx +124 -0
- package/src/panel/ControlsGrid.tsx +189 -0
- package/src/panel/FieldDoc.tsx +42 -0
- package/src/panel/FieldTable.tsx +113 -0
- package/src/panel/Host.tsx +100 -0
- package/src/panel/OperationDocs.tsx +181 -0
- package/src/panel/Panel.tsx +193 -0
- package/src/panel/ParamGrid.tsx +396 -0
- package/src/panel/RequestPanel.tsx +625 -0
- package/src/panel/RequestView.tsx +108 -0
- package/src/panel/ResponseView.tsx +182 -0
- package/src/panel/SecretsBar.tsx +156 -0
- package/src/panel/SequenceDocs.tsx +132 -0
- package/src/panel/SequencePanel.tsx +247 -0
- package/src/panel/StepExchange.tsx +118 -0
- package/src/panel/completions.ts +114 -0
- package/src/panel/draft.ts +106 -0
- package/src/panel/fieldnotes-split.ts +55 -0
- package/src/panel/fieldnotes.ts +40 -0
- package/src/panel/graphql.ts +31 -0
- package/src/panel/icons.tsx +85 -0
- package/src/panel/jsonCursor.ts +115 -0
- package/src/panel/stepnotes-split.ts +38 -0
- package/src/panel/stepnotes.ts +58 -0
- package/src/routing.ts +120 -0
- package/src/runtime.ts +32 -0
- package/src/schema.ts +359 -0
- package/src/secrets.ts +61 -0
- package/src/status.ts +27 -0
- package/src/store.ts +10 -0
- package/src/styles/app.css +821 -0
- package/src/styles/panel.css +1897 -0
- package/src/styles/tokens.css +122 -0
- package/src/theme.ts +37 -0
- package/src/viewer/Opening.tsx +43 -0
- package/src/viewer/bundles.ts +74 -0
- package/src/viewer/client.ts +54 -0
- package/src/viewer/embed.ts +78 -0
- package/src/viewer/faker-en.ts +3 -0
- package/src/viewer/main.tsx +285 -0
- package/src/viewer/mount.ts +52 -0
- package/src/viewer/protocol.ts +51 -0
- package/src/viewer/recent.ts +22 -0
- package/src/viewer/registry-data.ts +4 -0
- package/src/viewer/registry.ts +154 -0
- package/src/viewer/share.ts +134 -0
- package/src/viewer/site.ts +3 -0
- package/src/viewer/uploads.ts +53 -0
- package/src/viewer/viewer.css +184 -0
- package/src/viewer/worker-factory.ts +14 -0
- package/src/viewer/worker.ts +171 -0
- 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
|
+
}
|
package/src/extension.ts
ADDED
|
@@ -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
|
+
}
|