@specbook/vite 0.0.0-stage → 0.2.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/src/index.ts ADDED
@@ -0,0 +1,743 @@
1
+ import { existsSync, readdirSync } from 'node:fs'
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
3
+ import type { IncomingMessage, ServerResponse } from 'node:http'
4
+ import { createRequire } from 'node:module'
5
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path'
6
+ import fg from 'fast-glob'
7
+ import type { Plugin, ViteDevServer } from 'vite'
8
+ import { createDiscovery } from './discovery'
9
+ import { scanDocs } from './docs'
10
+ import {
11
+ ensureEnvironmentsDir,
12
+ isEnvName,
13
+ isVarName,
14
+ readAllVars,
15
+ readEnvironment,
16
+ secretsFileFor,
17
+ sharedSecretsFile,
18
+ varsFile,
19
+ writeEnvKey,
20
+ } from './environments'
21
+ import { scanExamples } from './examples'
22
+ import { requestsDirFor, safeProjectPath, scanProject } from './files'
23
+ import { handleProxy, type ProxyConfig } from './proxy'
24
+ import { parseSequenceMeta, scanSequences } from './sequences'
25
+
26
+ export * from './environments'
27
+ export { safeProjectPath, scanProject } from './files'
28
+ export type { ProxyConfig, ProxyDeps } from './proxy'
29
+ export { handleProxy } from './proxy'
30
+
31
+ export interface SpecbookPluginOptions {
32
+ /** Absolute path of the user's project (where .specbook/ lives). */
33
+ projectRoot: string
34
+ /** Generated code directory relative to the project root. Default `.generated`. */
35
+ outDir?: string
36
+ }
37
+
38
+ const VIRTUAL = 'virtual:specbook/registry'
39
+ const RESOLVED = `\0${VIRTUAL}`
40
+ const VIRTUAL_TYPES = 'virtual:specbook/types'
41
+ const RESOLVED_TYPES = `\0${VIRTUAL_TYPES}`
42
+ const VIRTUAL_FAKER = 'virtual:specbook/faker'
43
+ const RESOLVED_FAKER = `\0${VIRTUAL_FAKER}`
44
+ const VIRTUAL_FAKER_LOCALE = 'virtual:specbook/faker-locale/'
45
+ const RESOLVED_FAKER_LOCALE = `\0${VIRTUAL_FAKER_LOCALE}`
46
+ const VIRTUAL_MANIFESTS = 'virtual:specbook/manifests'
47
+ const RESOLVED_MANIFESTS = `\0${VIRTUAL_MANIFESTS}`
48
+ const VIRTUAL_OPERATIONS = 'virtual:specbook/operations'
49
+ const RESOLVED_OPERATIONS = `\0${VIRTUAL_OPERATIONS}`
50
+ const VIRTUAL_DOC = 'virtual:specbook/doc/'
51
+ const RESOLVED_DOC = `\0${VIRTUAL_DOC}`
52
+
53
+ function findConfig(root: string): string | undefined {
54
+ return [join(root, '.specbook/specbook.config.ts'), join(root, 'specbook.config.ts')].find(
55
+ existsSync,
56
+ )
57
+ }
58
+
59
+ function json(res: ServerResponse, status: number, body: unknown) {
60
+ res.statusCode = status
61
+ res.setHeader('content-type', 'application/json')
62
+ res.end(JSON.stringify(body))
63
+ }
64
+
65
+ async function readBody(req: IncomingMessage): Promise<string> {
66
+ return new Promise((ok, fail) => {
67
+ let d = ''
68
+ req.on('data', (c) => {
69
+ d += c
70
+ })
71
+ req.on('end', () => ok(d))
72
+ req.on('error', fail)
73
+ })
74
+ }
75
+
76
+ /** Locale names a faker install ships (`de`, `en_US`, ...), from its `locale/` folder next to the entry. */
77
+ function fakerLocales(entry: string): string[] {
78
+ const names = new Set<string>(['en', 'base'])
79
+ const dir = join(dirname(entry), 'locale')
80
+ if (existsSync(dir))
81
+ for (const f of readdirSync(dir)) {
82
+ const m = /^([A-Za-z0-9_]+)\.js$/.exec(f)
83
+ if (m) names.add(m[1] as string)
84
+ }
85
+ return [...names].sort()
86
+ }
87
+
88
+ /** What the sidebar and palette need; descriptions and example titles load per operation (`operationDocs`). */
89
+ function slim(manifests: Record<string, any>) {
90
+ return Object.fromEntries(
91
+ Object.entries(manifests).map(([k, m]) => [
92
+ k,
93
+ {
94
+ ...m,
95
+ operations: m.operations.map(
96
+ ({ description, examples, file, ...o }: Record<string, unknown>) => o,
97
+ ),
98
+ },
99
+ ]),
100
+ )
101
+ }
102
+
103
+ async function collectTypes(root: string, outDir: string) {
104
+ const libs: Record<string, string> = {}
105
+ const req = createRequire(join(root, 'package.json'))
106
+ try {
107
+ const entry = req.resolve('@specbook/core')
108
+ const srcDir = dirname(entry)
109
+ const files = await fg('**/*.ts', { cwd: srcDir, ignore: ['**/*.test.ts'] })
110
+ for (const f of files) {
111
+ libs[`file:///node_modules/@specbook/core/src/${f}`] = await readFile(join(srcDir, f), 'utf8')
112
+ }
113
+ libs['file:///node_modules/@specbook/core/package.json'] = JSON.stringify({
114
+ name: '@specbook/core',
115
+ types: './src/index.ts',
116
+ })
117
+ } catch {
118
+ /* core not resolvable from the project: editor falls back to untyped */
119
+ }
120
+ const genDir = join(root, outDir)
121
+ for (const f of await fg('**/*.ts', { cwd: genDir })) {
122
+ libs[`file:///${outDir}/${f}`] = await readFile(join(genDir, f), 'utf8')
123
+ }
124
+ const project = await scanProject(root)
125
+ const sources: Record<string, string> = {}
126
+ for (const f of project.sources.filter((s) => s.endsWith('.ts'))) {
127
+ sources[f] = await readFile(join(root, f), 'utf8')
128
+ }
129
+ return { libs, sources }
130
+ }
131
+
132
+ export function specbook(options: SpecbookPluginOptions): Plugin {
133
+ const root = resolve(options.projectRoot)
134
+ const outDir = options.outDir ?? '.generated'
135
+ let server: ViteDevServer | undefined
136
+ let isBuild = false
137
+ let docHtml: string[] = []
138
+ /** build only: parts of the registry that live in their own chunks, and the data files served as assets */
139
+ let manifestsCode = 'export default {}'
140
+ let operationsCode = 'export const operations = {}'
141
+ /** `service/Operation` -> the generated files that make up its data bundle */
142
+ let bundlePlan = new Map<
143
+ string,
144
+ {
145
+ schema?: string
146
+ responses?: string
147
+ docs?: string
148
+ examples: Record<string, string>
149
+ mock: Record<string, string>
150
+ }
151
+ >()
152
+ /** `service` -> its shared definitions file */
153
+ let definitionFiles = new Map<string, string>()
154
+ /** absolute operation module -> the chunk it is grouped into */
155
+ let operationChunks = new Map<string, string>()
156
+ const seqHeaders = new Map<string, string>()
157
+ let notifyRegistryChanged: () => void = () => {}
158
+ /** Credentials entered in the UI: never written to disk, gone when the server stops. */
159
+ const memorySecrets: Record<string, Record<string, string>> = {}
160
+ // where each OpenID Connect profile's token endpoint is, read once from its discovery document
161
+ const tokenOriginOf = createDiscovery({ warn: (m) => server?.config.logger.warn(m) })
162
+
163
+ async function registryCode(): Promise<string> {
164
+ const files = await scanProject(root)
165
+ const manifests: Record<string, unknown> = {}
166
+ for (const m of await fg('*/manifest.json', { cwd: join(root, outDir), absolute: true })) {
167
+ const parsed = JSON.parse(await readFile(m, 'utf8'))
168
+ manifests[parsed.service] = parsed
169
+ }
170
+ // 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,
174
+ })
175
+ // dev: cache-busted /@fs imports (files on disk are the source of truth); build: statically analysable
176
+ const imp = (abs: string) => {
177
+ if (isBuild) return `() => import(${JSON.stringify(abs)})`
178
+ // JSON is only turned into a module for a bare `?import` (extra params such as `?t=` disable
179
+ // the transform). Generated JSON changes trigger a full reload, so no cache-busting is needed.
180
+ if (abs.endsWith('.json'))
181
+ return `() => import(/* @vite-ignore */ ${JSON.stringify(`/@fs${abs}`)})`
182
+ return `(v) => import(/* @vite-ignore */ ${JSON.stringify(`/@fs${abs}`)} + '?t=' + v)`
183
+ }
184
+ const raw = (abs: string) =>
185
+ `() => import(${JSON.stringify(`${abs}?raw`)}).then((m) => m.default)`
186
+ const entry = (file: string) =>
187
+ `{ file: ${JSON.stringify(file)}, load: ${imp(join(root, file))}${isBuild ? `, source: ${raw(join(root, file))}` : ''} }`
188
+ const cfg = findConfig(root)
189
+ const requestsDir = requestsDirFor(root)
190
+ existsSync(join(root, 'apis')) && !existsSync(join(root, 'requests')) ? 'apis' : 'requests'
191
+ let fakerEntry: string | undefined
192
+ try {
193
+ fakerEntry = createRequire(join(root, 'package.json')).resolve('@faker-js/faker')
194
+ } catch {
195
+ /* optional peer dependency */
196
+ }
197
+ const operationEntries = Object.values(manifests).flatMap((m) => {
198
+ const mm = m as { service: string; operations: { id: string }[] }
199
+ return mm.operations.map(
200
+ (o) =>
201
+ ` ${JSON.stringify(`${mm.service}/${o.id}`)}: ${imp(join(root, outDir, mm.service, 'operations', `${o.id}.ts`))}`,
202
+ )
203
+ })
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
+ })
212
+ const responseEntries = responseFiles.map((abs) => {
213
+ const rel = relative(join(root, outDir), abs).split(sep)
214
+ const op = (rel[2] as string).replace(/\.responses\.schema\.json$/, '')
215
+ return ` ${JSON.stringify(`${rel[0]}/${op}`)}: ${imp(abs)}`
216
+ })
217
+ const defFiles = await fg('*/schemas/definitions.schema.json', {
218
+ cwd: join(root, outDir),
219
+ absolute: true,
220
+ })
221
+ const schemaEntries = schemaFiles.map((abs) => {
222
+ const rel = relative(join(root, outDir), abs).split(sep)
223
+ const op = (rel[2] as string).replace(/\.request\.schema\.json$/, '')
224
+ return ` ${JSON.stringify(`${rel[0]}/${op}`)}: ${imp(abs)}`
225
+ })
226
+ const docFiles = await fg('*/docs/*.json', { cwd: join(root, outDir), absolute: true })
227
+ const operationDocEntries = docFiles.map((abs) => {
228
+ const rel = relative(join(root, outDir), abs).split(sep)
229
+ return ` ${JSON.stringify(`${rel[0]}/${(rel[2] as string).replace(/\.json$/, '')}`)}: ${imp(abs)}`
230
+ })
231
+ const defEntries = defFiles.map((abs) => {
232
+ const service = relative(join(root, outDir), abs).split(sep)[0]
233
+ return ` ${JSON.stringify(service)}: ${imp(abs)}`
234
+ })
235
+ const known = new Set(
236
+ Object.values(manifests).flatMap((m) => {
237
+ const mm = m as { service: string; operations: { id: string }[] }
238
+ return mm.operations.map((o) => `${mm.service}/${o.id}`)
239
+ }),
240
+ )
241
+ // Sequence titles come from each file's literal header, so listing them imports no sequence.
242
+ const sequenceScan = await scanSequences(root, files.sequences)
243
+ for (const f of files.sequences) {
244
+ const src = await readFile(join(root, f), 'utf8').catch(() => '')
245
+ seqHeaders.set(join(root, f), JSON.stringify(parseSequenceMeta(src)))
246
+ }
247
+ const scanned = await scanExamples(root, outDir, known)
248
+ const url = (abs: string) =>
249
+ isBuild
250
+ ? `() => import(${JSON.stringify(`${abs}?url`)}).then((m) => m.default)`
251
+ : `() => import(/* @vite-ignore */ ${JSON.stringify(`/@fs${abs}?url`)}).then((m) => m.default)`
252
+ const requestExampleEntries = Object.entries(scanned.examples).map(
253
+ ([key, list]) =>
254
+ ` ${JSON.stringify(key)}: [${list
255
+ .map(
256
+ (e) =>
257
+ `{ name: ${JSON.stringify(e.name)}, source: ${JSON.stringify(e.source)}, dir: ${JSON.stringify(e.dir)}, load: ${imp(e.abs)} }`,
258
+ )
259
+ .join(', ')}]`,
260
+ )
261
+ const brandingFiles = await fg(
262
+ '.specbook/branding/{logo,favicon}.{svg,png,jpg,jpeg,webp,ico}',
263
+ {
264
+ cwd: root,
265
+ absolute: true,
266
+ dot: true,
267
+ },
268
+ )
269
+ const brandingEntries = brandingFiles.map((abs) => {
270
+ const name = abs.slice(abs.lastIndexOf('/') + 1).replace(/\.[^.]+$/, '')
271
+ return ` ${JSON.stringify(name)}: ${url(abs)}`
272
+ })
273
+ const assetEntries = Object.entries(scanned.assets).map(
274
+ ([key, abs]) => ` ${JSON.stringify(key)}: ${url(abs)}`,
275
+ )
276
+ const pages = await scanDocs(root)
277
+ docHtml = pages.map((p) => p.html)
278
+ const docEntries = pages.map(
279
+ (p, i) =>
280
+ `{ file: ${JSON.stringify(p.file)}, operation: ${JSON.stringify(p.operation)}, sequence: ${JSON.stringify(p.sequence)}, sequenceFile: ${JSON.stringify(p.sequenceFile)}, requestFile: ${JSON.stringify(p.requestFile)}, replace: ${JSON.stringify(p.replace)}, title: ${JSON.stringify(p.title)}, load: () => import(${JSON.stringify(`${VIRTUAL_DOC}${i}`)}).then((m) => m.default) }`,
281
+ )
282
+ const exampleEntries = examples.map((abs) => {
283
+ const rel = relative(join(root, outDir), abs).split(sep)
284
+ const status = (rel[3] as string).replace(/\.response\.json$/, '')
285
+ const key = `${rel[0]}/${rel[2]}.${status}`
286
+ return ` ${JSON.stringify(key)}: ${imp(abs)}`
287
+ })
288
+ if (isBuild) {
289
+ // The registry stays small: manifests and the operation modules sit in their own chunks, and the
290
+ // generated JSON (schemas, examples, docs, definitions) is served as plain files under data/.
291
+ manifestsCode = `export default ${JSON.stringify(slim(manifests))}`
292
+ operationsCode = `export const operations = {\n${operationEntries.join(',\n')}\n}`
293
+ const gen = join(root, outDir)
294
+ bundlePlan = new Map()
295
+ const slot = (key: string) => {
296
+ const have = bundlePlan.get(key)
297
+ if (have) return have
298
+ const fresh: NonNullable<ReturnType<typeof bundlePlan.get>> = { examples: {}, mock: {} }
299
+ bundlePlan.set(key, fresh)
300
+ return fresh
301
+ }
302
+ for (const abs of schemaFiles) {
303
+ const rel = relative(gen, abs).split(sep)
304
+ slot(`${rel[0]}/${(rel[2] as string).replace(/\.request\.schema\.json$/, '')}`).schema = abs
305
+ }
306
+ for (const abs of responseFiles) {
307
+ const rel = relative(gen, abs).split(sep)
308
+ slot(
309
+ `${rel[0]}/${(rel[2] as string).replace(/\.responses\.schema\.json$/, '')}`,
310
+ ).responses = abs
311
+ }
312
+ for (const abs of docFiles) {
313
+ const rel = relative(gen, abs).split(sep)
314
+ slot(`${rel[0]}/${(rel[2] as string).replace(/\.json$/, '')}`).docs = abs
315
+ }
316
+ for (const abs of examples) {
317
+ const rel = relative(gen, abs).split(sep)
318
+ slot(`${rel[0]}/${rel[2]}`).mock[(rel[3] as string).replace(/\.response\.json$/, '')] = abs
319
+ }
320
+ for (const [key, list] of Object.entries(scanned.examples))
321
+ for (const e of list) if (e.source === 'generated') slot(key).examples[e.name] = e.abs
322
+ definitionFiles = new Map(
323
+ defFiles.map((abs) => [relative(gen, abs).split(sep)[0] as string, abs]),
324
+ )
325
+ // operation modules grouped by tag, so a service is a few dozen chunks rather than a thousand
326
+ operationChunks = new Map()
327
+ for (const m of Object.values(manifests) as {
328
+ service: string
329
+ operations: { id: string; tags: string[] }[]
330
+ }[]) {
331
+ const counts = new Map<string, number>()
332
+ for (const o of m.operations) {
333
+ const tag = (o.tags[0] ?? 'default').replace(/[^A-Za-z0-9_-]+/g, '-')
334
+ const n = counts.get(tag) ?? 0
335
+ counts.set(tag, n + 1)
336
+ operationChunks.set(
337
+ join(gen, m.service, 'operations', `${o.id}.ts`),
338
+ `ops-${m.service}-${tag}-${Math.floor(n / 40)}`,
339
+ )
340
+ }
341
+ }
342
+ const index: Record<string, [string, 'p' | 'g'][]> = {}
343
+ const projectLoads: string[] = []
344
+ for (const [key, list] of Object.entries(scanned.examples)) {
345
+ index[key] = list.map((e) => [e.name, e.source === 'project' ? 'p' : 'g'])
346
+ for (const e of list)
347
+ if (e.source === 'project')
348
+ projectLoads.push(` ${JSON.stringify(`${key}/${e.name}`)}: ${imp(e.abs)}`)
349
+ }
350
+ const services = Object.keys(manifests)
351
+ return `import manifests from ${JSON.stringify(VIRTUAL_MANIFESTS)}
352
+ export const mode = "static"
353
+ export const project = ${JSON.stringify({ root, outDir, requestsDir })}
354
+ // where the data files are: dist/data, whether this chunk is in dist/ or dist/assets/
355
+ const dataBase = import.meta.url.replace(/(assets\\/)?[^/]*$/, '') + 'data/'
356
+ const fetchJson = (rel) =>
357
+ fetch(dataBase + rel).then((r) => (r.ok ? r.json() : undefined), () => undefined)
358
+ // one file per operation holds its schemas, docs, examples and mock responses
359
+ const bundles = new Map()
360
+ const bundle = (key) => {
361
+ if (!bundles.has(key)) bundles.set(key, fetchJson(key + '.json').then((b) => b ?? {}))
362
+ return bundles.get(key)
363
+ }
364
+ const part = (pick) =>
365
+ new Proxy({}, { get: (_, key) => (typeof key === 'string' && key.includes('/') ? () => bundle(key).then((b) => ({ default: pick(b) })) : undefined) })
366
+ export { manifests }
367
+ export const requests = [${files.requests.map(entry).join(',\n')}]
368
+ export const sequences = [${files.sequences.map(entry).join(',\n')}]
369
+ export const sequenceMeta = ${JSON.stringify(sequenceScan.meta)}
370
+ export const unresolvedSequences = ${JSON.stringify(sequenceScan.unresolved)}
371
+ export const brandingAssets = {\n${brandingEntries.join(',\n')}\n}
372
+ export const environmentVars = ${JSON.stringify(await readAllVars(root))}
373
+ const loadOperations = () => import(${JSON.stringify(VIRTUAL_OPERATIONS)}).then((m) => m.operations)
374
+ export const operations = new Proxy({}, {
375
+ get: (_, key) => typeof key === 'string' ? (v) => loadOperations().then((ops) => {
376
+ if (!ops[key]) throw new Error('Unknown operation ' + key)
377
+ return ops[key](v)
378
+ }) : undefined,
379
+ })
380
+ export const schemas = part((b) => b.schema)
381
+ export const responseSchemas = part((b) => b.responses)
382
+ export const operationDocs = part((b) => b.docs)
383
+ export const examples = new Proxy({}, {
384
+ get: (_, key) => {
385
+ if (typeof key !== 'string' || !key.includes('.')) return undefined
386
+ const at = key.lastIndexOf('.')
387
+ return () => bundle(key.slice(0, at)).then((b) => ({ default: b.mock?.[key.slice(at + 1)] }))
388
+ },
389
+ })
390
+ export const definitions = {${services.map((svc) => `\n ${JSON.stringify(svc)}: () => fetchJson(${JSON.stringify(`${svc}/_definitions.json`)}).then((d) => ({ default: d }))`).join(',')}\n}
391
+ const exampleIndex = ${JSON.stringify(index)}
392
+ const projectLoad = {\n${projectLoads.join(',\n')}\n}
393
+ const exampleLists = new Map()
394
+ export const requestExamples = new Proxy({}, {
395
+ get: (_, key) => {
396
+ if (typeof key !== 'string' || !exampleIndex[key]) return undefined
397
+ if (!exampleLists.has(key)) {
398
+ exampleLists.set(key, exampleIndex[key].map(([name, src]) => ({
399
+ name,
400
+ source: src === 'p' ? 'project' : 'generated',
401
+ dir: key + (src === 'p' ? '@project' : '@generated'),
402
+ load: src === 'p' ? projectLoad[key + '/' + name] : () => bundle(key).then((b) => ({ default: b.examples?.[name] })),
403
+ })))
404
+ }
405
+ return exampleLists.get(key)
406
+ },
407
+ })
408
+ export const assets = {\n${assetEntries.join(',\n')}\n}
409
+ export const docs = [${docEntries.join(',\n')}]
410
+ export const loadFaker = ${fakerEntry ? `() => import('virtual:specbook/faker')` : 'null'}
411
+ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: { project: "specbook", services: {} } })'}
412
+ `
413
+ }
414
+ return `export const mode = ${JSON.stringify(isBuild ? 'static' : 'dev')}
415
+ export const project = ${JSON.stringify({ root, outDir, requestsDir })}
416
+ export const manifests = ${JSON.stringify(slim(manifests))}
417
+ export const requests = [${files.requests.map(entry).join(',\n')}]
418
+ export const sequences = [${files.sequences.map(entry).join(',\n')}]
419
+ export const sequenceMeta = ${JSON.stringify(sequenceScan.meta)}
420
+ export const unresolvedSequences = ${JSON.stringify(sequenceScan.unresolved)}
421
+ export const brandingAssets = {\n${brandingEntries.join(',\n')}\n}
422
+ export const environmentVars = ${JSON.stringify(await readAllVars(root))}
423
+ export const operations = {\n${operationEntries.join(',\n')}\n}
424
+ export const schemas = {\n${schemaEntries.join(',\n')}\n}
425
+ export const responseSchemas = {\n${responseEntries.join(',\n')}\n}
426
+ export const operationDocs = {\n${operationDocEntries.join(',\n')}\n}
427
+ export const definitions = {\n${defEntries.join(',\n')}\n}
428
+ export const examples = {\n${exampleEntries.join(',\n')}\n}
429
+ export const requestExamples = {\n${requestExampleEntries.join(',\n')}\n}
430
+ export const assets = {\n${assetEntries.join(',\n')}\n}
431
+ export const docs = [${docEntries.join(',\n')}]
432
+ export const loadFaker = ${fakerEntry ? `() => import('virtual:specbook/faker')` : 'null'}
433
+ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: { project: "specbook", services: {} } })'}
434
+ `
435
+ }
436
+
437
+ async function getProxyConfig(): Promise<ProxyConfig> {
438
+ 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
+ }
469
+ }
470
+
471
+ return {
472
+ name: 'specbook',
473
+ configResolved(c) {
474
+ isBuild = c.command === 'build'
475
+ },
476
+ async generateBundle() {
477
+ const read = async (abs: string) => JSON.parse(await readFile(abs, 'utf8'))
478
+ for (const [key, p] of bundlePlan) {
479
+ const out: Record<string, unknown> = {}
480
+ if (p.schema) out.schema = await read(p.schema)
481
+ if (p.responses) out.responses = await read(p.responses)
482
+ if (p.docs) {
483
+ const docs = (await read(p.docs)) as { description?: string; examples?: unknown[] }
484
+ // nothing worth a file or a request: no text, and no examples beyond the generated pair
485
+ if (docs.description || (docs.examples?.length ?? 0) > 2) out.docs = docs
486
+ }
487
+ const names = Object.keys(p.examples)
488
+ if (names.length) {
489
+ out.examples = Object.fromEntries(
490
+ await Promise.all(names.map(async (n) => [n, await read(p.examples[n] as string)])),
491
+ )
492
+ }
493
+ const statuses = Object.keys(p.mock)
494
+ if (statuses.length) {
495
+ out.mock = Object.fromEntries(
496
+ await Promise.all(statuses.map(async (n) => [n, await read(p.mock[n] as string)])),
497
+ )
498
+ }
499
+ this.emitFile({
500
+ type: 'asset',
501
+ fileName: `data/${key}.json`,
502
+ source: JSON.stringify(out),
503
+ })
504
+ }
505
+ for (const [svc, abs] of definitionFiles)
506
+ this.emitFile({
507
+ type: 'asset',
508
+ fileName: `data/${svc}/_definitions.json`,
509
+ source: await readFile(abs),
510
+ })
511
+ },
512
+ config(_, env) {
513
+ if (env.command !== 'build') return undefined
514
+ return {
515
+ build: {
516
+ rollupOptions: {
517
+ output: {
518
+ // manifests are their own chunk (preloaded with the app, so no extra round trip), and
519
+ // operation modules are grouped by tag instead of one chunk per operation
520
+ manualChunks(id: string) {
521
+ if (id === RESOLVED_MANIFESTS) return 'manifests'
522
+ return operationChunks.get(id)
523
+ },
524
+ },
525
+ },
526
+ },
527
+ }
528
+ },
529
+ resolveId(id) {
530
+ if (id === VIRTUAL) return RESOLVED
531
+ if (id === VIRTUAL_TYPES) return RESOLVED_TYPES
532
+ if (id === VIRTUAL_FAKER) return RESOLVED_FAKER
533
+ if (id === VIRTUAL_MANIFESTS) return RESOLVED_MANIFESTS
534
+ if (id === VIRTUAL_OPERATIONS) return RESOLVED_OPERATIONS
535
+ if (id.startsWith(VIRTUAL_FAKER_LOCALE)) return `\0${id}`
536
+ if (id.startsWith(VIRTUAL_DOC)) return `\0${id}`
537
+ return undefined
538
+ },
539
+ async load(id) {
540
+ if (id === RESOLVED) return registryCode()
541
+ if (id === RESOLVED_MANIFESTS) return manifestsCode
542
+ if (id === RESOLVED_OPERATIONS) return operationsCode
543
+ if (id === RESOLVED_FAKER || id.startsWith(RESOLVED_FAKER_LOCALE)) {
544
+ // Let Vite resolve the package from the project (ESM entry, not the CJS one `require.resolve` finds).
545
+ const r = await this.resolve('@faker-js/faker', join(root, 'package.json'), {
546
+ skipSelf: true,
547
+ })
548
+ if (!r) return 'export {}'
549
+ const pkg = JSON.stringify(r.id)
550
+ if (id.startsWith(RESOLVED_FAKER_LOCALE))
551
+ return `export { ${id.slice(RESOLVED_FAKER_LOCALE.length)} as default } from ${pkg}`
552
+ // Faker's locales are about 2 MB together, so each is its own chunk and only the ones a run
553
+ // needs are fetched: `locales[name]()` resolves to the locale definition.
554
+ // the package's own folder, not Vite's pre-bundled copy of it
555
+ let entry = r.id
556
+ try {
557
+ entry = createRequire(join(root, 'package.json')).resolve('@faker-js/faker')
558
+ } catch {
559
+ /* fall back to the resolved id */
560
+ }
561
+ const names = fakerLocales(entry)
562
+ return `export { Faker, base } from ${pkg}
563
+ export const locales = {
564
+ ${names.map((n) => ` ${JSON.stringify(n)}: () => import(${JSON.stringify(`${VIRTUAL_FAKER_LOCALE}${n}`)}).then((m) => m.default)`).join(',\n')}
565
+ }`
566
+ }
567
+ if (id === RESOLVED_TYPES)
568
+ return `export default ${JSON.stringify(await collectTypes(root, outDir))}`
569
+ if (id.startsWith(RESOLVED_DOC))
570
+ return `export default ${JSON.stringify(docHtml[Number(id.slice(RESOLVED_DOC.length))] ?? '')}`
571
+ return undefined
572
+ },
573
+ configureServer(s) {
574
+ server = s
575
+ // chokidar 4 has no globs: watch the project's own folders (not dependencies or generated output)
576
+ const SKIP = new Set([
577
+ 'node_modules',
578
+ '.git',
579
+ 'dist',
580
+ 'dist-docs',
581
+ '.turbo',
582
+ 'openapi',
583
+ basename(outDir),
584
+ ])
585
+ for (const entry of readdirSync(root, { withFileTypes: true }))
586
+ if (entry.isDirectory() && !SKIP.has(entry.name)) s.watcher.add(join(root, entry.name))
587
+
588
+ const relevant = (file: string) => {
589
+ const rel = relative(root, file)
590
+ if (rel.startsWith('..') || /node_modules/.test(rel)) return false
591
+ return /\.(req|seq)\.ts$|\.example\.json$|\.md$/.test(rel) || rel.startsWith(outDir)
592
+ }
593
+ // one reload per burst of changes: a save also arrives here from the watcher
594
+ let pending: ReturnType<typeof setTimeout> | undefined
595
+ notifyRegistryChanged = () => {
596
+ clearTimeout(pending)
597
+ pending = setTimeout(() => {
598
+ const mod = s.moduleGraph.getModuleById(RESOLVED)
599
+ if (mod) s.moduleGraph.invalidateModule(mod)
600
+ s.ws.send({ type: 'custom', event: 'specbook:registry-changed' })
601
+ }, 60)
602
+ }
603
+ const refresh = (file: string) => {
604
+ if (relevant(file)) notifyRegistryChanged()
605
+ }
606
+ s.watcher.on('add', refresh)
607
+ s.watcher.on('unlink', refresh)
608
+ s.watcher.on('change', async (file) => {
609
+ if (file.endsWith('.md')) return refresh(file)
610
+ if (!file.endsWith('.seq.ts')) return
611
+ // only the listing is affected by the header; the module itself is re-imported on open
612
+ const header = JSON.stringify(
613
+ parseSequenceMeta(await readFile(file, 'utf8').catch(() => '')),
614
+ )
615
+ if (seqHeaders.get(file) !== header) {
616
+ seqHeaders.set(file, header)
617
+ refresh(file)
618
+ }
619
+ })
620
+
621
+ s.middlewares.use(async (req, res, next) => {
622
+ const url = new URL(req.url ?? '/', 'http://localhost')
623
+ if (!url.pathname.startsWith('/__specbook/')) return next()
624
+ try {
625
+ if (url.pathname === '/__specbook/save' && req.method === 'POST') {
626
+ const { path, content, encoding } = JSON.parse(await readBody(req)) as {
627
+ path: string
628
+ content: string
629
+ encoding?: 'base64'
630
+ }
631
+ const abs = safeProjectPath(root, path)
632
+ if (!abs || typeof content !== 'string')
633
+ return json(res, 400, { error: 'path not allowed' })
634
+ await mkdir(dirname(abs), { recursive: true })
635
+ await writeFile(abs, encoding === 'base64' ? Buffer.from(content, 'base64') : content)
636
+ notifyRegistryChanged()
637
+ return json(res, 200, { ok: true })
638
+ }
639
+ if (url.pathname === '/__specbook/source') {
640
+ const abs = safeProjectPath(root, url.searchParams.get('path') ?? '')
641
+ if (!abs || !existsSync(abs)) return json(res, 404, { error: 'not found' })
642
+ res.setHeader('content-type', 'text/plain; charset=utf-8')
643
+ return void res.end(await readFile(abs, 'utf8'))
644
+ }
645
+ if (url.pathname === '/__specbook/types')
646
+ return json(res, 200, await collectTypes(root, outDir))
647
+ if (url.pathname === '/__specbook/environment' && req.method === 'GET') {
648
+ const env = url.searchParams.get('env') ?? ''
649
+ if (!isEnvName(env)) return json(res, 400, { error: 'bad environment name' })
650
+ const cfg = await getProxyConfig()
651
+ const values = await readEnvironment(root, env)
652
+ const secrets = Object.entries(cfg.secretNames).map(([name, ref]) => {
653
+ const key = ref.env ?? name
654
+ const source =
655
+ memorySecrets[env]?.[name] !== undefined || memorySecrets['*']?.[name] !== undefined
656
+ ? 'session'
657
+ : process.env[key] !== undefined
658
+ ? 'process'
659
+ : values.secrets[key] !== undefined
660
+ ? 'environment'
661
+ : values.shared[key] !== undefined
662
+ ? 'shared'
663
+ : undefined
664
+ return { name, key, source }
665
+ })
666
+ // a declared secret sitting in the committed file is the mistake worth catching early
667
+ const warnings = secrets
668
+ .filter((s) => s.key in values.vars)
669
+ .map(
670
+ (s) =>
671
+ `"${s.key}" is a secret but is in ${env}.env, which is committed. Move it to ${env}.secrets.env.`,
672
+ )
673
+ return json(res, 200, { env, vars: values.vars, secrets, warnings })
674
+ }
675
+ if (
676
+ (url.pathname === '/__specbook/secret' || url.pathname === '/__specbook/var') &&
677
+ req.method === 'POST'
678
+ ) {
679
+ // a page on another origin must not be able to plant credentials
680
+ const origin = req.headers.origin
681
+ if (origin && new URL(origin).host !== req.headers.host)
682
+ return json(res, 403, { error: 'cross-origin request refused' })
683
+ const body = JSON.parse(await readBody(req)) as {
684
+ name: string
685
+ value: string | null
686
+ env?: string
687
+ scope?: 'session' | 'environment' | 'shared'
688
+ }
689
+ const { name, env } = body
690
+ const value = body.value === null || body.value === '' ? undefined : body.value
691
+ if (env !== undefined && !isEnvName(env))
692
+ return json(res, 400, { error: 'bad environment name' })
693
+
694
+ if (url.pathname === '/__specbook/var') {
695
+ if (!isVarName(name) || !env)
696
+ return json(res, 400, { error: 'name and env required' })
697
+ await ensureEnvironmentsDir(root)
698
+ await writeEnvKey(varsFile(root, env), name, value)
699
+ return json(res, 200, { ok: true })
700
+ }
701
+
702
+ const cfg = await getProxyConfig()
703
+ const ref = cfg.secretNames[name]
704
+ if (!ref) return json(res, 400, { error: `unknown secret "${name}"` })
705
+ const scope = body.scope ?? 'session'
706
+ if (scope === 'session') {
707
+ const scopeKey = env ?? '*'
708
+ const bucket = memorySecrets[scopeKey] ?? {}
709
+ if (value) bucket[name] = value
710
+ else delete bucket[name]
711
+ memorySecrets[scopeKey] = bucket
712
+ } else if (scope === 'environment') {
713
+ if (!env) return json(res, 400, { error: 'env required' })
714
+ await ensureEnvironmentsDir(root)
715
+ await writeEnvKey(secretsFileFor(root, env), ref.env ?? name, value)
716
+ delete memorySecrets[env]?.[name]
717
+ } else {
718
+ await mkdir(dirname(sharedSecretsFile(root)), { recursive: true })
719
+ await writeEnvKey(sharedSecretsFile(root), ref.env ?? name, value)
720
+ for (const bucket of Object.values(memorySecrets)) delete bucket[name]
721
+ }
722
+ return json(res, 200, { ok: true })
723
+ }
724
+ if (
725
+ await handleProxy(req, res, {
726
+ getConfig: getProxyConfig,
727
+ readSecrets: async (env) => {
728
+ const v = await readEnvironment(root, env)
729
+ return { ...v.shared, ...v.secrets }
730
+ },
731
+ env: process.env,
732
+ memory: memorySecrets,
733
+ })
734
+ )
735
+ return
736
+ next()
737
+ } catch (e) {
738
+ json(res, 500, { error: e instanceof Error ? e.message : String(e) })
739
+ }
740
+ })
741
+ },
742
+ }
743
+ }