@specbook/vite 0.0.0-stage → 0.1.0

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