@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/LICENSE +110 -0
- package/README.md +6 -2
- package/dist/index.js +1439 -0
- package/package.json +58 -3
- package/src/discovery.ts +60 -0
- package/src/docs.ts +102 -0
- package/src/environments.ts +114 -0
- package/src/examples.ts +77 -0
- package/src/files.ts +59 -0
- package/src/index.ts +743 -0
- package/src/proxy.ts +121 -0
- package/src/sequences.ts +135 -0
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
|
+
}
|