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