@specbook/ui 0.2.0 → 0.3.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/dist-viewer/assets/Host-Cq-4gDuM.js +41 -0
- package/dist-viewer/assets/index-BBlFcQUE.js +1 -0
- package/dist-viewer/assets/main-CSkB-O_8.js +14 -0
- package/dist-viewer/assets/{mount-CGL2tOpN.js → mount-DfwluJy4.js} +2 -2
- package/dist-viewer/assets/{panel-C8Ve3LeS.js → panel-9eYU7Gz6.js} +1 -1
- package/dist-viewer/assets/panel-hNOojwiM.css +1 -0
- package/dist-viewer/assets/registry-DIHX11uY.js +2 -0
- package/dist-viewer/assets/worker-BmvGpsJS.js +181 -0
- package/dist-viewer/index.html +4 -4
- package/dist-viewer/specbook.js +1 -1
- package/package.json +6 -5
- package/src/app.tsx +65 -12
- package/src/branding.ts +12 -4
- package/src/components/Catalog.tsx +128 -51
- package/src/components/EnvBadge.tsx +21 -3
- package/src/components/EnvironmentMenu.tsx +237 -127
- package/src/components/Fields.tsx +18 -1
- package/src/components/NewEnvironmentDialog.tsx +118 -0
- package/src/components/Palette.tsx +7 -2
- package/src/custom-environments.ts +63 -0
- package/src/embed/panel.tsx +286 -44
- package/src/embed/styles.ts +2 -0
- package/src/embed/target.ts +82 -0
- package/src/embed/theme.ts +75 -0
- package/src/embed.ts +44 -15
- package/src/engine.ts +274 -32
- package/src/environment-list.ts +16 -0
- package/src/main.tsx +7 -2
- package/src/nav.ts +12 -1
- package/src/oauth.ts +9 -8
- package/src/panel/Body.tsx +27 -4
- package/src/panel/Checks.tsx +79 -0
- package/src/panel/CodeSample.tsx +16 -9
- package/src/panel/Combo.tsx +52 -13
- package/src/panel/ControlsGrid.tsx +54 -9
- package/src/panel/ErrorBoundary.tsx +36 -0
- package/src/panel/Host.tsx +14 -2
- package/src/panel/OAuthSignIn.tsx +6 -2
- package/src/panel/Panel.tsx +43 -10
- package/src/panel/ParamGrid.tsx +15 -8
- package/src/panel/RequestPanel.tsx +91 -36
- package/src/panel/RequestView.tsx +2 -1
- package/src/panel/ResponseView.tsx +25 -14
- package/src/panel/SecretsBar.tsx +10 -9
- package/src/panel/SequenceDocs.tsx +7 -6
- package/src/panel/SequencePanel.tsx +124 -32
- package/src/panel/announce.ts +17 -0
- package/src/panel/icons.tsx +9 -0
- package/src/panel/short.ts +24 -0
- package/src/routing.ts +0 -9
- package/src/runtime.ts +19 -1
- package/src/secrets.ts +4 -2
- package/src/storage.ts +58 -0
- package/src/store.ts +8 -0
- package/src/styles/app.css +69 -6
- package/src/styles/embed.css +116 -0
- package/src/styles/panel.css +233 -0
- package/src/viewer/manifests.ts +2 -0
- package/src/viewer/protocol.ts +6 -0
- package/src/viewer/registry.ts +46 -10
- package/src/viewer/worker.ts +9 -1
- package/src/virtual.d.ts +8 -0
- package/dist-viewer/assets/Host-CSrM9wY1.js +0 -37
- package/dist-viewer/assets/index-ez9c20v3.js +0 -1
- package/dist-viewer/assets/main-38QGGpNT.js +0 -14
- package/dist-viewer/assets/panel-C-vBZrep.css +0 -1
- package/dist-viewer/assets/registry-ChKCFBt1.js +0 -2
- package/dist-viewer/assets/worker-ClGb0tE1.js +0 -181
package/src/embed.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { parseTarget, type Target } from './embed/target'
|
|
2
|
+
import { followTheme } from './embed/theme'
|
|
2
3
|
import tokens from './styles/tokens.css?inline'
|
|
3
4
|
|
|
4
5
|
const css = `button {
|
|
@@ -12,14 +13,27 @@ const css = `button {
|
|
|
12
13
|
color: var(--sb-accent);
|
|
13
14
|
}
|
|
14
15
|
button:hover { background: var(--sb-surface-raised); }
|
|
15
|
-
button:focus-visible { outline: 2px solid var(--sb-focus-ring); }
|
|
16
|
+
button:focus-visible { outline: 2px solid var(--sb-focus-ring); }
|
|
17
|
+
@media (pointer: coarse) { button { min-height: 44px; min-width: 44px; } }`
|
|
18
|
+
|
|
19
|
+
let opened = false
|
|
16
20
|
|
|
17
21
|
/** The panel (editor, grammars, runner) is a separate chunk fetched on first click. */
|
|
18
|
-
async function open(target: Target) {
|
|
22
|
+
export async function open(target: Target, trigger?: HTMLElement): Promise<void> {
|
|
19
23
|
const { show } = await import('./embed/panel')
|
|
20
|
-
|
|
24
|
+
opened = true
|
|
25
|
+
show(target, trigger)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Closes the panel, if it is open. */
|
|
29
|
+
export async function close(): Promise<void> {
|
|
30
|
+
if (!opened) return
|
|
31
|
+
const { close: closePanel } = await import('./embed/panel')
|
|
32
|
+
closePanel()
|
|
21
33
|
}
|
|
22
34
|
|
|
35
|
+
const nameOf = (t: Target) => t.sequence ?? t.request ?? t.operation ?? ''
|
|
36
|
+
|
|
23
37
|
class SpecbookPlay extends HTMLElement {
|
|
24
38
|
target: Target = {}
|
|
25
39
|
|
|
@@ -33,29 +47,44 @@ class SpecbookPlay extends HTMLElement {
|
|
|
33
47
|
b.type = 'button'
|
|
34
48
|
b.part.add('play')
|
|
35
49
|
b.textContent = '▶ Play'
|
|
36
|
-
b.
|
|
50
|
+
b.setAttribute('aria-haspopup', 'dialog')
|
|
51
|
+
b.setAttribute('aria-expanded', 'false')
|
|
52
|
+
b.setAttribute('aria-label', `Play ${nameOf(this.target)}`.trim())
|
|
53
|
+
b.addEventListener('click', () => open(this.target, this))
|
|
37
54
|
root.append(b)
|
|
55
|
+
followTheme(this, this.target.theme)
|
|
38
56
|
}
|
|
39
57
|
}
|
|
40
58
|
|
|
41
59
|
customElements.define('specbook-play', SpecbookPlay)
|
|
42
60
|
|
|
61
|
+
/** A button or a link that carries the attribute is the trigger itself; anything else gets a Play button beside it. */
|
|
62
|
+
const isTrigger = (el: HTMLElement) => el.matches('button, a[href], [role="button"]')
|
|
63
|
+
|
|
43
64
|
function attach(el: HTMLElement) {
|
|
44
|
-
if (el.dataset.specbookReady) return
|
|
65
|
+
if (el.dataset.specbookReady !== undefined) return
|
|
45
66
|
el.dataset.specbookReady = ''
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
67
|
+
const target = parseTarget(el)
|
|
68
|
+
if (!target) return
|
|
69
|
+
if (isTrigger(el)) {
|
|
70
|
+
el.setAttribute('aria-haspopup', 'dialog')
|
|
71
|
+
if (!el.hasAttribute('aria-expanded')) el.setAttribute('aria-expanded', 'false')
|
|
72
|
+
el.addEventListener('click', (e) => {
|
|
73
|
+
e.preventDefault()
|
|
74
|
+
open(target, el)
|
|
75
|
+
})
|
|
76
|
+
return
|
|
53
77
|
}
|
|
78
|
+
const play = document.createElement('specbook-play') as SpecbookPlay
|
|
79
|
+
play.target = target
|
|
54
80
|
el.append(play)
|
|
55
81
|
}
|
|
56
82
|
|
|
57
|
-
const
|
|
58
|
-
|
|
83
|
+
const SELECTOR = ['try', 'flow']
|
|
84
|
+
.map((a) => `[data-specbook-${a}]:not([data-specbook-ready])`)
|
|
85
|
+
.join(',')
|
|
86
|
+
|
|
87
|
+
const scan = (root: ParentNode) => root.querySelectorAll<HTMLElement>(SELECTOR).forEach(attach)
|
|
59
88
|
|
|
60
89
|
scan(document)
|
|
61
90
|
// SPA docs add content after load
|
package/src/engine.ts
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
// Run orchestration for the UI. Pure TS (no DOM) so it can be unit-tested with a fake registry.
|
|
2
2
|
import {
|
|
3
|
+
AddressError,
|
|
3
4
|
type AuthProfile,
|
|
5
|
+
absoluteBase,
|
|
6
|
+
addressUses,
|
|
7
|
+
baseUrlFor,
|
|
4
8
|
createFetchTransport,
|
|
5
9
|
createMockTransport,
|
|
6
10
|
type DiscoveredEndpoints,
|
|
7
11
|
deepMerge,
|
|
8
12
|
type Example,
|
|
13
|
+
environmentNames,
|
|
14
|
+
environmentScope,
|
|
9
15
|
installFaker,
|
|
10
16
|
isDiscovery,
|
|
17
|
+
type OAuthSettings,
|
|
11
18
|
type OpMeta,
|
|
19
|
+
oauthFor,
|
|
12
20
|
parseDiscovery,
|
|
13
21
|
prepareFaker,
|
|
14
22
|
type RequestDef,
|
|
@@ -20,9 +28,11 @@ import {
|
|
|
20
28
|
type SpecbookConfig,
|
|
21
29
|
type Transport,
|
|
22
30
|
usesFaker,
|
|
31
|
+
withMock,
|
|
23
32
|
} from '@specbook/core'
|
|
33
|
+
import type { EnvironmentStore } from './custom-environments'
|
|
24
34
|
import { type ExtensionBridge, extensionTransport } from './extension'
|
|
25
|
-
import {
|
|
35
|
+
import { routingTransport, withPageSecrets } from './routing'
|
|
26
36
|
import { originOf, type SecretStore, secretStore } from './secrets'
|
|
27
37
|
|
|
28
38
|
/**
|
|
@@ -75,7 +85,7 @@ export interface RunInput {
|
|
|
75
85
|
version: number
|
|
76
86
|
/** Forced mock status per run (mock env only). */
|
|
77
87
|
mockStatus?: number
|
|
78
|
-
|
|
88
|
+
inputs?: Record<string, unknown>
|
|
79
89
|
vars?: Record<string, unknown>
|
|
80
90
|
onEvent?: (e: RunEvent) => void
|
|
81
91
|
onInput?: Parameters<typeof runSequence>[1]['onInput']
|
|
@@ -87,13 +97,46 @@ export interface RunInput {
|
|
|
87
97
|
fileDir?: string
|
|
88
98
|
}
|
|
89
99
|
|
|
100
|
+
/** One variable of an environment as the settings show it. */
|
|
101
|
+
export interface VariableRow {
|
|
102
|
+
name: string
|
|
103
|
+
value: string
|
|
104
|
+
/** where the value comes from: the config's defaults or the environment's section, a file, this browser */
|
|
105
|
+
source: 'default' | 'config' | 'file' | 'browser'
|
|
106
|
+
/** what the variable falls back to when its own value is removed; undefined when it has no other */
|
|
107
|
+
fallback?: string
|
|
108
|
+
/** whether the value can be edited here */
|
|
109
|
+
editable: boolean
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface EnvironmentView {
|
|
113
|
+
rows: VariableRow[]
|
|
114
|
+
/** where each service's calls go in this environment, or why they cannot */
|
|
115
|
+
addresses: { service: string; url?: string; error?: string }[]
|
|
116
|
+
/** which services' addresses and profiles' sign-in fields read each variable */
|
|
117
|
+
uses: ReturnType<typeof addressUses>
|
|
118
|
+
/** a browser-made environment: it can be deleted */
|
|
119
|
+
removable: boolean
|
|
120
|
+
}
|
|
121
|
+
|
|
90
122
|
export interface EngineOptions {
|
|
91
123
|
/** The bridge to the browser extension when it is installed and enabled for this page. */
|
|
92
124
|
extension?: () => Promise<ExtensionBridge | undefined>
|
|
93
125
|
/** Where the person's typed secrets are kept (the page's store by default). */
|
|
94
126
|
secrets?: SecretStore
|
|
127
|
+
/** Environments made in this browser (a static page has no files to write them to). */
|
|
128
|
+
environmentStore?: EnvironmentStore
|
|
95
129
|
}
|
|
96
130
|
|
|
131
|
+
const ENV_NAME = /^[A-Za-z0-9_-]+$/
|
|
132
|
+
const VAR_NAME = /^[A-Za-z_][\w.-]*$/
|
|
133
|
+
const hasTemplate = (v: unknown): boolean =>
|
|
134
|
+
typeof v === 'string'
|
|
135
|
+
? /\{\{/.test(v)
|
|
136
|
+
: v !== null && typeof v === 'object' && Object.values(v).some(hasTemplate)
|
|
137
|
+
const text = (v: unknown) =>
|
|
138
|
+
v !== null && typeof v === 'object' ? JSON.stringify(v) : String(v ?? '')
|
|
139
|
+
|
|
97
140
|
export function createEngine(reg: Registry, opts: EngineOptions = {}) {
|
|
98
141
|
const mockLoader = {
|
|
99
142
|
statuses: (service: string, opId: string) =>
|
|
@@ -109,6 +152,11 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
|
|
|
109
152
|
return (await reg.loadConfig(version)).default as SpecbookConfig
|
|
110
153
|
}
|
|
111
154
|
|
|
155
|
+
const isStaticPage = () => reg.mode !== 'dev'
|
|
156
|
+
const browserEnvs = () => (isStaticPage() ? (opts.environmentStore?.all() ?? {}) : {})
|
|
157
|
+
// dev: environments created this session, until the registry reloads with their files
|
|
158
|
+
const createdHere = new Set<string>()
|
|
159
|
+
|
|
112
160
|
/** Environments the config marks `production` or `sandbox`; the rest are judged by their name. */
|
|
113
161
|
async function environmentRisks(
|
|
114
162
|
version: number,
|
|
@@ -119,6 +167,9 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
|
|
|
119
167
|
const risk = (e as { risk?: string } | undefined)?.risk
|
|
120
168
|
if (risk === 'production' || risk === 'sandbox') out[name] = risk
|
|
121
169
|
}
|
|
170
|
+
// a copy is as careful as what it was copied from
|
|
171
|
+
for (const [name, b] of Object.entries(browserEnvs()))
|
|
172
|
+
if (b.from && out[b.from] && !out[name]) out[name] = out[b.from] as 'production' | 'sandbox'
|
|
122
173
|
return out
|
|
123
174
|
}
|
|
124
175
|
|
|
@@ -130,20 +181,24 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
|
|
|
130
181
|
const note = (e as { description?: string } | undefined)?.description
|
|
131
182
|
if (note) out[name] = note
|
|
132
183
|
}
|
|
184
|
+
for (const [name, b] of Object.entries(browserEnvs()))
|
|
185
|
+
if (b.from) out[name] = `copy of ${b.from}, kept in this browser`
|
|
133
186
|
return out
|
|
134
187
|
}
|
|
135
188
|
|
|
136
189
|
async function environments(version: number): Promise<string[]> {
|
|
137
190
|
const c = await config(version)
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
return [...envs]
|
|
191
|
+
return environmentNames(
|
|
192
|
+
{ services: c.services ?? {}, environments: c.environments },
|
|
193
|
+
[...Object.keys(reg.environmentVars ?? {}), ...createdHere],
|
|
194
|
+
Object.keys(browserEnvs()),
|
|
195
|
+
)
|
|
144
196
|
}
|
|
145
197
|
|
|
146
|
-
/**
|
|
198
|
+
/**
|
|
199
|
+
* The environment's own variables: read live from the dev server; baked in for a static build, with
|
|
200
|
+
* the ones this browser holds over them.
|
|
201
|
+
*/
|
|
147
202
|
async function environmentVars(env: string): Promise<Record<string, string>> {
|
|
148
203
|
if (reg.mode === 'dev') {
|
|
149
204
|
try {
|
|
@@ -153,7 +208,166 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
|
|
|
153
208
|
/* fall back to what was baked in */
|
|
154
209
|
}
|
|
155
210
|
}
|
|
156
|
-
return reg.environmentVars?.[env]
|
|
211
|
+
return { ...reg.environmentVars?.[env], ...browserEnvs()[env]?.vars }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** What an address is rendered with: defaults, the environment's own settings, its file or browser copy. */
|
|
215
|
+
async function scopeOf(env: string, version: number): Promise<Record<string, unknown>> {
|
|
216
|
+
return environmentScope(await config(version), env, await environmentVars(env))
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Where `service`'s calls go in `env`; undefined when it has no address there. Throws an AddressError. */
|
|
220
|
+
async function baseUrlOf(service: string, env: string, version: number) {
|
|
221
|
+
return baseUrlFor(await config(version), service, env, await scopeOf(env, version))
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** A profile's sign-in settings with its address fields rendered for `env`. */
|
|
225
|
+
async function oauthOf(
|
|
226
|
+
profile: string,
|
|
227
|
+
env: string,
|
|
228
|
+
version: number,
|
|
229
|
+
): Promise<OAuthSettings | undefined> {
|
|
230
|
+
const oauth = ((await config(version)).auth?.[profile] as AuthProfile | undefined)?.oauth
|
|
231
|
+
// nothing to render, so nothing to ask the dev server for
|
|
232
|
+
if (!oauth || !hasTemplate(oauth)) return oauth
|
|
233
|
+
return oauthFor(oauth, await scopeOf(env, version), profile)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Addresses for a config the caller already holds (a page reading `engine.config` once for several
|
|
238
|
+
* services): the same rendering as `baseUrlOf` / `oauthOf`, with an address that cannot be worked out
|
|
239
|
+
* left undefined. The environment's variables are fetched only when something is written as a template.
|
|
240
|
+
*/
|
|
241
|
+
async function addressesIn(cfg: SpecbookConfig, env: string) {
|
|
242
|
+
const templated =
|
|
243
|
+
Object.values(cfg.services ?? {}).some((s) => hasTemplate(s.baseUrl)) ||
|
|
244
|
+
Object.values(cfg.auth ?? {}).some((p) => hasTemplate((p as AuthProfile).oauth))
|
|
245
|
+
const scope = environmentScope(cfg, env, templated ? await environmentVars(env) : {})
|
|
246
|
+
return {
|
|
247
|
+
baseUrl(service: string): string | undefined {
|
|
248
|
+
try {
|
|
249
|
+
return baseUrlFor(cfg, service, env, scope)
|
|
250
|
+
} catch {
|
|
251
|
+
return undefined
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
oauth(profile: string): OAuthSettings | undefined {
|
|
255
|
+
const oauth = (cfg.auth?.[profile] as AuthProfile | undefined)?.oauth
|
|
256
|
+
try {
|
|
257
|
+
return oauth ? oauthFor(oauth, scope, profile) : undefined
|
|
258
|
+
} catch {
|
|
259
|
+
return undefined
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The environment's variables as the settings show them, and where each service's calls go. */
|
|
266
|
+
async function environmentView(env: string, version: number): Promise<EnvironmentView> {
|
|
267
|
+
const c = await config(version)
|
|
268
|
+
const defaults = c.vars ?? {}
|
|
269
|
+
const section =
|
|
270
|
+
(c.environments?.[env] as { vars?: Record<string, unknown> } | undefined)?.vars ?? {}
|
|
271
|
+
const stored = browserEnvs()[env]
|
|
272
|
+
const baked = isStaticPage() ? (reg.environmentVars?.[env] ?? {}) : await environmentVars(env)
|
|
273
|
+
const browser = stored?.vars ?? {}
|
|
274
|
+
const names = [
|
|
275
|
+
...new Set([
|
|
276
|
+
...Object.keys(defaults),
|
|
277
|
+
...Object.keys(section),
|
|
278
|
+
...Object.keys(baked),
|
|
279
|
+
...Object.keys(browser),
|
|
280
|
+
]),
|
|
281
|
+
]
|
|
282
|
+
const editable = reg.mode === 'dev' || !!opts.environmentStore
|
|
283
|
+
const rows: VariableRow[] = names.map((name) => {
|
|
284
|
+
const inherited = name in section ? section[name] : defaults[name]
|
|
285
|
+
const own = name in browser ? 'browser' : name in baked ? 'file' : undefined
|
|
286
|
+
const value = name in browser ? browser[name] : name in baked ? baked[name] : inherited
|
|
287
|
+
const fallbackSource = name in browser && name in baked ? baked[name] : inherited
|
|
288
|
+
return {
|
|
289
|
+
name,
|
|
290
|
+
value: text(value),
|
|
291
|
+
source: own ?? (name in section ? 'config' : 'default'),
|
|
292
|
+
fallback: own && fallbackSource !== undefined ? text(fallbackSource) : undefined,
|
|
293
|
+
editable,
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
const scope = environmentScope(c, env, { ...baked, ...browser })
|
|
297
|
+
const addresses: EnvironmentView['addresses'] = []
|
|
298
|
+
if (env !== 'mock')
|
|
299
|
+
for (const service of Object.keys(c.services ?? {})) {
|
|
300
|
+
try {
|
|
301
|
+
const url = baseUrlFor(c, service, env, scope)
|
|
302
|
+
addresses.push({ service, ...(url ? { url } : {}) })
|
|
303
|
+
} catch (e) {
|
|
304
|
+
if (!(e instanceof AddressError)) throw e
|
|
305
|
+
addresses.push({ service, error: e.message })
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return { rows, addresses, uses: addressUses(c), removable: !!stored?.from }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Whether a person can make environments here: the dev server writes a file, a static page keeps it. */
|
|
312
|
+
const canCreateEnvironment = () => reg.mode === 'dev' || !!opts.environmentStore
|
|
313
|
+
|
|
314
|
+
/** A new environment holding a copy of `from`'s own variables (never its secrets). */
|
|
315
|
+
async function createEnvironment(name: string, from: string, version: number): Promise<void> {
|
|
316
|
+
if (!ENV_NAME.test(name))
|
|
317
|
+
throw new Error('Use letters, digits, "-" and "_" in an environment name')
|
|
318
|
+
if (name === 'mock' || (await environments(version)).includes(name))
|
|
319
|
+
throw new Error(`"${name}" already exists`)
|
|
320
|
+
if (reg.mode === 'dev') {
|
|
321
|
+
const r = await fetch('/__specbook/environment', {
|
|
322
|
+
method: 'POST',
|
|
323
|
+
headers: { 'content-type': 'application/json' },
|
|
324
|
+
body: JSON.stringify({ name, from }),
|
|
325
|
+
})
|
|
326
|
+
if (!r.ok)
|
|
327
|
+
throw new Error(((await r.json()) as { error?: string }).error ?? 'could not create')
|
|
328
|
+
createdHere.add(name)
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
if (!opts.environmentStore) throw new Error('This page has nowhere to keep a new environment')
|
|
332
|
+
const c = await config(version)
|
|
333
|
+
const section =
|
|
334
|
+
(c.environments?.[from] as { vars?: Record<string, unknown> } | undefined)?.vars ?? {}
|
|
335
|
+
const own = { ...section, ...(await environmentVars(from)) }
|
|
336
|
+
opts.environmentStore.save(name, {
|
|
337
|
+
from,
|
|
338
|
+
vars: Object.fromEntries(
|
|
339
|
+
Object.entries(own)
|
|
340
|
+
.filter(([, v]) => v !== null && typeof v !== 'object')
|
|
341
|
+
.map(([k, v]) => [k, String(v)]),
|
|
342
|
+
),
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Sets (or, with `null`, removes) one of an environment's own variables. */
|
|
347
|
+
async function setVariable(env: string, name: string, value: string | null): Promise<void> {
|
|
348
|
+
if (!VAR_NAME.test(name)) throw new Error(`"${name}" is not a variable name`)
|
|
349
|
+
if (reg.mode === 'dev') {
|
|
350
|
+
const r = await fetch('/__specbook/var', {
|
|
351
|
+
method: 'POST',
|
|
352
|
+
headers: { 'content-type': 'application/json' },
|
|
353
|
+
body: JSON.stringify({ env, name, value }),
|
|
354
|
+
})
|
|
355
|
+
if (!r.ok) throw new Error(((await r.json()) as { error?: string }).error ?? 'save failed')
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
const store = opts.environmentStore
|
|
359
|
+
if (!store) throw new Error('This page has nowhere to keep a variable')
|
|
360
|
+
const current = store.all()[env] ?? { vars: {} }
|
|
361
|
+
const vars = { ...current.vars }
|
|
362
|
+
if (value === null) delete vars[name]
|
|
363
|
+
else vars[name] = value
|
|
364
|
+
if (!current.from && Object.keys(vars).length === 0) store.remove(env)
|
|
365
|
+
else store.save(env, { ...current, vars })
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Forgets an environment made in this browser. */
|
|
369
|
+
function deleteEnvironment(env: string): void {
|
|
370
|
+
opts.environmentStore?.remove(env)
|
|
157
371
|
}
|
|
158
372
|
|
|
159
373
|
/** Names of the variables the project already supplies in `env`: config, per-environment and env files. */
|
|
@@ -181,12 +395,21 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
|
|
|
181
395
|
): Promise<Transport> {
|
|
182
396
|
if (env === 'mock') return createMockTransport({ loader: mockLoader })
|
|
183
397
|
const cfg = await config(version)
|
|
398
|
+
// the environment's variables are only fetched when an address is written as a template
|
|
399
|
+
const templated = Object.values(cfg.services ?? {}).some((s) => hasTemplate(s.baseUrl))
|
|
400
|
+
const scope = templated ? await scopeOf(env, version) : {}
|
|
184
401
|
const pageHref = globalThis.location?.href
|
|
185
402
|
const pageOrigin = globalThis.location?.origin
|
|
186
403
|
const baseOf = (service: string) => {
|
|
187
404
|
if (extra[service]) return extra[service]
|
|
188
|
-
|
|
189
|
-
|
|
405
|
+
let url: string | undefined
|
|
406
|
+
try {
|
|
407
|
+
url = baseUrlFor(cfg, service, env, scope)
|
|
408
|
+
} catch (e) {
|
|
409
|
+
if (e instanceof AddressError) throw new Error(e.message)
|
|
410
|
+
throw e
|
|
411
|
+
}
|
|
412
|
+
if (!url) throw new Error(`No base URL for ${service} in ${env}`)
|
|
190
413
|
return absoluteBase(url, pageHref)
|
|
191
414
|
}
|
|
192
415
|
const hasProxy = reg.mode !== 'static'
|
|
@@ -275,7 +498,13 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
|
|
|
275
498
|
signal?: AbortSignal
|
|
276
499
|
}): Promise<SignInEndpoints> {
|
|
277
500
|
if (i.env === 'mock') throw new Error('Sign in needs a live environment, not mock')
|
|
278
|
-
|
|
501
|
+
let oauth: OAuthSettings | undefined
|
|
502
|
+
try {
|
|
503
|
+
oauth = await oauthOf(i.profile, i.env, i.version)
|
|
504
|
+
} catch (e) {
|
|
505
|
+
if (e instanceof AddressError) throw new Error(e.message)
|
|
506
|
+
throw e
|
|
507
|
+
}
|
|
279
508
|
if (!oauth) throw new Error(`"${i.profile}" has no sign-in configured`)
|
|
280
509
|
if (!isDiscovery(oauth))
|
|
281
510
|
return {
|
|
@@ -394,13 +623,9 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
|
|
|
394
623
|
)
|
|
395
624
|
return {
|
|
396
625
|
env: i.env,
|
|
397
|
-
vars:
|
|
398
|
-
...(c.vars ?? {}),
|
|
399
|
-
...(c.environments?.[i.env]?.vars ?? {}),
|
|
400
|
-
...(await environmentVars(i.env)),
|
|
401
|
-
},
|
|
626
|
+
vars: await scopeOf(i.env, i.version),
|
|
402
627
|
overrides: i.vars,
|
|
403
|
-
|
|
628
|
+
inputs: i.inputs,
|
|
404
629
|
transport: await transportFor(i.env, i.proxyBase ?? '/__specbook/proxy', i.version),
|
|
405
630
|
// placeholders stay in the request: the dev server's proxy or a static page's transport fills them
|
|
406
631
|
deferSecrets: true,
|
|
@@ -409,6 +634,8 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
|
|
|
409
634
|
onEvent: i.onEvent,
|
|
410
635
|
onInput: i.onInput,
|
|
411
636
|
signal: i.signal,
|
|
637
|
+
// the project's own, else the runner's default
|
|
638
|
+
timeoutMs: c.timeoutMs,
|
|
412
639
|
example: (op: { service: string; id: string }, name: string) =>
|
|
413
640
|
loadExample(`${op.service}/${op.id}`, name, i.version),
|
|
414
641
|
readFile: (path: string, dir?: string) => readFile(path, dir ?? i.fileDir),
|
|
@@ -433,27 +660,31 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
|
|
|
433
660
|
return usesFaker([base, data, target.dataPatches, path, query, headers, i.vars])
|
|
434
661
|
}
|
|
435
662
|
|
|
663
|
+
/**
|
|
664
|
+
* Makes faker ready for `locale` (a bundled module offers each locale as its own chunk, so only the ones it falls back
|
|
665
|
+
* through are fetched). Generated values need it before a run: a form that offers one asks for it when it opens.
|
|
666
|
+
*/
|
|
667
|
+
async function fakerReady(version: number, locale?: string, seed?: number): Promise<boolean> {
|
|
668
|
+
if (!reg.loadFaker) return false
|
|
669
|
+
const [mod, cfg] = await Promise.all([reg.loadFaker(version), config(version)])
|
|
670
|
+
const ready = await prepareFaker(mod, cfg.faker, locale)
|
|
671
|
+
installFaker(ready as never, cfg.faker, { locale, seed })
|
|
672
|
+
return true
|
|
673
|
+
}
|
|
674
|
+
|
|
436
675
|
async function run(
|
|
437
676
|
target: RequestDef<any, any, any> | Sequence<any, any>,
|
|
438
677
|
i: RunInput,
|
|
439
678
|
): Promise<RunResult> {
|
|
440
679
|
// `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
|
-
|
|
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
|
-
}
|
|
680
|
+
if (reg.loadFaker && (await mayUseFaker(target, i)))
|
|
681
|
+
await fakerReady(i.version, i.locale, i.seed)
|
|
447
682
|
const o = await baseOptions(i)
|
|
448
683
|
if (target.kind === 'sequence') return runSequence(target, o)
|
|
449
|
-
|
|
450
|
-
i.mockStatus !== undefined && i.env === 'mock'
|
|
451
|
-
? target.with({ mock: { status: i.mockStatus } })
|
|
452
|
-
: target
|
|
453
|
-
return runRequest(def, o)
|
|
684
|
+
return runRequest(target, i.env === 'mock' ? { ...o, mockStatus: i.mockStatus } : o)
|
|
454
685
|
}
|
|
455
686
|
|
|
456
|
-
/** `base` keeps its init/capture/assert
|
|
687
|
+
/** `base` keeps its init/capture/assert and mock hook; `overrides` replace path/query/headers/data. */
|
|
457
688
|
async function operationRequest(
|
|
458
689
|
key: string,
|
|
459
690
|
version: number,
|
|
@@ -466,7 +697,8 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
|
|
|
466
697
|
const op = mod[key.split('/')[1] as string]
|
|
467
698
|
if (!op) throw new Error(`Operation module for ${key} has no export`)
|
|
468
699
|
const defined = Object.fromEntries(Object.entries(overrides).filter(([, v]) => v !== undefined))
|
|
469
|
-
|
|
700
|
+
const made = op.request({ ...(base?.input ?? {}), ...defined })
|
|
701
|
+
return base?.mockHook ? withMock(made, base.mockHook) : made
|
|
470
702
|
}
|
|
471
703
|
|
|
472
704
|
async function operationMeta(key: string, version: number): Promise<OpMeta | undefined> {
|
|
@@ -540,10 +772,20 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
|
|
|
540
772
|
environments,
|
|
541
773
|
environmentRisks,
|
|
542
774
|
environmentNotes,
|
|
775
|
+
environmentVars,
|
|
776
|
+
environmentView,
|
|
777
|
+
canCreateEnvironment,
|
|
778
|
+
createEnvironment,
|
|
779
|
+
deleteEnvironment,
|
|
780
|
+
setVariable,
|
|
781
|
+
baseUrlOf,
|
|
782
|
+
oauthOf,
|
|
783
|
+
addressesIn,
|
|
543
784
|
managedSecrets,
|
|
544
785
|
oauthEndpoints,
|
|
545
786
|
oauthExchange,
|
|
546
787
|
run,
|
|
788
|
+
fakerReady,
|
|
547
789
|
operationRequest,
|
|
548
790
|
operationMeta,
|
|
549
791
|
examplesOf,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { engine } from './runtime'
|
|
2
|
+
import { environmentNote, environmentRisk, environments, envVarsVersion, version } from './store'
|
|
3
|
+
|
|
4
|
+
/** Reads the environment list, risks and notes again (after one is made or removed) and tells dependents. */
|
|
5
|
+
export async function refreshEnvironments(): Promise<void> {
|
|
6
|
+
const v = version.value
|
|
7
|
+
const [names, risks, notes] = await Promise.all([
|
|
8
|
+
engine.environments(v),
|
|
9
|
+
engine.environmentRisks(v),
|
|
10
|
+
engine.environmentNotes(v),
|
|
11
|
+
])
|
|
12
|
+
environments.value = names
|
|
13
|
+
environmentRisk.value = risks
|
|
14
|
+
environmentNote.value = notes
|
|
15
|
+
envVarsVersion.value += 1
|
|
16
|
+
}
|
package/src/main.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { loadManifests } from 'virtual:specbook/registry'
|
|
1
2
|
import { render } from 'preact'
|
|
2
3
|
import { App } from './app'
|
|
3
4
|
import { handleOAuthCallback } from './oauth-callback'
|
|
@@ -15,8 +16,12 @@ applyTheme()
|
|
|
15
16
|
// would strip a host page's own `?code=&state=` that the embed deliberately left alone.
|
|
16
17
|
const underShell =
|
|
17
18
|
(globalThis as { __SPECBOOK_VIEWER__?: unknown }).__SPECBOOK_VIEWER__ !== undefined
|
|
18
|
-
void (underShell ? Promise.resolve(false) : handleOAuthCallback()).then((handled) => {
|
|
19
|
-
if (
|
|
19
|
+
void (underShell ? Promise.resolve(false) : handleOAuthCallback()).then(async (handled) => {
|
|
20
|
+
if (handled) return
|
|
21
|
+
// a built project starts with its services' lists not loaded: fetch them, then draw the app. The app's own code
|
|
22
|
+
// is part of this page's static imports, so it loads alongside, not after (an embed fetches only the lists it needs)
|
|
23
|
+
await loadManifests()
|
|
24
|
+
render(<App />, document.getElementById('app') as HTMLElement)
|
|
20
25
|
})
|
|
21
26
|
|
|
22
27
|
if (import.meta.hot) {
|
package/src/nav.ts
CHANGED
|
@@ -70,7 +70,15 @@ export const paletteOpen = signal(false)
|
|
|
70
70
|
export const catalogService = signal('')
|
|
71
71
|
export const catalogFilter = signal('')
|
|
72
72
|
|
|
73
|
+
let navigator: ((r: Route) => void) | undefined
|
|
74
|
+
|
|
75
|
+
/** A host that is not the app (an embed) takes links itself instead of writing the page's address. */
|
|
76
|
+
export const setNavigator = (fn: ((r: Route) => void) | undefined) => {
|
|
77
|
+
navigator = fn
|
|
78
|
+
}
|
|
79
|
+
|
|
73
80
|
export function go(r: Route) {
|
|
81
|
+
if (navigator) return navigator(r)
|
|
74
82
|
location.hash = hashFor(r)
|
|
75
83
|
route.value = r
|
|
76
84
|
}
|
|
@@ -88,13 +96,15 @@ export interface Entry {
|
|
|
88
96
|
/** the spec requires credentials */
|
|
89
97
|
secured?: boolean
|
|
90
98
|
/** sequences */
|
|
99
|
+
/** flows and requests */
|
|
91
100
|
tags?: string[]
|
|
92
101
|
/** the flow group heading */
|
|
93
102
|
flowGroup?: string
|
|
94
103
|
description?: string
|
|
95
104
|
/** extra text matched by search, never shown */
|
|
96
105
|
hay: string
|
|
97
|
-
|
|
106
|
+
/** `req`: a saved request, which opens its operation on that request */
|
|
107
|
+
kind: 'seq' | 'op' | 'req'
|
|
98
108
|
}
|
|
99
109
|
|
|
100
110
|
export function search(entries: Entry[], query: string, limit = 40): Entry[] {
|
|
@@ -109,6 +119,7 @@ export function search(entries: Entry[], query: string, limit = 40): Entry[] {
|
|
|
109
119
|
let score = 0
|
|
110
120
|
for (const t of terms) score += title.startsWith(t) ? 3 : title.includes(t) ? 2 : 1
|
|
111
121
|
if (e.kind === 'seq') score += 0.5
|
|
122
|
+
if (e.kind === 'req') score += 0.4
|
|
112
123
|
scored.push([score, e])
|
|
113
124
|
}
|
|
114
125
|
return scored
|