@specbook/vite 0.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specbook/vite",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "The Specbook plugin for Vite: the registry, the docs UI hooks and the dev server credential proxy.",
5
5
  "keywords": [
6
6
  "specbook",
@@ -34,8 +34,8 @@
34
34
  }
35
35
  },
36
36
  "dependencies": {
37
- "@specbook/codegen": "0.1.0",
38
- "@specbook/core": "0.1.0",
37
+ "@specbook/codegen": "0.3.0",
38
+ "@specbook/core": "0.3.0",
39
39
  "fast-glob": "3.3.2",
40
40
  "marked": "^18.1.1"
41
41
  },
@@ -0,0 +1,62 @@
1
+ import {
2
+ baseUrlFor,
3
+ environmentNames,
4
+ environmentScope,
5
+ type OAuthSettings,
6
+ oauthFor,
7
+ type SpecbookConfig,
8
+ } from '@specbook/core'
9
+ import type { EnvironmentAddresses } from './proxy'
10
+
11
+ export type AddressConfig = Omit<Partial<SpecbookConfig>, 'auth'> & {
12
+ auth?: Record<string, { oauth?: OAuthSettings }>
13
+ }
14
+
15
+ export const NO_ADDRESSES: EnvironmentAddresses = {
16
+ services: {},
17
+ tokenOrigins: {},
18
+ discoveryOrigins: {},
19
+ }
20
+
21
+ /**
22
+ * Where `env`'s calls may go, from the project's config and the environment's files alone: the page never
23
+ * says where to go. An environment the project does not have gets nothing (a 404 at the proxy).
24
+ * `tokenOriginOf` finds the token endpoint of an OpenID Connect discovery document.
25
+ */
26
+ export async function addressesFor(
27
+ cfg: AddressConfig,
28
+ env: string,
29
+ files: { environments: string[]; vars(): Promise<Record<string, unknown>> },
30
+ tokenOriginOf: (discoveryUrl: string) => Promise<string | undefined>,
31
+ ): Promise<EnvironmentAddresses> {
32
+ const services = cfg.services ?? {}
33
+ const known = environmentNames({ services, environments: cfg.environments }, files.environments)
34
+ if (!known.includes(env)) return NO_ADDRESSES
35
+ const scope = environmentScope(cfg, env, await files.vars())
36
+ const out: EnvironmentAddresses = { services: {}, tokenOrigins: {}, discoveryOrigins: {} }
37
+ for (const [name, svc] of Object.entries(services)) {
38
+ // `mock` never leaves the page: a template's defaults are not its address
39
+ if (env === 'mock' && typeof svc.baseUrl === 'string') continue
40
+ try {
41
+ out.services[name] = baseUrlFor({ services }, name, env, scope)
42
+ } catch {
43
+ /* no address in this environment: a call gets a 404 saying so */
44
+ }
45
+ }
46
+ for (const [name, profile] of Object.entries(cfg.auth ?? {})) {
47
+ try {
48
+ if (!profile.oauth) continue
49
+ const oauth = oauthFor(profile.oauth, scope, name)
50
+ if ('tokenUrl' in oauth && oauth.tokenUrl)
51
+ out.tokenOrigins[name] = new URL(oauth.tokenUrl).origin
52
+ else if ('openIdConnectUrl' in oauth && oauth.openIdConnectUrl) {
53
+ out.discoveryOrigins[name] = new URL(oauth.openIdConnectUrl).origin
54
+ const token = await tokenOriginOf(oauth.openIdConnectUrl)
55
+ if (token) out.tokenOrigins[name] = token
56
+ }
57
+ } catch {
58
+ /* an unusable address is left out; a sign-in against it fails with a 404 */
59
+ }
60
+ }
61
+ return out
62
+ }
@@ -0,0 +1,26 @@
1
+ // The shared definitions a service's schemas refer to, for a build that publishes only some of its operations.
2
+
3
+ /** Names of the shared definitions a JSON text refers to (`specbook://<svc>/defs#/$defs/Name` or `#/$defs/Name`). */
4
+ export function definitionRefs(text: string): string[] {
5
+ const names: string[] = []
6
+ for (const m of text.matchAll(/"\$ref":"(?:specbook:\/\/[^"#]*)?#\/\$defs\/([^"]+)"/g))
7
+ names.push((m[1] as string).replace(/~1/g, '/').replace(/~0/g, '~'))
8
+ return names
9
+ }
10
+
11
+ /** A service's definitions cut down to the ones `roots` refer to, directly or through each other. */
12
+ export function pruneDefinitions(
13
+ defs: { $id?: string; $defs?: Record<string, unknown> },
14
+ roots: Set<string> | undefined,
15
+ ) {
16
+ const all = defs.$defs ?? {}
17
+ const keep = new Set<string>()
18
+ const queue = [...(roots ?? [])]
19
+ while (queue.length) {
20
+ const name = queue.pop() as string
21
+ if (keep.has(name) || !(name in all)) continue
22
+ keep.add(name)
23
+ queue.push(...definitionRefs(JSON.stringify(all[name])))
24
+ }
25
+ return { ...defs, $defs: Object.fromEntries(Object.entries(all).filter(([k]) => keep.has(k))) }
26
+ }
@@ -0,0 +1,60 @@
1
+ import { parseDiscovery } from '@specbook/core'
2
+
3
+ const FETCH_TIMEOUT_MS = 5_000
4
+
5
+ /**
6
+ * Learns, on the server, where an OpenID Connect provider's token endpoint is. The page needs the proxy to
7
+ * forward its code exchange there, and the proxy only forwards to origins it was told about by the
8
+ * project's own config; a discovery document is the config's pointer to that origin. Successes are kept
9
+ * for the life of the dev server; a failure is not retried for a short while, so a provider that is down
10
+ * costs one slow request, not one per call.
11
+ */
12
+ export function createDiscovery(
13
+ opts: {
14
+ fetch?: typeof fetch
15
+ retryAfterMs?: number
16
+ now?: () => number
17
+ warn?: (message: string) => void
18
+ } = {},
19
+ ) {
20
+ const f = opts.fetch ?? fetch
21
+ const now = opts.now ?? Date.now
22
+ const retryAfter = opts.retryAfterMs ?? 30_000
23
+ const found = new Map<string, string>()
24
+ const failedAt = new Map<string, number>()
25
+ const inFlight = new Map<string, Promise<string | undefined>>()
26
+
27
+ async function look(url: string): Promise<string | undefined> {
28
+ try {
29
+ const res = await f(url, {
30
+ headers: { accept: 'application/json' },
31
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
32
+ })
33
+ if (!res.ok) throw new Error(`answered ${res.status}`)
34
+ const origin = new URL(parseDiscovery(await res.json()).tokenUrl).origin
35
+ found.set(url, origin)
36
+ failedAt.delete(url)
37
+ return origin
38
+ } catch (e) {
39
+ failedAt.set(url, now())
40
+ opts.warn?.(
41
+ `Could not read the OpenID Connect discovery document at ${url} (${e instanceof Error ? e.message : String(e)}). Sign in is unavailable until it can; pasting a token still works.`,
42
+ )
43
+ return undefined
44
+ }
45
+ }
46
+
47
+ /** The origin of the provider's token endpoint, or `undefined` when discovery has failed. */
48
+ return function tokenOrigin(url: string): Promise<string | undefined> {
49
+ const known = found.get(url)
50
+ if (known) return Promise.resolve(known)
51
+ const failed = failedAt.get(url)
52
+ if (failed !== undefined && now() - failed < retryAfter) return Promise.resolve(undefined)
53
+ let pending = inFlight.get(url)
54
+ if (!pending) {
55
+ pending = look(url).finally(() => inFlight.delete(url))
56
+ inFlight.set(url, pending)
57
+ }
58
+ return pending
59
+ }
60
+ }
package/src/examples.ts CHANGED
@@ -40,6 +40,8 @@ export async function scanExamples(
40
40
  const gen = `${root}/${outDir}`
41
41
  for (const abs of await fg('*/examples/*/*.example.json', { cwd: gen, absolute: true })) {
42
42
  const [svc, , id, file] = relative(gen, abs).split(sep) as [string, string, string, string]
43
+ // only operations the build knows (a minimal build lists just those it publishes)
44
+ if (!operations.has(`${svc}/${id}`)) continue
43
45
  add(`${svc}/${id}`, {
44
46
  name: file.replace(NAME, ''),
45
47
  abs,
package/src/index.ts CHANGED
@@ -3,13 +3,18 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'
3
3
  import type { IncomingMessage, ServerResponse } from 'node:http'
4
4
  import { createRequire } from 'node:module'
5
5
  import { basename, dirname, join, relative, resolve, sep } from 'node:path'
6
+ import { environmentNames } from '@specbook/core'
6
7
  import fg from 'fast-glob'
7
8
  import type { Plugin, ViteDevServer } from 'vite'
9
+ import { type AddressConfig, addressesFor, NO_ADDRESSES } from './addresses'
10
+ import { definitionRefs, pruneDefinitions } from './definitions'
11
+ import { createDiscovery } from './discovery'
8
12
  import { scanDocs } from './docs'
9
13
  import {
10
14
  ensureEnvironmentsDir,
11
15
  isEnvName,
12
16
  isVarName,
17
+ listEnvironmentFiles,
13
18
  readAllVars,
14
19
  readEnvironment,
15
20
  secretsFileFor,
@@ -19,12 +24,13 @@ import {
19
24
  } from './environments'
20
25
  import { scanExamples } from './examples'
21
26
  import { requestsDirFor, safeProjectPath, scanProject } from './files'
22
- import { handleProxy, type ProxyConfig } from './proxy'
27
+ import { type EnvironmentAddresses, handleProxy, type ProxyConfig } from './proxy'
28
+ import { scanRequests } from './requests'
23
29
  import { parseSequenceMeta, scanSequences } from './sequences'
24
30
 
25
31
  export * from './environments'
26
- export { safeProjectPath, scanProject } from './files'
27
- export type { ProxyConfig, ProxyDeps } from './proxy'
32
+ export { requestsDirFor, safeProjectPath, scanProject } from './files'
33
+ export type { EnvironmentAddresses, ProxyConfig, ProxyDeps } from './proxy'
28
34
  export { handleProxy } from './proxy'
29
35
 
30
36
  export interface SpecbookPluginOptions {
@@ -32,6 +38,12 @@ export interface SpecbookPluginOptions {
32
38
  projectRoot: string
33
39
  /** Generated code directory relative to the project root. Default `.generated`. */
34
40
  outDir?: string
41
+ /**
42
+ * Publish only these operations (`service/OperationId`): the ones a project's requests and flows use. The catalog,
43
+ * schemas, examples, docs and definitions of every other operation are left out, so the build neither carries nor
44
+ * reveals the rest of the API.
45
+ */
46
+ operations?: Set<string>
35
47
  }
36
48
 
37
49
  const VIRTUAL = 'virtual:specbook/registry'
@@ -42,8 +54,8 @@ const VIRTUAL_FAKER = 'virtual:specbook/faker'
42
54
  const RESOLVED_FAKER = `\0${VIRTUAL_FAKER}`
43
55
  const VIRTUAL_FAKER_LOCALE = 'virtual:specbook/faker-locale/'
44
56
  const RESOLVED_FAKER_LOCALE = `\0${VIRTUAL_FAKER_LOCALE}`
45
- const VIRTUAL_MANIFESTS = 'virtual:specbook/manifests'
46
- const RESOLVED_MANIFESTS = `\0${VIRTUAL_MANIFESTS}`
57
+ const VIRTUAL_MANIFEST = 'virtual:specbook/manifest/'
58
+ const RESOLVED_MANIFEST = `\0${VIRTUAL_MANIFEST}`
47
59
  const VIRTUAL_OPERATIONS = 'virtual:specbook/operations'
48
60
  const RESOLVED_OPERATIONS = `\0${VIRTUAL_OPERATIONS}`
49
61
  const VIRTUAL_DOC = 'virtual:specbook/doc/'
@@ -131,11 +143,17 @@ async function collectTypes(root: string, outDir: string) {
131
143
  export function specbook(options: SpecbookPluginOptions): Plugin {
132
144
  const root = resolve(options.projectRoot)
133
145
  const outDir = options.outDir ?? '.generated'
146
+ const only = options.operations
147
+ /** Whether an operation is part of this build. */
148
+ const keep = (service: string, op: string) => !only || only.has(`${service}/${op}`)
149
+ /** per service, the shared definitions its kept schemas refer to (a minimal build trims the rest) */
150
+ const neededDefs = new Map<string, Set<string>>()
134
151
  let server: ViteDevServer | undefined
135
152
  let isBuild = false
136
153
  let docHtml: string[] = []
137
154
  /** build only: parts of the registry that live in their own chunks, and the data files served as assets */
138
- let manifestsCode = 'export default {}'
155
+ /** build: the manifest of each service, one module each */
156
+ let manifestCode = new Map<string, string>()
139
157
  let operationsCode = 'export const operations = {}'
140
158
  /** `service/Operation` -> the generated files that make up its data bundle */
141
159
  let bundlePlan = new Map<
@@ -156,6 +174,8 @@ export function specbook(options: SpecbookPluginOptions): Plugin {
156
174
  let notifyRegistryChanged: () => void = () => {}
157
175
  /** Credentials entered in the UI: never written to disk, gone when the server stops. */
158
176
  const memorySecrets: Record<string, Record<string, string>> = {}
177
+ // where each OpenID Connect profile's token endpoint is, read once from its discovery document
178
+ const tokenOriginOf = createDiscovery({ warn: (m) => server?.config.logger.warn(m) })
159
179
 
160
180
  async function registryCode(): Promise<string> {
161
181
  const files = await scanProject(root)
@@ -164,10 +184,24 @@ export function specbook(options: SpecbookPluginOptions): Plugin {
164
184
  const parsed = JSON.parse(await readFile(m, 'utf8'))
165
185
  manifests[parsed.service] = parsed
166
186
  }
187
+ if (only) {
188
+ for (const [svc, m] of Object.entries(manifests)) {
189
+ const mm = m as { operations: { id: string; tags?: string[] }[]; tags?: string[] }
190
+ mm.operations = mm.operations.filter((o) => keep(svc, o.id))
191
+ if (mm.operations.length === 0) delete manifests[svc]
192
+ else mm.tags = [...new Set(mm.operations.flatMap((o) => o.tags ?? []))]
193
+ }
194
+ }
195
+ const inScope = (svc: string, op: string) => svc in manifests && keep(svc, op)
167
196
  // response examples: <svc>/examples/<Op>/<status|default>.response.json, keyed `svc/Op.<status|default>`
168
- const examples = await fg('*/examples/*/*.response.json', {
169
- cwd: join(root, outDir),
170
- absolute: true,
197
+ const examples = (
198
+ await fg('*/examples/*/*.response.json', {
199
+ cwd: join(root, outDir),
200
+ absolute: true,
201
+ })
202
+ ).filter((abs) => {
203
+ const rel = relative(join(root, outDir), abs).split(sep)
204
+ return inScope(rel[0] as string, rel[2] as string)
171
205
  })
172
206
  // dev: cache-busted /@fs imports (files on disk are the source of truth); build: statically analysable
173
207
  const imp = (abs: string) => {
@@ -198,29 +232,32 @@ export function specbook(options: SpecbookPluginOptions): Plugin {
198
232
  ` ${JSON.stringify(`${mm.service}/${o.id}`)}: ${imp(join(root, outDir, mm.service, 'operations', `${o.id}.ts`))}`,
199
233
  )
200
234
  })
201
- const schemaFiles = await fg('*/schemas/*.request.schema.json', {
202
- cwd: join(root, outDir),
203
- absolute: true,
204
- })
205
- const responseFiles = await fg('*/schemas/*.responses.schema.json', {
206
- cwd: join(root, outDir),
207
- absolute: true,
208
- })
235
+ const opOf = (abs: string, suffix: RegExp) => {
236
+ const rel = relative(join(root, outDir), abs).split(sep)
237
+ return [rel[0] as string, (rel[2] as string).replace(suffix, '')] as const
238
+ }
239
+ const schemaFiles = (
240
+ await fg('*/schemas/*.request.schema.json', { cwd: join(root, outDir), absolute: true })
241
+ ).filter((abs) => inScope(...opOf(abs, /\.request\.schema\.json$/)))
242
+ const responseFiles = (
243
+ await fg('*/schemas/*.responses.schema.json', { cwd: join(root, outDir), absolute: true })
244
+ ).filter((abs) => inScope(...opOf(abs, /\.responses\.schema\.json$/)))
209
245
  const responseEntries = responseFiles.map((abs) => {
210
246
  const rel = relative(join(root, outDir), abs).split(sep)
211
247
  const op = (rel[2] as string).replace(/\.responses\.schema\.json$/, '')
212
248
  return ` ${JSON.stringify(`${rel[0]}/${op}`)}: ${imp(abs)}`
213
249
  })
214
- const defFiles = await fg('*/schemas/definitions.schema.json', {
215
- cwd: join(root, outDir),
216
- absolute: true,
217
- })
250
+ const defFiles = (
251
+ await fg('*/schemas/definitions.schema.json', { cwd: join(root, outDir), absolute: true })
252
+ ).filter((abs) => (relative(join(root, outDir), abs).split(sep)[0] as string) in manifests)
218
253
  const schemaEntries = schemaFiles.map((abs) => {
219
254
  const rel = relative(join(root, outDir), abs).split(sep)
220
255
  const op = (rel[2] as string).replace(/\.request\.schema\.json$/, '')
221
256
  return ` ${JSON.stringify(`${rel[0]}/${op}`)}: ${imp(abs)}`
222
257
  })
223
- const docFiles = await fg('*/docs/*.json', { cwd: join(root, outDir), absolute: true })
258
+ const docFiles = (
259
+ await fg('*/docs/*.json', { cwd: join(root, outDir), absolute: true })
260
+ ).filter((abs) => inScope(...opOf(abs, /\.json$/)))
224
261
  const operationDocEntries = docFiles.map((abs) => {
225
262
  const rel = relative(join(root, outDir), abs).split(sep)
226
263
  return ` ${JSON.stringify(`${rel[0]}/${(rel[2] as string).replace(/\.json$/, '')}`)}: ${imp(abs)}`
@@ -237,11 +274,16 @@ export function specbook(options: SpecbookPluginOptions): Plugin {
237
274
  )
238
275
  // Sequence titles come from each file's literal header, so listing them imports no sequence.
239
276
  const sequenceScan = await scanSequences(root, files.sequences)
277
+ // which operation each request file is for, read from its text, so an embed imports only the one it opens
278
+ const requestScan = await scanRequests(root, files.requests, new Set(Object.keys(manifests)))
240
279
  for (const f of files.sequences) {
241
280
  const src = await readFile(join(root, f), 'utf8').catch(() => '')
242
281
  seqHeaders.set(join(root, f), JSON.stringify(parseSequenceMeta(src)))
243
282
  }
244
283
  const scanned = await scanExamples(root, outDir, known)
284
+ const keepExampleKey = (key: string) => !only || only.has(key)
285
+ for (const key of Object.keys(scanned.examples))
286
+ if (!keepExampleKey(key)) delete scanned.examples[key]
245
287
  const url = (abs: string) =>
246
288
  isBuild
247
289
  ? `() => import(${JSON.stringify(`${abs}?url`)}).then((m) => m.default)`
@@ -270,7 +312,10 @@ export function specbook(options: SpecbookPluginOptions): Plugin {
270
312
  const assetEntries = Object.entries(scanned.assets).map(
271
313
  ([key, abs]) => ` ${JSON.stringify(key)}: ${url(abs)}`,
272
314
  )
273
- const pages = await scanDocs(root)
315
+ // a minimal build keeps the notes on its requests and flows, and drops the pages of operations it does not publish
316
+ const pages = (await scanDocs(root)).filter(
317
+ (p) => !only || !p.operation || only.has(p.operation),
318
+ )
274
319
  docHtml = pages.map((p) => p.html)
275
320
  const docEntries = pages.map(
276
321
  (p, i) =>
@@ -285,7 +330,11 @@ export function specbook(options: SpecbookPluginOptions): Plugin {
285
330
  if (isBuild) {
286
331
  // The registry stays small: manifests and the operation modules sit in their own chunks, and the
287
332
  // generated JSON (schemas, examples, docs, definitions) is served as plain files under data/.
288
- manifestsCode = `export default ${JSON.stringify(slim(manifests))}`
333
+ // one chunk per service, so a page that opens one operation fetches one service's list
334
+ const slimmed = slim(manifests)
335
+ manifestCode = new Map(
336
+ Object.entries(slimmed).map(([svc, m]) => [svc, `export default ${JSON.stringify(m)}`]),
337
+ )
289
338
  operationsCode = `export const operations = {\n${operationEntries.join(',\n')}\n}`
290
339
  const gen = join(root, outDir)
291
340
  bundlePlan = new Map()
@@ -345,8 +394,8 @@ export function specbook(options: SpecbookPluginOptions): Plugin {
345
394
  projectLoads.push(` ${JSON.stringify(`${key}/${e.name}`)}: ${imp(e.abs)}`)
346
395
  }
347
396
  const services = Object.keys(manifests)
348
- return `import manifests from ${JSON.stringify(VIRTUAL_MANIFESTS)}
349
- export const mode = "static"
397
+ return `export const mode = "static"
398
+ export const minimal = ${JSON.stringify(!!only)}
350
399
  export const project = ${JSON.stringify({ root, outDir, requestsDir })}
351
400
  // where the data files are: dist/data, whether this chunk is in dist/ or dist/assets/
352
401
  const dataBase = import.meta.url.replace(/(assets\\/)?[^/]*$/, '') + 'data/'
@@ -360,8 +409,13 @@ const bundle = (key) => {
360
409
  }
361
410
  const part = (pick) =>
362
411
  new Proxy({}, { get: (_, key) => (typeof key === 'string' && key.includes('/') ? () => bundle(key).then((b) => ({ default: pick(b) })) : undefined) })
363
- export { manifests }
412
+ export const manifests = {}
413
+ const manifestLoaders = {${services.map((svc) => `\n ${JSON.stringify(svc)}: () => import(${JSON.stringify(`${VIRTUAL_MANIFEST}${svc}`)})`).join(',')}\n}
414
+ export const loadManifests = (names = Object.keys(manifestLoaders)) =>
415
+ Promise.all(names.map((svc) => manifests[svc] ? undefined : manifestLoaders[svc]?.().then((m) => { manifests[svc] = m.default }))).then(() => undefined)
364
416
  export const requests = [${files.requests.map(entry).join(',\n')}]
417
+ export const requestMeta = ${JSON.stringify(requestScan.meta)}
418
+ export const unresolvedRequests = ${JSON.stringify(requestScan.unresolved)}
365
419
  export const sequences = [${files.sequences.map(entry).join(',\n')}]
366
420
  export const sequenceMeta = ${JSON.stringify(sequenceScan.meta)}
367
421
  export const unresolvedSequences = ${JSON.stringify(sequenceScan.unresolved)}
@@ -409,9 +463,13 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
409
463
  `
410
464
  }
411
465
  return `export const mode = ${JSON.stringify(isBuild ? 'static' : 'dev')}
466
+ export const minimal = ${JSON.stringify(!!only)}
412
467
  export const project = ${JSON.stringify({ root, outDir, requestsDir })}
413
468
  export const manifests = ${JSON.stringify(slim(manifests))}
469
+ export const loadManifests = () => Promise.resolve()
414
470
  export const requests = [${files.requests.map(entry).join(',\n')}]
471
+ export const requestMeta = ${JSON.stringify(requestScan.meta)}
472
+ export const unresolvedRequests = ${JSON.stringify(requestScan.unresolved)}
415
473
  export const sequences = [${files.sequences.map(entry).join(',\n')}]
416
474
  export const sequenceMeta = ${JSON.stringify(sequenceScan.meta)}
417
475
  export const unresolvedSequences = ${JSON.stringify(sequenceScan.unresolved)}
@@ -431,20 +489,29 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
431
489
  `
432
490
  }
433
491
 
434
- async function getProxyConfig(): Promise<ProxyConfig> {
492
+ async function loadProjectConfig(): Promise<AddressConfig | undefined> {
435
493
  const cfgFile = findConfig(root)
436
- if (!server || !cfgFile) return { services: {}, secretNames: {} }
437
- const mod = await server.ssrLoadModule(cfgFile)
438
- const cfg = (mod.default ?? {}) as {
439
- services?: Record<string, { baseUrl?: Record<string, string> }>
440
- secrets?: Record<string, { env?: string }>
441
- }
442
- return {
443
- services: Object.fromEntries(
444
- Object.entries(cfg.services ?? {}).map(([k, v]) => [k, { baseUrl: v.baseUrl ?? {} }]),
445
- ),
446
- secretNames: cfg.secrets ?? {},
447
- }
494
+ if (!server || !cfgFile) return undefined
495
+ return ((await server.ssrLoadModule(cfgFile)).default ?? {}) as AddressConfig
496
+ }
497
+
498
+ async function getProxyConfig(): Promise<ProxyConfig> {
499
+ const cfg = await loadProjectConfig()
500
+ if (!cfg) return { secretNames: {}, resolve: async () => NO_ADDRESSES }
501
+ // addresses are worked out here, per environment, from config and files: the page never says where to go
502
+ const resolve = async (env: string): Promise<EnvironmentAddresses> =>
503
+ isEnvName(env)
504
+ ? addressesFor(
505
+ cfg,
506
+ env,
507
+ {
508
+ environments: await listEnvironmentFiles(root),
509
+ vars: async () => (await readEnvironment(root, env)).vars,
510
+ },
511
+ tokenOriginOf,
512
+ )
513
+ : NO_ADDRESSES
514
+ return { secretNames: cfg.secrets ?? {}, resolve }
448
515
  }
449
516
 
450
517
  return {
@@ -475,6 +542,13 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
475
542
  await Promise.all(statuses.map(async (n) => [n, await read(p.mock[n] as string)])),
476
543
  )
477
544
  }
545
+ if (only) {
546
+ const svc = key.slice(0, key.indexOf('/'))
547
+ const set = neededDefs.get(svc) ?? new Set<string>()
548
+ for (const name of definitionRefs(JSON.stringify([out.schema, out.responses])))
549
+ set.add(name)
550
+ neededDefs.set(svc, set)
551
+ }
478
552
  this.emitFile({
479
553
  type: 'asset',
480
554
  fileName: `data/${key}.json`,
@@ -485,7 +559,11 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
485
559
  this.emitFile({
486
560
  type: 'asset',
487
561
  fileName: `data/${svc}/_definitions.json`,
488
- source: await readFile(abs),
562
+ source: only
563
+ ? JSON.stringify(
564
+ pruneDefinitions(JSON.parse(await readFile(abs, 'utf8')), neededDefs.get(svc)),
565
+ )
566
+ : await readFile(abs),
489
567
  })
490
568
  },
491
569
  config(_, env) {
@@ -497,7 +575,8 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
497
575
  // manifests are their own chunk (preloaded with the app, so no extra round trip), and
498
576
  // operation modules are grouped by tag instead of one chunk per operation
499
577
  manualChunks(id: string) {
500
- if (id === RESOLVED_MANIFESTS) return 'manifests'
578
+ if (id.startsWith(RESOLVED_MANIFEST))
579
+ return `manifest-${id.slice(RESOLVED_MANIFEST.length)}`
501
580
  return operationChunks.get(id)
502
581
  },
503
582
  },
@@ -509,7 +588,7 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
509
588
  if (id === VIRTUAL) return RESOLVED
510
589
  if (id === VIRTUAL_TYPES) return RESOLVED_TYPES
511
590
  if (id === VIRTUAL_FAKER) return RESOLVED_FAKER
512
- if (id === VIRTUAL_MANIFESTS) return RESOLVED_MANIFESTS
591
+ if (id.startsWith(VIRTUAL_MANIFEST)) return `\0${id}`
513
592
  if (id === VIRTUAL_OPERATIONS) return RESOLVED_OPERATIONS
514
593
  if (id.startsWith(VIRTUAL_FAKER_LOCALE)) return `\0${id}`
515
594
  if (id.startsWith(VIRTUAL_DOC)) return `\0${id}`
@@ -517,7 +596,8 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
517
596
  },
518
597
  async load(id) {
519
598
  if (id === RESOLVED) return registryCode()
520
- if (id === RESOLVED_MANIFESTS) return manifestsCode
599
+ if (id.startsWith(RESOLVED_MANIFEST))
600
+ return manifestCode.get(id.slice(RESOLVED_MANIFEST.length)) ?? 'export default {}'
521
601
  if (id === RESOLVED_OPERATIONS) return operationsCode
522
602
  if (id === RESOLVED_FAKER || id.startsWith(RESOLVED_FAKER_LOCALE)) {
523
603
  // Let Vite resolve the package from the project (ESM entry, not the CJS one `require.resolve` finds).
@@ -567,6 +647,8 @@ ${names.map((n) => ` ${JSON.stringify(n)}: () => import(${JSON.stringify(`${VIR
567
647
  const relevant = (file: string) => {
568
648
  const rel = relative(root, file)
569
649
  if (rel.startsWith('..') || /node_modules/.test(rel)) return false
650
+ // not `.specbook/environments/*.env`: the page reads an environment's variables live, and a reload
651
+ // when its file is first written would blank the page under whoever is editing a variable
570
652
  return /\.(req|seq)\.ts$|\.example\.json$|\.md$/.test(rel) || rel.startsWith(outDir)
571
653
  }
572
654
  // one reload per burst of changes: a save also arrives here from the watcher
@@ -651,6 +733,40 @@ ${names.map((n) => ` ${JSON.stringify(n)}: () => import(${JSON.stringify(`${VIR
651
733
  )
652
734
  return json(res, 200, { env, vars: values.vars, secrets, warnings })
653
735
  }
736
+ if (url.pathname === '/__specbook/environment' && req.method === 'POST') {
737
+ // a new environment is a copy of another one's own variables (never its secrets)
738
+ const origin = req.headers.origin
739
+ if (origin && new URL(origin).host !== req.headers.host)
740
+ return json(res, 403, { error: 'cross-origin request refused' })
741
+ const { name, from } = JSON.parse(await readBody(req)) as {
742
+ name?: string
743
+ from?: string
744
+ }
745
+ if (!isEnvName(name) || name === 'mock' || !isEnvName(from))
746
+ return json(res, 400, { error: 'bad environment name' })
747
+ const conf = await loadProjectConfig()
748
+ const known = environmentNames(
749
+ { services: conf?.services ?? {}, environments: conf?.environments },
750
+ await listEnvironmentFiles(root),
751
+ )
752
+ if (known.includes(name)) return json(res, 409, { error: `"${name}" already exists` })
753
+ if (!known.includes(from))
754
+ return json(res, 400, { error: `unknown environment "${from}"` })
755
+ const own = {
756
+ ...(
757
+ conf?.environments as Record<string, { vars?: Record<string, unknown> }> | undefined
758
+ )?.[from]?.vars,
759
+ ...(await readEnvironment(root, from)).vars,
760
+ }
761
+ await ensureEnvironmentsDir(root)
762
+ const file = varsFile(root, name)
763
+ await writeFile(file, `# variables for ${name}, copied from ${from}\n`)
764
+ for (const [k, v] of Object.entries(own)) {
765
+ if (v === null || typeof v === 'object' || !isVarName(k)) continue
766
+ await writeEnvKey(file, k, String(v))
767
+ }
768
+ return json(res, 200, { ok: true, name })
769
+ }
654
770
  if (
655
771
  (url.pathname === '/__specbook/secret' || url.pathname === '/__specbook/var') &&
656
772
  req.method === 'POST'
package/src/proxy.ts CHANGED
@@ -1,9 +1,39 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http'
2
2
  import { handleProxy as proxy } from '@specbook/core'
3
3
 
4
+ /** Where one environment's calls may go, worked out on the server from the project's config and files. */
5
+ export interface EnvironmentAddresses {
6
+ /** each service's base URL in this environment */
7
+ services: Record<string, string | undefined>
8
+ /**
9
+ * Origin of each OAuth profile's token endpoint. A sign-in exchanges its code through
10
+ * `<mount>/<env>/auth:<profile>/…`; the page chooses the path, never the host.
11
+ */
12
+ tokenOrigins: Record<string, string>
13
+ /**
14
+ * Origin of each OpenID Connect profile's discovery document, addressed as
15
+ * `<mount>/<env>/discovery:<profile>/…` (the token endpoint may be on another host).
16
+ */
17
+ discoveryOrigins: Record<string, string>
18
+ }
19
+
4
20
  export interface ProxyConfig {
5
- services: Record<string, { baseUrl: Record<string, string | undefined> }>
6
21
  secretNames: Record<string, { env?: string }>
22
+ /** The addresses for `env`; empty for an environment the project does not have. */
23
+ resolve(env: string): Promise<EnvironmentAddresses>
24
+ }
25
+
26
+ /** The reserved service name a sign-in's token exchange is addressed to. */
27
+ export const AUTH_SERVICE_PREFIX = 'auth:'
28
+ /** The reserved service name a sign-in's discovery document is fetched through. */
29
+ export const DISCOVERY_SERVICE_PREFIX = 'discovery:'
30
+
31
+ const decode = (s: string) => {
32
+ try {
33
+ return decodeURIComponent(s)
34
+ } catch {
35
+ return s
36
+ }
7
37
  }
8
38
 
9
39
  export interface ProxyDeps {
@@ -46,6 +76,15 @@ export async function handleProxy(
46
76
  if (!url.startsWith(`${MOUNT}/`)) return false
47
77
 
48
78
  const config = await deps.getConfig()
79
+ const addressesByEnv = new Map<string, Promise<EnvironmentAddresses>>()
80
+ const addressesFor = (env: string) => {
81
+ let a = addressesByEnv.get(env)
82
+ if (!a) {
83
+ a = config.resolve(env)
84
+ addressesByEnv.set(env, a)
85
+ }
86
+ return a
87
+ }
49
88
  const secretsByEnv = new Map<string, Record<string, string>>()
50
89
  const method = req.method ?? 'GET'
51
90
  const headers = new Headers()
@@ -61,7 +100,14 @@ export async function handleProxy(
61
100
  const response = await proxy(request, {
62
101
  mount: MOUNT,
63
102
  policy: {
64
- upstream: (env, service) => config.services[service]?.baseUrl[env],
103
+ upstream: async (env, service) => {
104
+ const a = await addressesFor(env)
105
+ if (service.startsWith(AUTH_SERVICE_PREFIX))
106
+ return a.tokenOrigins[decode(service.slice(AUTH_SERVICE_PREFIX.length))]
107
+ if (service.startsWith(DISCOVERY_SERVICE_PREFIX))
108
+ return a.discoveryOrigins[decode(service.slice(DISCOVERY_SERVICE_PREFIX.length))]
109
+ return a.services[service]
110
+ },
65
111
  // a developer's own machine: their local services are the point
66
112
  allowPrivateHosts: true,
67
113
  },