@specbook/vite 0.2.0 → 0.4.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.2.0",
3
+ "version": "0.4.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.2.0",
38
- "@specbook/core": "0.2.0",
37
+ "@specbook/codegen": "0.4.0",
38
+ "@specbook/core": "0.4.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
+ }
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,14 +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'
8
11
  import { createDiscovery } from './discovery'
9
12
  import { scanDocs } from './docs'
10
13
  import {
11
14
  ensureEnvironmentsDir,
12
15
  isEnvName,
13
16
  isVarName,
17
+ listEnvironmentFiles,
14
18
  readAllVars,
15
19
  readEnvironment,
16
20
  secretsFileFor,
@@ -20,12 +24,13 @@ import {
20
24
  } from './environments'
21
25
  import { scanExamples } from './examples'
22
26
  import { requestsDirFor, safeProjectPath, scanProject } from './files'
23
- import { handleProxy, type ProxyConfig } from './proxy'
27
+ import { type EnvironmentAddresses, handleProxy, type ProxyConfig } from './proxy'
28
+ import { scanRequests } from './requests'
24
29
  import { parseSequenceMeta, scanSequences } from './sequences'
25
30
 
26
31
  export * from './environments'
27
- export { safeProjectPath, scanProject } from './files'
28
- export type { ProxyConfig, ProxyDeps } from './proxy'
32
+ export { requestsDirFor, safeProjectPath, scanProject } from './files'
33
+ export type { EnvironmentAddresses, ProxyConfig, ProxyDeps } from './proxy'
29
34
  export { handleProxy } from './proxy'
30
35
 
31
36
  export interface SpecbookPluginOptions {
@@ -33,6 +38,12 @@ export interface SpecbookPluginOptions {
33
38
  projectRoot: string
34
39
  /** Generated code directory relative to the project root. Default `.generated`. */
35
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>
36
47
  }
37
48
 
38
49
  const VIRTUAL = 'virtual:specbook/registry'
@@ -43,8 +54,8 @@ const VIRTUAL_FAKER = 'virtual:specbook/faker'
43
54
  const RESOLVED_FAKER = `\0${VIRTUAL_FAKER}`
44
55
  const VIRTUAL_FAKER_LOCALE = 'virtual:specbook/faker-locale/'
45
56
  const RESOLVED_FAKER_LOCALE = `\0${VIRTUAL_FAKER_LOCALE}`
46
- const VIRTUAL_MANIFESTS = 'virtual:specbook/manifests'
47
- const RESOLVED_MANIFESTS = `\0${VIRTUAL_MANIFESTS}`
57
+ const VIRTUAL_MANIFEST = 'virtual:specbook/manifest/'
58
+ const RESOLVED_MANIFEST = `\0${VIRTUAL_MANIFEST}`
48
59
  const VIRTUAL_OPERATIONS = 'virtual:specbook/operations'
49
60
  const RESOLVED_OPERATIONS = `\0${VIRTUAL_OPERATIONS}`
50
61
  const VIRTUAL_DOC = 'virtual:specbook/doc/'
@@ -132,11 +143,17 @@ async function collectTypes(root: string, outDir: string) {
132
143
  export function specbook(options: SpecbookPluginOptions): Plugin {
133
144
  const root = resolve(options.projectRoot)
134
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>>()
135
151
  let server: ViteDevServer | undefined
136
152
  let isBuild = false
137
153
  let docHtml: string[] = []
138
154
  /** build only: parts of the registry that live in their own chunks, and the data files served as assets */
139
- let manifestsCode = 'export default {}'
155
+ /** build: the manifest of each service, one module each */
156
+ let manifestCode = new Map<string, string>()
140
157
  let operationsCode = 'export const operations = {}'
141
158
  /** `service/Operation` -> the generated files that make up its data bundle */
142
159
  let bundlePlan = new Map<
@@ -167,10 +184,24 @@ export function specbook(options: SpecbookPluginOptions): Plugin {
167
184
  const parsed = JSON.parse(await readFile(m, 'utf8'))
168
185
  manifests[parsed.service] = parsed
169
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)
170
196
  // response examples: <svc>/examples/<Op>/<status|default>.response.json, keyed `svc/Op.<status|default>`
171
- const examples = await fg('*/examples/*/*.response.json', {
172
- cwd: join(root, outDir),
173
- 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)
174
205
  })
175
206
  // dev: cache-busted /@fs imports (files on disk are the source of truth); build: statically analysable
176
207
  const imp = (abs: string) => {
@@ -201,29 +232,32 @@ export function specbook(options: SpecbookPluginOptions): Plugin {
201
232
  ` ${JSON.stringify(`${mm.service}/${o.id}`)}: ${imp(join(root, outDir, mm.service, 'operations', `${o.id}.ts`))}`,
202
233
  )
203
234
  })
204
- const schemaFiles = await fg('*/schemas/*.request.schema.json', {
205
- cwd: join(root, outDir),
206
- absolute: true,
207
- })
208
- const responseFiles = await fg('*/schemas/*.responses.schema.json', {
209
- cwd: join(root, outDir),
210
- absolute: true,
211
- })
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$/)))
212
245
  const responseEntries = responseFiles.map((abs) => {
213
246
  const rel = relative(join(root, outDir), abs).split(sep)
214
247
  const op = (rel[2] as string).replace(/\.responses\.schema\.json$/, '')
215
248
  return ` ${JSON.stringify(`${rel[0]}/${op}`)}: ${imp(abs)}`
216
249
  })
217
- const defFiles = await fg('*/schemas/definitions.schema.json', {
218
- cwd: join(root, outDir),
219
- absolute: true,
220
- })
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)
221
253
  const schemaEntries = schemaFiles.map((abs) => {
222
254
  const rel = relative(join(root, outDir), abs).split(sep)
223
255
  const op = (rel[2] as string).replace(/\.request\.schema\.json$/, '')
224
256
  return ` ${JSON.stringify(`${rel[0]}/${op}`)}: ${imp(abs)}`
225
257
  })
226
- 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$/)))
227
261
  const operationDocEntries = docFiles.map((abs) => {
228
262
  const rel = relative(join(root, outDir), abs).split(sep)
229
263
  return ` ${JSON.stringify(`${rel[0]}/${(rel[2] as string).replace(/\.json$/, '')}`)}: ${imp(abs)}`
@@ -240,11 +274,16 @@ export function specbook(options: SpecbookPluginOptions): Plugin {
240
274
  )
241
275
  // Sequence titles come from each file's literal header, so listing them imports no sequence.
242
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)))
243
279
  for (const f of files.sequences) {
244
280
  const src = await readFile(join(root, f), 'utf8').catch(() => '')
245
281
  seqHeaders.set(join(root, f), JSON.stringify(parseSequenceMeta(src)))
246
282
  }
247
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]
248
287
  const url = (abs: string) =>
249
288
  isBuild
250
289
  ? `() => import(${JSON.stringify(`${abs}?url`)}).then((m) => m.default)`
@@ -273,7 +312,10 @@ export function specbook(options: SpecbookPluginOptions): Plugin {
273
312
  const assetEntries = Object.entries(scanned.assets).map(
274
313
  ([key, abs]) => ` ${JSON.stringify(key)}: ${url(abs)}`,
275
314
  )
276
- 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
+ )
277
319
  docHtml = pages.map((p) => p.html)
278
320
  const docEntries = pages.map(
279
321
  (p, i) =>
@@ -288,7 +330,11 @@ export function specbook(options: SpecbookPluginOptions): Plugin {
288
330
  if (isBuild) {
289
331
  // The registry stays small: manifests and the operation modules sit in their own chunks, and the
290
332
  // generated JSON (schemas, examples, docs, definitions) is served as plain files under data/.
291
- 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
+ )
292
338
  operationsCode = `export const operations = {\n${operationEntries.join(',\n')}\n}`
293
339
  const gen = join(root, outDir)
294
340
  bundlePlan = new Map()
@@ -348,8 +394,8 @@ export function specbook(options: SpecbookPluginOptions): Plugin {
348
394
  projectLoads.push(` ${JSON.stringify(`${key}/${e.name}`)}: ${imp(e.abs)}`)
349
395
  }
350
396
  const services = Object.keys(manifests)
351
- return `import manifests from ${JSON.stringify(VIRTUAL_MANIFESTS)}
352
- export const mode = "static"
397
+ return `export const mode = "static"
398
+ export const minimal = ${JSON.stringify(!!only)}
353
399
  export const project = ${JSON.stringify({ root, outDir, requestsDir })}
354
400
  // where the data files are: dist/data, whether this chunk is in dist/ or dist/assets/
355
401
  const dataBase = import.meta.url.replace(/(assets\\/)?[^/]*$/, '') + 'data/'
@@ -363,8 +409,13 @@ const bundle = (key) => {
363
409
  }
364
410
  const part = (pick) =>
365
411
  new Proxy({}, { get: (_, key) => (typeof key === 'string' && key.includes('/') ? () => bundle(key).then((b) => ({ default: pick(b) })) : undefined) })
366
- 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)
367
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)}
368
419
  export const sequences = [${files.sequences.map(entry).join(',\n')}]
369
420
  export const sequenceMeta = ${JSON.stringify(sequenceScan.meta)}
370
421
  export const unresolvedSequences = ${JSON.stringify(sequenceScan.unresolved)}
@@ -412,9 +463,13 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
412
463
  `
413
464
  }
414
465
  return `export const mode = ${JSON.stringify(isBuild ? 'static' : 'dev')}
466
+ export const minimal = ${JSON.stringify(!!only)}
415
467
  export const project = ${JSON.stringify({ root, outDir, requestsDir })}
416
468
  export const manifests = ${JSON.stringify(slim(manifests))}
469
+ export const loadManifests = () => Promise.resolve()
417
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)}
418
473
  export const sequences = [${files.sequences.map(entry).join(',\n')}]
419
474
  export const sequenceMeta = ${JSON.stringify(sequenceScan.meta)}
420
475
  export const unresolvedSequences = ${JSON.stringify(sequenceScan.unresolved)}
@@ -434,38 +489,29 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
434
489
  `
435
490
  }
436
491
 
437
- async function getProxyConfig(): Promise<ProxyConfig> {
492
+ async function loadProjectConfig(): Promise<AddressConfig | undefined> {
438
493
  const cfgFile = findConfig(root)
439
- if (!server || !cfgFile) return { services: {}, secretNames: {} }
440
- const mod = await server.ssrLoadModule(cfgFile)
441
- const cfg = (mod.default ?? {}) as {
442
- services?: Record<string, { baseUrl?: Record<string, string> }>
443
- secrets?: Record<string, { env?: string }>
444
- auth?: Record<string, { oauth?: { tokenUrl?: string; openIdConnectUrl?: string } }>
445
- }
446
- const tokenOrigins: Record<string, string> = {}
447
- const discoveryOrigins: Record<string, string> = {}
448
- for (const [name, profile] of Object.entries(cfg.auth ?? {})) {
449
- try {
450
- const { tokenUrl, openIdConnectUrl } = profile.oauth ?? {}
451
- if (tokenUrl) tokenOrigins[name] = new URL(tokenUrl).origin
452
- else if (openIdConnectUrl) {
453
- discoveryOrigins[name] = new URL(openIdConnectUrl).origin
454
- const token = await tokenOriginOf(openIdConnectUrl)
455
- if (token) tokenOrigins[name] = token
456
- }
457
- } catch {
458
- /* an unparsable URL is left out; a sign-in against it fails with a 404 */
459
- }
460
- }
461
- return {
462
- services: Object.fromEntries(
463
- Object.entries(cfg.services ?? {}).map(([k, v]) => [k, { baseUrl: v.baseUrl ?? {} }]),
464
- ),
465
- secretNames: cfg.secrets ?? {},
466
- tokenOrigins,
467
- discoveryOrigins,
468
- }
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 }
469
515
  }
470
516
 
471
517
  return {
@@ -496,6 +542,13 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
496
542
  await Promise.all(statuses.map(async (n) => [n, await read(p.mock[n] as string)])),
497
543
  )
498
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
+ }
499
552
  this.emitFile({
500
553
  type: 'asset',
501
554
  fileName: `data/${key}.json`,
@@ -506,7 +559,11 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
506
559
  this.emitFile({
507
560
  type: 'asset',
508
561
  fileName: `data/${svc}/_definitions.json`,
509
- 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),
510
567
  })
511
568
  },
512
569
  config(_, env) {
@@ -518,7 +575,8 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
518
575
  // manifests are their own chunk (preloaded with the app, so no extra round trip), and
519
576
  // operation modules are grouped by tag instead of one chunk per operation
520
577
  manualChunks(id: string) {
521
- if (id === RESOLVED_MANIFESTS) return 'manifests'
578
+ if (id.startsWith(RESOLVED_MANIFEST))
579
+ return `manifest-${id.slice(RESOLVED_MANIFEST.length)}`
522
580
  return operationChunks.get(id)
523
581
  },
524
582
  },
@@ -530,7 +588,7 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
530
588
  if (id === VIRTUAL) return RESOLVED
531
589
  if (id === VIRTUAL_TYPES) return RESOLVED_TYPES
532
590
  if (id === VIRTUAL_FAKER) return RESOLVED_FAKER
533
- if (id === VIRTUAL_MANIFESTS) return RESOLVED_MANIFESTS
591
+ if (id.startsWith(VIRTUAL_MANIFEST)) return `\0${id}`
534
592
  if (id === VIRTUAL_OPERATIONS) return RESOLVED_OPERATIONS
535
593
  if (id.startsWith(VIRTUAL_FAKER_LOCALE)) return `\0${id}`
536
594
  if (id.startsWith(VIRTUAL_DOC)) return `\0${id}`
@@ -538,7 +596,8 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
538
596
  },
539
597
  async load(id) {
540
598
  if (id === RESOLVED) return registryCode()
541
- if (id === RESOLVED_MANIFESTS) return manifestsCode
599
+ if (id.startsWith(RESOLVED_MANIFEST))
600
+ return manifestCode.get(id.slice(RESOLVED_MANIFEST.length)) ?? 'export default {}'
542
601
  if (id === RESOLVED_OPERATIONS) return operationsCode
543
602
  if (id === RESOLVED_FAKER || id.startsWith(RESOLVED_FAKER_LOCALE)) {
544
603
  // Let Vite resolve the package from the project (ESM entry, not the CJS one `require.resolve` finds).
@@ -588,6 +647,8 @@ ${names.map((n) => ` ${JSON.stringify(n)}: () => import(${JSON.stringify(`${VIR
588
647
  const relevant = (file: string) => {
589
648
  const rel = relative(root, file)
590
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
591
652
  return /\.(req|seq)\.ts$|\.example\.json$|\.md$/.test(rel) || rel.startsWith(outDir)
592
653
  }
593
654
  // one reload per burst of changes: a save also arrives here from the watcher
@@ -672,6 +733,40 @@ ${names.map((n) => ` ${JSON.stringify(n)}: () => import(${JSON.stringify(`${VIR
672
733
  )
673
734
  return json(res, 200, { env, vars: values.vars, secrets, warnings })
674
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
+ }
675
770
  if (
676
771
  (url.pathname === '/__specbook/secret' || url.pathname === '/__specbook/var') &&
677
772
  req.method === 'POST'
package/src/proxy.ts CHANGED
@@ -1,19 +1,26 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http'
2
2
  import { handleProxy as proxy } from '@specbook/core'
3
3
 
4
- export interface ProxyConfig {
5
- services: Record<string, { baseUrl: Record<string, string | undefined> }>
6
- secretNames: Record<string, { env?: string }>
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>
7
8
  /**
8
- * Origin of each OAuth profile's token endpoint, read from the project's own config. A sign-in
9
- * exchanges its code through `<mount>/<env>/auth:<profile>/…`; the page chooses the path, never the host.
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.
10
11
  */
11
- tokenOrigins?: Record<string, string>
12
+ tokenOrigins: Record<string, string>
12
13
  /**
13
14
  * Origin of each OpenID Connect profile's discovery document, addressed as
14
15
  * `<mount>/<env>/discovery:<profile>/…` (the token endpoint may be on another host).
15
16
  */
16
- discoveryOrigins?: Record<string, string>
17
+ discoveryOrigins: Record<string, string>
18
+ }
19
+
20
+ export interface ProxyConfig {
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>
17
24
  }
18
25
 
19
26
  /** The reserved service name a sign-in's token exchange is addressed to. */
@@ -69,6 +76,15 @@ export async function handleProxy(
69
76
  if (!url.startsWith(`${MOUNT}/`)) return false
70
77
 
71
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
+ }
72
88
  const secretsByEnv = new Map<string, Record<string, string>>()
73
89
  const method = req.method ?? 'GET'
74
90
  const headers = new Headers()
@@ -84,12 +100,13 @@ export async function handleProxy(
84
100
  const response = await proxy(request, {
85
101
  mount: MOUNT,
86
102
  policy: {
87
- upstream: (env, service) => {
103
+ upstream: async (env, service) => {
104
+ const a = await addressesFor(env)
88
105
  if (service.startsWith(AUTH_SERVICE_PREFIX))
89
- return config.tokenOrigins?.[decode(service.slice(AUTH_SERVICE_PREFIX.length))]
106
+ return a.tokenOrigins[decode(service.slice(AUTH_SERVICE_PREFIX.length))]
90
107
  if (service.startsWith(DISCOVERY_SERVICE_PREFIX))
91
- return config.discoveryOrigins?.[decode(service.slice(DISCOVERY_SERVICE_PREFIX.length))]
92
- return config.services[service]?.baseUrl[env]
108
+ return a.discoveryOrigins[decode(service.slice(DISCOVERY_SERVICE_PREFIX.length))]
109
+ return a.services[service]
93
110
  },
94
111
  // a developer's own machine: their local services are the point
95
112
  allowPrivateHosts: true,
@@ -0,0 +1,58 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+
4
+ /**
5
+ * Which operation a request file is for, read from its text without importing it:
6
+ * `import { GetPetById } from '#api/petstore'` and `GetPetById.request({...})` make `petstore/GetPetById`. The panel of an
7
+ * embed opens one request, and with this it imports that file instead of every request in the project.
8
+ *
9
+ * `services` are the project's service names; an import counts when the last segment of its address is one of them, so any
10
+ * import prefix works. Anything the text does not settle (no call, two different operations, a computed one) is undefined,
11
+ * and the file is then imported when requests are listed.
12
+ */
13
+ export function parseRequestTarget(src: string, services: ReadonlySet<string>): string | undefined {
14
+ const local = new Map<string, string>()
15
+ for (const m of src.matchAll(/import\s*(?:type\s*)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g)) {
16
+ const service = (m[2] as string).split('/').pop() as string
17
+ if (!services.has(service) || /^import\s*type/.test(m[0])) continue
18
+ for (const part of (m[1] as string).split(',')) {
19
+ const spec = part.trim()
20
+ if (!spec || spec.startsWith('type ')) continue
21
+ const [imported, as] = spec.split(/\s+as\s+/)
22
+ local.set((as ?? imported) as string, `${service}/${imported}`)
23
+ }
24
+ }
25
+ const used = new Set<string>()
26
+ for (const m of src.matchAll(/\b([A-Za-z_$][\w$]*)\s*\.\s*request\s*\(/g)) {
27
+ const key = local.get(m[1] as string)
28
+ if (key) used.add(key)
29
+ }
30
+ return used.size === 1 ? [...used][0] : undefined
31
+ }
32
+
33
+ export interface RequestScan {
34
+ /** project-relative request file → `service/Operation` */
35
+ meta: Record<string, string>
36
+ /** files the text does not settle; the UI imports these */
37
+ unresolved: string[]
38
+ }
39
+
40
+ export async function scanRequests(
41
+ root: string,
42
+ files: string[],
43
+ services: ReadonlySet<string>,
44
+ ): Promise<RequestScan> {
45
+ const meta: Record<string, string> = {}
46
+ const unresolved: string[] = []
47
+ await Promise.all(
48
+ files.map(async (file) => {
49
+ const key = parseRequestTarget(
50
+ await readFile(join(root, file), 'utf8').catch(() => ''),
51
+ services,
52
+ )
53
+ if (key) meta[file] = key
54
+ else unresolved.push(file)
55
+ }),
56
+ )
57
+ return { meta, unresolved: unresolved.sort() }
58
+ }