@specbook/vite 0.1.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/README.md +1 -1
- package/dist/index.js +553 -135
- package/package.json +3 -3
- package/src/addresses.ts +62 -0
- package/src/definitions.ts +26 -0
- package/src/discovery.ts +60 -0
- package/src/examples.ts +2 -0
- package/src/index.ts +160 -44
- package/src/proxy.ts +48 -2
- 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/discovery.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { parseDiscovery } from '@specbook/core'
|
|
2
|
+
|
|
3
|
+
const FETCH_TIMEOUT_MS = 5_000
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Learns, on the server, where an OpenID Connect provider's token endpoint is. The page needs the proxy to
|
|
7
|
+
* forward its code exchange there, and the proxy only forwards to origins it was told about by the
|
|
8
|
+
* project's own config; a discovery document is the config's pointer to that origin. Successes are kept
|
|
9
|
+
* for the life of the dev server; a failure is not retried for a short while, so a provider that is down
|
|
10
|
+
* costs one slow request, not one per call.
|
|
11
|
+
*/
|
|
12
|
+
export function createDiscovery(
|
|
13
|
+
opts: {
|
|
14
|
+
fetch?: typeof fetch
|
|
15
|
+
retryAfterMs?: number
|
|
16
|
+
now?: () => number
|
|
17
|
+
warn?: (message: string) => void
|
|
18
|
+
} = {},
|
|
19
|
+
) {
|
|
20
|
+
const f = opts.fetch ?? fetch
|
|
21
|
+
const now = opts.now ?? Date.now
|
|
22
|
+
const retryAfter = opts.retryAfterMs ?? 30_000
|
|
23
|
+
const found = new Map<string, string>()
|
|
24
|
+
const failedAt = new Map<string, number>()
|
|
25
|
+
const inFlight = new Map<string, Promise<string | undefined>>()
|
|
26
|
+
|
|
27
|
+
async function look(url: string): Promise<string | undefined> {
|
|
28
|
+
try {
|
|
29
|
+
const res = await f(url, {
|
|
30
|
+
headers: { accept: 'application/json' },
|
|
31
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
32
|
+
})
|
|
33
|
+
if (!res.ok) throw new Error(`answered ${res.status}`)
|
|
34
|
+
const origin = new URL(parseDiscovery(await res.json()).tokenUrl).origin
|
|
35
|
+
found.set(url, origin)
|
|
36
|
+
failedAt.delete(url)
|
|
37
|
+
return origin
|
|
38
|
+
} catch (e) {
|
|
39
|
+
failedAt.set(url, now())
|
|
40
|
+
opts.warn?.(
|
|
41
|
+
`Could not read the OpenID Connect discovery document at ${url} (${e instanceof Error ? e.message : String(e)}). Sign in is unavailable until it can; pasting a token still works.`,
|
|
42
|
+
)
|
|
43
|
+
return undefined
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The origin of the provider's token endpoint, or `undefined` when discovery has failed. */
|
|
48
|
+
return function tokenOrigin(url: string): Promise<string | undefined> {
|
|
49
|
+
const known = found.get(url)
|
|
50
|
+
if (known) return Promise.resolve(known)
|
|
51
|
+
const failed = failedAt.get(url)
|
|
52
|
+
if (failed !== undefined && now() - failed < retryAfter) return Promise.resolve(undefined)
|
|
53
|
+
let pending = inFlight.get(url)
|
|
54
|
+
if (!pending) {
|
|
55
|
+
pending = look(url).finally(() => inFlight.delete(url))
|
|
56
|
+
inFlight.set(url, pending)
|
|
57
|
+
}
|
|
58
|
+
return pending
|
|
59
|
+
}
|
|
60
|
+
}
|
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,13 +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'
|
|
11
|
+
import { createDiscovery } from './discovery'
|
|
8
12
|
import { scanDocs } from './docs'
|
|
9
13
|
import {
|
|
10
14
|
ensureEnvironmentsDir,
|
|
11
15
|
isEnvName,
|
|
12
16
|
isVarName,
|
|
17
|
+
listEnvironmentFiles,
|
|
13
18
|
readAllVars,
|
|
14
19
|
readEnvironment,
|
|
15
20
|
secretsFileFor,
|
|
@@ -19,12 +24,13 @@ import {
|
|
|
19
24
|
} from './environments'
|
|
20
25
|
import { scanExamples } from './examples'
|
|
21
26
|
import { requestsDirFor, safeProjectPath, scanProject } from './files'
|
|
22
|
-
import { handleProxy, type ProxyConfig } from './proxy'
|
|
27
|
+
import { type EnvironmentAddresses, handleProxy, type ProxyConfig } from './proxy'
|
|
28
|
+
import { scanRequests } from './requests'
|
|
23
29
|
import { parseSequenceMeta, scanSequences } from './sequences'
|
|
24
30
|
|
|
25
31
|
export * from './environments'
|
|
26
|
-
export { safeProjectPath, scanProject } from './files'
|
|
27
|
-
export type { ProxyConfig, ProxyDeps } from './proxy'
|
|
32
|
+
export { requestsDirFor, safeProjectPath, scanProject } from './files'
|
|
33
|
+
export type { EnvironmentAddresses, ProxyConfig, ProxyDeps } from './proxy'
|
|
28
34
|
export { handleProxy } from './proxy'
|
|
29
35
|
|
|
30
36
|
export interface SpecbookPluginOptions {
|
|
@@ -32,6 +38,12 @@ export interface SpecbookPluginOptions {
|
|
|
32
38
|
projectRoot: string
|
|
33
39
|
/** Generated code directory relative to the project root. Default `.generated`. */
|
|
34
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>
|
|
35
47
|
}
|
|
36
48
|
|
|
37
49
|
const VIRTUAL = 'virtual:specbook/registry'
|
|
@@ -42,8 +54,8 @@ const VIRTUAL_FAKER = 'virtual:specbook/faker'
|
|
|
42
54
|
const RESOLVED_FAKER = `\0${VIRTUAL_FAKER}`
|
|
43
55
|
const VIRTUAL_FAKER_LOCALE = 'virtual:specbook/faker-locale/'
|
|
44
56
|
const RESOLVED_FAKER_LOCALE = `\0${VIRTUAL_FAKER_LOCALE}`
|
|
45
|
-
const
|
|
46
|
-
const
|
|
57
|
+
const VIRTUAL_MANIFEST = 'virtual:specbook/manifest/'
|
|
58
|
+
const RESOLVED_MANIFEST = `\0${VIRTUAL_MANIFEST}`
|
|
47
59
|
const VIRTUAL_OPERATIONS = 'virtual:specbook/operations'
|
|
48
60
|
const RESOLVED_OPERATIONS = `\0${VIRTUAL_OPERATIONS}`
|
|
49
61
|
const VIRTUAL_DOC = 'virtual:specbook/doc/'
|
|
@@ -131,11 +143,17 @@ async function collectTypes(root: string, outDir: string) {
|
|
|
131
143
|
export function specbook(options: SpecbookPluginOptions): Plugin {
|
|
132
144
|
const root = resolve(options.projectRoot)
|
|
133
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>>()
|
|
134
151
|
let server: ViteDevServer | undefined
|
|
135
152
|
let isBuild = false
|
|
136
153
|
let docHtml: string[] = []
|
|
137
154
|
/** build only: parts of the registry that live in their own chunks, and the data files served as assets */
|
|
138
|
-
|
|
155
|
+
/** build: the manifest of each service, one module each */
|
|
156
|
+
let manifestCode = new Map<string, string>()
|
|
139
157
|
let operationsCode = 'export const operations = {}'
|
|
140
158
|
/** `service/Operation` -> the generated files that make up its data bundle */
|
|
141
159
|
let bundlePlan = new Map<
|
|
@@ -156,6 +174,8 @@ export function specbook(options: SpecbookPluginOptions): Plugin {
|
|
|
156
174
|
let notifyRegistryChanged: () => void = () => {}
|
|
157
175
|
/** Credentials entered in the UI: never written to disk, gone when the server stops. */
|
|
158
176
|
const memorySecrets: Record<string, Record<string, string>> = {}
|
|
177
|
+
// where each OpenID Connect profile's token endpoint is, read once from its discovery document
|
|
178
|
+
const tokenOriginOf = createDiscovery({ warn: (m) => server?.config.logger.warn(m) })
|
|
159
179
|
|
|
160
180
|
async function registryCode(): Promise<string> {
|
|
161
181
|
const files = await scanProject(root)
|
|
@@ -164,10 +184,24 @@ export function specbook(options: SpecbookPluginOptions): Plugin {
|
|
|
164
184
|
const parsed = JSON.parse(await readFile(m, 'utf8'))
|
|
165
185
|
manifests[parsed.service] = parsed
|
|
166
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)
|
|
167
196
|
// response examples: <svc>/examples/<Op>/<status|default>.response.json, keyed `svc/Op.<status|default>`
|
|
168
|
-
const examples =
|
|
169
|
-
|
|
170
|
-
|
|
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)
|
|
171
205
|
})
|
|
172
206
|
// dev: cache-busted /@fs imports (files on disk are the source of truth); build: statically analysable
|
|
173
207
|
const imp = (abs: string) => {
|
|
@@ -198,29 +232,32 @@ export function specbook(options: SpecbookPluginOptions): Plugin {
|
|
|
198
232
|
` ${JSON.stringify(`${mm.service}/${o.id}`)}: ${imp(join(root, outDir, mm.service, 'operations', `${o.id}.ts`))}`,
|
|
199
233
|
)
|
|
200
234
|
})
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
}
|
|
205
|
-
const
|
|
206
|
-
cwd: join(root, outDir),
|
|
207
|
-
|
|
208
|
-
|
|
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$/)))
|
|
209
245
|
const responseEntries = responseFiles.map((abs) => {
|
|
210
246
|
const rel = relative(join(root, outDir), abs).split(sep)
|
|
211
247
|
const op = (rel[2] as string).replace(/\.responses\.schema\.json$/, '')
|
|
212
248
|
return ` ${JSON.stringify(`${rel[0]}/${op}`)}: ${imp(abs)}`
|
|
213
249
|
})
|
|
214
|
-
const defFiles =
|
|
215
|
-
cwd: join(root, outDir),
|
|
216
|
-
|
|
217
|
-
})
|
|
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)
|
|
218
253
|
const schemaEntries = schemaFiles.map((abs) => {
|
|
219
254
|
const rel = relative(join(root, outDir), abs).split(sep)
|
|
220
255
|
const op = (rel[2] as string).replace(/\.request\.schema\.json$/, '')
|
|
221
256
|
return ` ${JSON.stringify(`${rel[0]}/${op}`)}: ${imp(abs)}`
|
|
222
257
|
})
|
|
223
|
-
const docFiles =
|
|
258
|
+
const docFiles = (
|
|
259
|
+
await fg('*/docs/*.json', { cwd: join(root, outDir), absolute: true })
|
|
260
|
+
).filter((abs) => inScope(...opOf(abs, /\.json$/)))
|
|
224
261
|
const operationDocEntries = docFiles.map((abs) => {
|
|
225
262
|
const rel = relative(join(root, outDir), abs).split(sep)
|
|
226
263
|
return ` ${JSON.stringify(`${rel[0]}/${(rel[2] as string).replace(/\.json$/, '')}`)}: ${imp(abs)}`
|
|
@@ -237,11 +274,16 @@ export function specbook(options: SpecbookPluginOptions): Plugin {
|
|
|
237
274
|
)
|
|
238
275
|
// Sequence titles come from each file's literal header, so listing them imports no sequence.
|
|
239
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)))
|
|
240
279
|
for (const f of files.sequences) {
|
|
241
280
|
const src = await readFile(join(root, f), 'utf8').catch(() => '')
|
|
242
281
|
seqHeaders.set(join(root, f), JSON.stringify(parseSequenceMeta(src)))
|
|
243
282
|
}
|
|
244
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]
|
|
245
287
|
const url = (abs: string) =>
|
|
246
288
|
isBuild
|
|
247
289
|
? `() => import(${JSON.stringify(`${abs}?url`)}).then((m) => m.default)`
|
|
@@ -270,7 +312,10 @@ export function specbook(options: SpecbookPluginOptions): Plugin {
|
|
|
270
312
|
const assetEntries = Object.entries(scanned.assets).map(
|
|
271
313
|
([key, abs]) => ` ${JSON.stringify(key)}: ${url(abs)}`,
|
|
272
314
|
)
|
|
273
|
-
|
|
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
|
+
)
|
|
274
319
|
docHtml = pages.map((p) => p.html)
|
|
275
320
|
const docEntries = pages.map(
|
|
276
321
|
(p, i) =>
|
|
@@ -285,7 +330,11 @@ export function specbook(options: SpecbookPluginOptions): Plugin {
|
|
|
285
330
|
if (isBuild) {
|
|
286
331
|
// The registry stays small: manifests and the operation modules sit in their own chunks, and the
|
|
287
332
|
// generated JSON (schemas, examples, docs, definitions) is served as plain files under data/.
|
|
288
|
-
|
|
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
|
+
)
|
|
289
338
|
operationsCode = `export const operations = {\n${operationEntries.join(',\n')}\n}`
|
|
290
339
|
const gen = join(root, outDir)
|
|
291
340
|
bundlePlan = new Map()
|
|
@@ -345,8 +394,8 @@ export function specbook(options: SpecbookPluginOptions): Plugin {
|
|
|
345
394
|
projectLoads.push(` ${JSON.stringify(`${key}/${e.name}`)}: ${imp(e.abs)}`)
|
|
346
395
|
}
|
|
347
396
|
const services = Object.keys(manifests)
|
|
348
|
-
return `
|
|
349
|
-
export const
|
|
397
|
+
return `export const mode = "static"
|
|
398
|
+
export const minimal = ${JSON.stringify(!!only)}
|
|
350
399
|
export const project = ${JSON.stringify({ root, outDir, requestsDir })}
|
|
351
400
|
// where the data files are: dist/data, whether this chunk is in dist/ or dist/assets/
|
|
352
401
|
const dataBase = import.meta.url.replace(/(assets\\/)?[^/]*$/, '') + 'data/'
|
|
@@ -360,8 +409,13 @@ const bundle = (key) => {
|
|
|
360
409
|
}
|
|
361
410
|
const part = (pick) =>
|
|
362
411
|
new Proxy({}, { get: (_, key) => (typeof key === 'string' && key.includes('/') ? () => bundle(key).then((b) => ({ default: pick(b) })) : undefined) })
|
|
363
|
-
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)
|
|
364
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)}
|
|
365
419
|
export const sequences = [${files.sequences.map(entry).join(',\n')}]
|
|
366
420
|
export const sequenceMeta = ${JSON.stringify(sequenceScan.meta)}
|
|
367
421
|
export const unresolvedSequences = ${JSON.stringify(sequenceScan.unresolved)}
|
|
@@ -409,9 +463,13 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
|
|
|
409
463
|
`
|
|
410
464
|
}
|
|
411
465
|
return `export const mode = ${JSON.stringify(isBuild ? 'static' : 'dev')}
|
|
466
|
+
export const minimal = ${JSON.stringify(!!only)}
|
|
412
467
|
export const project = ${JSON.stringify({ root, outDir, requestsDir })}
|
|
413
468
|
export const manifests = ${JSON.stringify(slim(manifests))}
|
|
469
|
+
export const loadManifests = () => Promise.resolve()
|
|
414
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)}
|
|
415
473
|
export const sequences = [${files.sequences.map(entry).join(',\n')}]
|
|
416
474
|
export const sequenceMeta = ${JSON.stringify(sequenceScan.meta)}
|
|
417
475
|
export const unresolvedSequences = ${JSON.stringify(sequenceScan.unresolved)}
|
|
@@ -431,20 +489,29 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
|
|
|
431
489
|
`
|
|
432
490
|
}
|
|
433
491
|
|
|
434
|
-
async function
|
|
492
|
+
async function loadProjectConfig(): Promise<AddressConfig | undefined> {
|
|
435
493
|
const cfgFile = findConfig(root)
|
|
436
|
-
if (!server || !cfgFile) return
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
return {
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
)
|
|
446
|
-
|
|
447
|
-
|
|
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 }
|
|
448
515
|
}
|
|
449
516
|
|
|
450
517
|
return {
|
|
@@ -475,6 +542,13 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
|
|
|
475
542
|
await Promise.all(statuses.map(async (n) => [n, await read(p.mock[n] as string)])),
|
|
476
543
|
)
|
|
477
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
|
+
}
|
|
478
552
|
this.emitFile({
|
|
479
553
|
type: 'asset',
|
|
480
554
|
fileName: `data/${key}.json`,
|
|
@@ -485,7 +559,11 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
|
|
|
485
559
|
this.emitFile({
|
|
486
560
|
type: 'asset',
|
|
487
561
|
fileName: `data/${svc}/_definitions.json`,
|
|
488
|
-
source:
|
|
562
|
+
source: only
|
|
563
|
+
? JSON.stringify(
|
|
564
|
+
pruneDefinitions(JSON.parse(await readFile(abs, 'utf8')), neededDefs.get(svc)),
|
|
565
|
+
)
|
|
566
|
+
: await readFile(abs),
|
|
489
567
|
})
|
|
490
568
|
},
|
|
491
569
|
config(_, env) {
|
|
@@ -497,7 +575,8 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
|
|
|
497
575
|
// manifests are their own chunk (preloaded with the app, so no extra round trip), and
|
|
498
576
|
// operation modules are grouped by tag instead of one chunk per operation
|
|
499
577
|
manualChunks(id: string) {
|
|
500
|
-
if (id
|
|
578
|
+
if (id.startsWith(RESOLVED_MANIFEST))
|
|
579
|
+
return `manifest-${id.slice(RESOLVED_MANIFEST.length)}`
|
|
501
580
|
return operationChunks.get(id)
|
|
502
581
|
},
|
|
503
582
|
},
|
|
@@ -509,7 +588,7 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
|
|
|
509
588
|
if (id === VIRTUAL) return RESOLVED
|
|
510
589
|
if (id === VIRTUAL_TYPES) return RESOLVED_TYPES
|
|
511
590
|
if (id === VIRTUAL_FAKER) return RESOLVED_FAKER
|
|
512
|
-
if (id
|
|
591
|
+
if (id.startsWith(VIRTUAL_MANIFEST)) return `\0${id}`
|
|
513
592
|
if (id === VIRTUAL_OPERATIONS) return RESOLVED_OPERATIONS
|
|
514
593
|
if (id.startsWith(VIRTUAL_FAKER_LOCALE)) return `\0${id}`
|
|
515
594
|
if (id.startsWith(VIRTUAL_DOC)) return `\0${id}`
|
|
@@ -517,7 +596,8 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
|
|
|
517
596
|
},
|
|
518
597
|
async load(id) {
|
|
519
598
|
if (id === RESOLVED) return registryCode()
|
|
520
|
-
if (id
|
|
599
|
+
if (id.startsWith(RESOLVED_MANIFEST))
|
|
600
|
+
return manifestCode.get(id.slice(RESOLVED_MANIFEST.length)) ?? 'export default {}'
|
|
521
601
|
if (id === RESOLVED_OPERATIONS) return operationsCode
|
|
522
602
|
if (id === RESOLVED_FAKER || id.startsWith(RESOLVED_FAKER_LOCALE)) {
|
|
523
603
|
// Let Vite resolve the package from the project (ESM entry, not the CJS one `require.resolve` finds).
|
|
@@ -567,6 +647,8 @@ ${names.map((n) => ` ${JSON.stringify(n)}: () => import(${JSON.stringify(`${VIR
|
|
|
567
647
|
const relevant = (file: string) => {
|
|
568
648
|
const rel = relative(root, file)
|
|
569
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
|
|
570
652
|
return /\.(req|seq)\.ts$|\.example\.json$|\.md$/.test(rel) || rel.startsWith(outDir)
|
|
571
653
|
}
|
|
572
654
|
// one reload per burst of changes: a save also arrives here from the watcher
|
|
@@ -651,6 +733,40 @@ ${names.map((n) => ` ${JSON.stringify(n)}: () => import(${JSON.stringify(`${VIR
|
|
|
651
733
|
)
|
|
652
734
|
return json(res, 200, { env, vars: values.vars, secrets, warnings })
|
|
653
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
|
+
}
|
|
654
770
|
if (
|
|
655
771
|
(url.pathname === '/__specbook/secret' || url.pathname === '/__specbook/var') &&
|
|
656
772
|
req.method === 'POST'
|
package/src/proxy.ts
CHANGED
|
@@ -1,9 +1,39 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
2
2
|
import { handleProxy as proxy } from '@specbook/core'
|
|
3
3
|
|
|
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>
|
|
8
|
+
/**
|
|
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.
|
|
11
|
+
*/
|
|
12
|
+
tokenOrigins: Record<string, string>
|
|
13
|
+
/**
|
|
14
|
+
* Origin of each OpenID Connect profile's discovery document, addressed as
|
|
15
|
+
* `<mount>/<env>/discovery:<profile>/…` (the token endpoint may be on another host).
|
|
16
|
+
*/
|
|
17
|
+
discoveryOrigins: Record<string, string>
|
|
18
|
+
}
|
|
19
|
+
|
|
4
20
|
export interface ProxyConfig {
|
|
5
|
-
services: Record<string, { baseUrl: Record<string, string | undefined> }>
|
|
6
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>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The reserved service name a sign-in's token exchange is addressed to. */
|
|
27
|
+
export const AUTH_SERVICE_PREFIX = 'auth:'
|
|
28
|
+
/** The reserved service name a sign-in's discovery document is fetched through. */
|
|
29
|
+
export const DISCOVERY_SERVICE_PREFIX = 'discovery:'
|
|
30
|
+
|
|
31
|
+
const decode = (s: string) => {
|
|
32
|
+
try {
|
|
33
|
+
return decodeURIComponent(s)
|
|
34
|
+
} catch {
|
|
35
|
+
return s
|
|
36
|
+
}
|
|
7
37
|
}
|
|
8
38
|
|
|
9
39
|
export interface ProxyDeps {
|
|
@@ -46,6 +76,15 @@ export async function handleProxy(
|
|
|
46
76
|
if (!url.startsWith(`${MOUNT}/`)) return false
|
|
47
77
|
|
|
48
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
|
+
}
|
|
49
88
|
const secretsByEnv = new Map<string, Record<string, string>>()
|
|
50
89
|
const method = req.method ?? 'GET'
|
|
51
90
|
const headers = new Headers()
|
|
@@ -61,7 +100,14 @@ export async function handleProxy(
|
|
|
61
100
|
const response = await proxy(request, {
|
|
62
101
|
mount: MOUNT,
|
|
63
102
|
policy: {
|
|
64
|
-
upstream: (env, service) =>
|
|
103
|
+
upstream: async (env, service) => {
|
|
104
|
+
const a = await addressesFor(env)
|
|
105
|
+
if (service.startsWith(AUTH_SERVICE_PREFIX))
|
|
106
|
+
return a.tokenOrigins[decode(service.slice(AUTH_SERVICE_PREFIX.length))]
|
|
107
|
+
if (service.startsWith(DISCOVERY_SERVICE_PREFIX))
|
|
108
|
+
return a.discoveryOrigins[decode(service.slice(DISCOVERY_SERVICE_PREFIX.length))]
|
|
109
|
+
return a.services[service]
|
|
110
|
+
},
|
|
65
111
|
// a developer's own machine: their local services are the point
|
|
66
112
|
allowPrivateHosts: true,
|
|
67
113
|
},
|