@specbook/vite 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +110 -0
- package/README.md +6 -2
- package/dist/index.js +1348 -0
- package/package.json +58 -3
- 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 +722 -0
- package/src/proxy.ts +92 -0
- package/src/sequences.ts +135 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1348 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { existsSync as existsSync3, readdirSync } from "fs";
|
|
3
|
+
import { mkdir as mkdir2, readFile as readFile5, writeFile as writeFile2 } from "fs/promises";
|
|
4
|
+
import { createRequire } from "module";
|
|
5
|
+
import { basename, dirname, join as join4, relative as relative4, resolve as resolve2, sep as sep4 } from "path";
|
|
6
|
+
import fg4 from "fast-glob";
|
|
7
|
+
|
|
8
|
+
// src/docs.ts
|
|
9
|
+
import { readFile } from "fs/promises";
|
|
10
|
+
import { relative, sep } from "path";
|
|
11
|
+
import fg from "fast-glob";
|
|
12
|
+
import { marked } from "marked";
|
|
13
|
+
function frontmatter(src) {
|
|
14
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(src);
|
|
15
|
+
if (!m) return { meta: {}, body: src };
|
|
16
|
+
const meta = {};
|
|
17
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
18
|
+
const i = line.indexOf(":");
|
|
19
|
+
if (i > 0)
|
|
20
|
+
meta[line.slice(0, i).trim()] = line.slice(i + 1).trim().replace(/^["']|["']$/g, "");
|
|
21
|
+
}
|
|
22
|
+
return { meta, body: src.slice(m[0].length) };
|
|
23
|
+
}
|
|
24
|
+
var OP_FOLDER = /(?:^|\/)(?:requests|apis)\/([^/]+)\/([^/]+)\/(?:readme|index)\.md$/i;
|
|
25
|
+
var OP_FLAT = /(?:^|\/)(?:requests|apis)\/([^/]+)\/docs\/([^/]+)\.md$/i;
|
|
26
|
+
var NOT_AN_OPERATION = /^(readme|index)$/i;
|
|
27
|
+
function colocated(file) {
|
|
28
|
+
if (/\.seq\.md$/.test(file)) return { sequenceFile: file.replace(/\.md$/, ".ts") };
|
|
29
|
+
if (/\.req\.md$/.test(file)) return { requestFile: file.replace(/\.md$/, ".ts") };
|
|
30
|
+
const folder = OP_FOLDER.exec(file);
|
|
31
|
+
if (folder && folder[2] !== "docs") return { operation: `${folder[1]}/${folder[2]}` };
|
|
32
|
+
const flat = OP_FLAT.exec(file);
|
|
33
|
+
if (flat && !NOT_AN_OPERATION.test(flat[2]))
|
|
34
|
+
return { operation: `${flat[1]}/${flat[2]}` };
|
|
35
|
+
return void 0;
|
|
36
|
+
}
|
|
37
|
+
async function scanDocs(root) {
|
|
38
|
+
const files = await fg("**/*.md", {
|
|
39
|
+
cwd: root,
|
|
40
|
+
absolute: true,
|
|
41
|
+
ignore: ["**/node_modules/**", "**/.generated/**", "**/dist/**", "**/.git/**"]
|
|
42
|
+
});
|
|
43
|
+
const pages = [];
|
|
44
|
+
const claimed = /* @__PURE__ */ new Map();
|
|
45
|
+
for (const abs of files.sort()) {
|
|
46
|
+
const { meta, body } = frontmatter(await readFile(abs, "utf8"));
|
|
47
|
+
const file = relative(root, abs).split(sep).join("/");
|
|
48
|
+
const near = colocated(file);
|
|
49
|
+
if (!near && !meta.operation && !meta.sequence && !meta.page) continue;
|
|
50
|
+
const operation = meta.operation ?? near?.operation;
|
|
51
|
+
if (operation) {
|
|
52
|
+
const first = claimed.get(operation);
|
|
53
|
+
if (first) {
|
|
54
|
+
console.warn(
|
|
55
|
+
`[specbook] ${file} documents ${operation}, which ${first} already does; ignored`
|
|
56
|
+
);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
claimed.set(operation, file);
|
|
60
|
+
}
|
|
61
|
+
pages.push({
|
|
62
|
+
file,
|
|
63
|
+
operation,
|
|
64
|
+
sequence: meta.sequence,
|
|
65
|
+
sequenceFile: near?.sequenceFile,
|
|
66
|
+
requestFile: near?.requestFile,
|
|
67
|
+
replace: meta.replace === "true" || void 0,
|
|
68
|
+
title: meta.title ?? meta.page,
|
|
69
|
+
html: marked.parse(body, { async: false })
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return pages;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// src/environments.ts
|
|
76
|
+
import { existsSync } from "fs";
|
|
77
|
+
import { mkdir, readdir, readFile as readFile2, writeFile } from "fs/promises";
|
|
78
|
+
import { join } from "path";
|
|
79
|
+
|
|
80
|
+
// ../core/src/proxy/policy.ts
|
|
81
|
+
var DEFAULT_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
|
|
82
|
+
var DEFAULT_MAX_BODY = 25 * 1024 * 1024;
|
|
83
|
+
var DEFAULT_TIMEOUT_MS = 6e4;
|
|
84
|
+
var STRIPPED_REQUEST_HEADERS = /* @__PURE__ */ new Set([
|
|
85
|
+
"host",
|
|
86
|
+
"connection",
|
|
87
|
+
"content-length",
|
|
88
|
+
"transfer-encoding",
|
|
89
|
+
"keep-alive",
|
|
90
|
+
"upgrade",
|
|
91
|
+
"te",
|
|
92
|
+
"trailer",
|
|
93
|
+
"proxy-authorization",
|
|
94
|
+
"proxy-connection",
|
|
95
|
+
"cookie",
|
|
96
|
+
"origin",
|
|
97
|
+
"referer",
|
|
98
|
+
"x-forwarded-for",
|
|
99
|
+
"x-forwarded-host",
|
|
100
|
+
"x-forwarded-proto",
|
|
101
|
+
"x-real-ip"
|
|
102
|
+
]);
|
|
103
|
+
var isStrippedHeader = (name) => {
|
|
104
|
+
const n = name.toLowerCase();
|
|
105
|
+
return STRIPPED_REQUEST_HEADERS.has(n) || n.startsWith("sec-fetch-") || n.startsWith("sec-ch-");
|
|
106
|
+
};
|
|
107
|
+
var STRIPPED_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
|
|
108
|
+
"connection",
|
|
109
|
+
"keep-alive",
|
|
110
|
+
"transfer-encoding",
|
|
111
|
+
"upgrade",
|
|
112
|
+
"content-encoding",
|
|
113
|
+
"content-length",
|
|
114
|
+
"set-cookie"
|
|
115
|
+
]);
|
|
116
|
+
function ipv4(host) {
|
|
117
|
+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
118
|
+
if (!m) return void 0;
|
|
119
|
+
const n = m.slice(1).map(Number);
|
|
120
|
+
return n.every((x) => x <= 255) ? n : void 0;
|
|
121
|
+
}
|
|
122
|
+
function isPrivateAddress(host) {
|
|
123
|
+
const h = host.replace(/^\[|\]$/g, "").toLowerCase();
|
|
124
|
+
const v4 = ipv4(h);
|
|
125
|
+
if (v4) {
|
|
126
|
+
const [a, b] = v4;
|
|
127
|
+
return a === 0 || a === 10 || a === 127 || a === 100 && b >= 64 && b <= 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a >= 224;
|
|
128
|
+
}
|
|
129
|
+
if (h.includes(":")) {
|
|
130
|
+
if (h === "::" || h === "::1") return true;
|
|
131
|
+
const mapped = /^::ffff:(.+)$/.exec(h)?.[1];
|
|
132
|
+
if (mapped) {
|
|
133
|
+
const dotted = ipv4(mapped);
|
|
134
|
+
if (dotted) return isPrivateAddress(mapped);
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
return /^f[cd]/.test(h) || /^fe[89ab]/.test(h);
|
|
138
|
+
}
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
var PRIVATE_NAMES = /(^|\.)(localhost|local|internal|lan|home|corp|intranet)$/i;
|
|
142
|
+
function upstreamProblem(raw, opts = {}) {
|
|
143
|
+
let u;
|
|
144
|
+
try {
|
|
145
|
+
u = new URL(raw);
|
|
146
|
+
} catch {
|
|
147
|
+
return "the upstream is not a valid URL";
|
|
148
|
+
}
|
|
149
|
+
if (u.protocol !== "https:" && u.protocol !== "http:") return "only http and https are proxied";
|
|
150
|
+
if (u.username || u.password) return "credentials in a URL are not allowed";
|
|
151
|
+
if (!opts.allowPrivateHosts) {
|
|
152
|
+
if (isPrivateAddress(u.hostname) || PRIVATE_NAMES.test(u.hostname))
|
|
153
|
+
return "private and local addresses are not reachable through this proxy";
|
|
154
|
+
if (/^\d+$/.test(u.hostname) || /^0x/i.test(u.hostname))
|
|
155
|
+
return "numeric host forms are not allowed";
|
|
156
|
+
}
|
|
157
|
+
return void 0;
|
|
158
|
+
}
|
|
159
|
+
function crossSiteProblem(headers, url) {
|
|
160
|
+
const site = headers.get("sec-fetch-site");
|
|
161
|
+
if (site === "cross-site") return "cross-site requests are not accepted";
|
|
162
|
+
const origin = headers.get("origin");
|
|
163
|
+
if (origin && origin !== "null") {
|
|
164
|
+
try {
|
|
165
|
+
if (new URL(origin).host !== url.host) return "requests from another origin are not accepted";
|
|
166
|
+
} catch {
|
|
167
|
+
return "invalid Origin";
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (origin === "null") return "requests from an opaque origin are not accepted";
|
|
171
|
+
return void 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ../core/src/proxy/substitute.ts
|
|
175
|
+
var OPEN = 123;
|
|
176
|
+
var CLOSE = 125;
|
|
177
|
+
var isSpace = (c) => c === 32 || c === 9 || c === 10 || c === 13;
|
|
178
|
+
var isName = (c) => c >= 48 && c <= 57 || c >= 65 && c <= 90 || c >= 97 && c <= 122 || c === 95 || c === 36 || c === 45;
|
|
179
|
+
var PREFIX = [115, 101, 99, 114, 101, 116, 115, 46];
|
|
180
|
+
function placeholderAt(b, i) {
|
|
181
|
+
let p = i + 2;
|
|
182
|
+
while (p < b.length && isSpace(b[p])) p++;
|
|
183
|
+
for (const c of PREFIX) if (b[p++] !== c) return void 0;
|
|
184
|
+
const from = p;
|
|
185
|
+
while (p < b.length && isName(b[p])) p++;
|
|
186
|
+
if (p === from) return void 0;
|
|
187
|
+
const name = String.fromCharCode(...b.subarray(from, p));
|
|
188
|
+
while (p < b.length && isSpace(b[p])) p++;
|
|
189
|
+
if (b[p] !== CLOSE || b[p + 1] !== CLOSE) return void 0;
|
|
190
|
+
return { start: i, end: p + 2, name };
|
|
191
|
+
}
|
|
192
|
+
function find(b) {
|
|
193
|
+
const out = [];
|
|
194
|
+
for (let i = 0; i < b.length - 1; i++) {
|
|
195
|
+
if (b[i] === OPEN && b[i + 1] === OPEN) {
|
|
196
|
+
const hit = placeholderAt(b, i);
|
|
197
|
+
if (hit) {
|
|
198
|
+
out.push(hit);
|
|
199
|
+
i = hit.end - 1;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
var enc = new TextEncoder();
|
|
206
|
+
function secretNames(bytes) {
|
|
207
|
+
return [...new Set(find(bytes).map((h) => h.name))];
|
|
208
|
+
}
|
|
209
|
+
function secretNamesIn(text) {
|
|
210
|
+
return text.includes("{{") ? secretNames(enc.encode(text)) : [];
|
|
211
|
+
}
|
|
212
|
+
function substituteBytes(bytes, values) {
|
|
213
|
+
const hits = find(bytes).filter((h) => values.has(h.name));
|
|
214
|
+
if (hits.length === 0) return bytes;
|
|
215
|
+
const parts = [];
|
|
216
|
+
let at = 0;
|
|
217
|
+
for (const h of hits) {
|
|
218
|
+
parts.push(bytes.subarray(at, h.start), enc.encode(values.get(h.name)));
|
|
219
|
+
at = h.end;
|
|
220
|
+
}
|
|
221
|
+
parts.push(bytes.subarray(at));
|
|
222
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
223
|
+
let o = 0;
|
|
224
|
+
for (const p of parts) {
|
|
225
|
+
out.set(p, o);
|
|
226
|
+
o += p.length;
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
var dec = new TextDecoder();
|
|
231
|
+
function substituteText(text, values) {
|
|
232
|
+
if (!text.includes("{{")) return text;
|
|
233
|
+
return dec.decode(substituteBytes(enc.encode(text), values));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ../core/src/proxy/forward.ts
|
|
237
|
+
var json = (status, body) => new Response(JSON.stringify(body), {
|
|
238
|
+
status,
|
|
239
|
+
headers: { "content-type": "application/json" }
|
|
240
|
+
});
|
|
241
|
+
async function forward(req, target, deps) {
|
|
242
|
+
const started = Date.now();
|
|
243
|
+
const { policy } = deps;
|
|
244
|
+
let bytesIn = 0;
|
|
245
|
+
const done = (res, outcome) => {
|
|
246
|
+
deps.onEvent?.({
|
|
247
|
+
env: target.env,
|
|
248
|
+
service: target.service,
|
|
249
|
+
method: req.method,
|
|
250
|
+
host: safeHost(target.url),
|
|
251
|
+
status: res.status,
|
|
252
|
+
ms: Date.now() - started,
|
|
253
|
+
bytesIn,
|
|
254
|
+
outcome
|
|
255
|
+
});
|
|
256
|
+
return res;
|
|
257
|
+
};
|
|
258
|
+
if (policy.refuseCrossSite !== false) {
|
|
259
|
+
const why = crossSiteProblem(req.headers, new URL(req.url));
|
|
260
|
+
if (why) return done(json(403, { error: why }), "refused");
|
|
261
|
+
}
|
|
262
|
+
const methods = policy.methods ?? DEFAULT_METHODS;
|
|
263
|
+
if (!methods.includes(req.method.toUpperCase()))
|
|
264
|
+
return done(json(405, { error: `${req.method} is not allowed` }), "refused");
|
|
265
|
+
const bad = upstreamProblem(target.url, { allowPrivateHosts: policy.allowPrivateHosts });
|
|
266
|
+
if (bad) return done(json(403, { error: bad }), "refused");
|
|
267
|
+
const max = policy.maxBodyBytes ?? DEFAULT_MAX_BODY;
|
|
268
|
+
const declared = Number(req.headers.get("content-length") ?? 0);
|
|
269
|
+
if (declared > max) return done(json(413, { error: "request body too large" }), "refused");
|
|
270
|
+
let body;
|
|
271
|
+
if (req.method !== "GET" && req.method !== "HEAD" && req.body) {
|
|
272
|
+
body = new Uint8Array(await req.arrayBuffer());
|
|
273
|
+
bytesIn = body.length;
|
|
274
|
+
if (body.length > max) return done(json(413, { error: "request body too large" }), "refused");
|
|
275
|
+
}
|
|
276
|
+
const headerPairs = [...req.headers.entries()].filter(([k]) => !isStrippedHeader(k));
|
|
277
|
+
const asked = /* @__PURE__ */ new Set();
|
|
278
|
+
for (const [, v] of headerPairs) for (const n of secretNamesIn(v)) asked.add(n);
|
|
279
|
+
const url = new URL(target.url);
|
|
280
|
+
const plainQuery = url.search.replace(/%7B/gi, "{").replace(/%7D/gi, "}").replace(/%20/g, " ");
|
|
281
|
+
for (const n of secretNamesIn(plainQuery)) asked.add(n);
|
|
282
|
+
if (body) for (const n of secretNames(body)) asked.add(n);
|
|
283
|
+
const values = /* @__PURE__ */ new Map();
|
|
284
|
+
const missing = [];
|
|
285
|
+
for (const name of asked) {
|
|
286
|
+
const v = await deps.lookup?.(name, { env: target.env, service: target.service });
|
|
287
|
+
if (v === void 0) missing.push(name);
|
|
288
|
+
else values.set(name, v);
|
|
289
|
+
}
|
|
290
|
+
if (missing.length)
|
|
291
|
+
return done(json(400, { error: `Missing secrets: ${missing.join(", ")}` }), "missing-secrets");
|
|
292
|
+
const headers = new Headers();
|
|
293
|
+
for (const [k, v] of headerPairs) headers.set(k, substituteText(v, values));
|
|
294
|
+
if (secretNamesIn(plainQuery).length > 0) {
|
|
295
|
+
const encoded = new Map([...values].map(([k, v]) => [k, encodeURIComponent(v)]));
|
|
296
|
+
url.search = substituteText(plainQuery, encoded);
|
|
297
|
+
}
|
|
298
|
+
const ctl = new AbortController();
|
|
299
|
+
const timer = setTimeout(() => ctl.abort(), policy.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
300
|
+
req.signal?.addEventListener("abort", () => ctl.abort());
|
|
301
|
+
try {
|
|
302
|
+
const upstream = await (deps.fetch ?? fetch)(url.toString(), {
|
|
303
|
+
method: req.method,
|
|
304
|
+
headers,
|
|
305
|
+
body: body ? substituteBytes(body, values) : void 0,
|
|
306
|
+
// a redirect is the caller's to follow: following one could leave the allow-listed host
|
|
307
|
+
redirect: "manual",
|
|
308
|
+
signal: ctl.signal
|
|
309
|
+
});
|
|
310
|
+
const out = new Headers();
|
|
311
|
+
upstream.headers.forEach((v, k) => {
|
|
312
|
+
if (!STRIPPED_RESPONSE_HEADERS.has(k.toLowerCase())) out.set(k, v);
|
|
313
|
+
});
|
|
314
|
+
return done(new Response(upstream.body, { status: upstream.status, headers: out }), "ok");
|
|
315
|
+
} catch (e) {
|
|
316
|
+
if (ctl.signal.aborted) return done(json(504, { error: "the upstream timed out" }), "timeout");
|
|
317
|
+
return done(json(502, { error: e instanceof Error ? e.message : String(e) }), "upstream-error");
|
|
318
|
+
} finally {
|
|
319
|
+
clearTimeout(timer);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
function safeHost(url) {
|
|
323
|
+
try {
|
|
324
|
+
return new URL(url).host;
|
|
325
|
+
} catch {
|
|
326
|
+
return void 0;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
function parseRoute(pathname, mount) {
|
|
330
|
+
const base = mount.replace(/\/+$/, "");
|
|
331
|
+
if (!pathname.startsWith(`${base}/`)) return void 0;
|
|
332
|
+
const m = /^\/([^/]+)\/([^/]+)(\/.*)?$/.exec(pathname.slice(base.length));
|
|
333
|
+
if (!m) return void 0;
|
|
334
|
+
return { env: m[1], service: m[2], rest: m[3] ?? "/" };
|
|
335
|
+
}
|
|
336
|
+
async function handleProxy(req, deps) {
|
|
337
|
+
const url = new URL(req.url);
|
|
338
|
+
const route = parseRoute(url.pathname, deps.mount);
|
|
339
|
+
if (!route) return void 0;
|
|
340
|
+
const base = await deps.policy.upstream?.(route.env, route.service);
|
|
341
|
+
if (!base)
|
|
342
|
+
return json(404, {
|
|
343
|
+
error: `No baseUrl for service "${route.service}" in env "${route.env}"`
|
|
344
|
+
});
|
|
345
|
+
return forward(
|
|
346
|
+
req,
|
|
347
|
+
{
|
|
348
|
+
env: route.env,
|
|
349
|
+
service: route.service,
|
|
350
|
+
url: `${base.replace(/\/+$/, "")}${route.rest}${url.search}`
|
|
351
|
+
},
|
|
352
|
+
deps
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ../core/src/secrets.ts
|
|
357
|
+
function parseDotenv(text) {
|
|
358
|
+
const out = {};
|
|
359
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
360
|
+
const line = raw.trim();
|
|
361
|
+
if (!line || line.startsWith("#")) continue;
|
|
362
|
+
const eq = line.indexOf("=");
|
|
363
|
+
if (eq < 1) continue;
|
|
364
|
+
const key = line.slice(0, eq).trim().replace(/^export\s+/, "");
|
|
365
|
+
let val = line.slice(eq + 1).trim();
|
|
366
|
+
if (val.startsWith('"') && val.endsWith('"') || val.startsWith("'") && val.endsWith("'")) {
|
|
367
|
+
val = val.slice(1, -1);
|
|
368
|
+
}
|
|
369
|
+
out[key] = val;
|
|
370
|
+
}
|
|
371
|
+
return out;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// src/environments.ts
|
|
375
|
+
var ENV_NAME = /^[A-Za-z0-9_-]+$/;
|
|
376
|
+
var VAR_NAME = /^[A-Za-z_][\w.-]*$/;
|
|
377
|
+
var environmentsDir = (root) => join(root, ".specbook", "environments");
|
|
378
|
+
var varsFile = (root, env) => join(environmentsDir(root), `${env}.env`);
|
|
379
|
+
var secretsFileFor = (root, env) => join(environmentsDir(root), `${env}.secrets.env`);
|
|
380
|
+
var sharedSecretsFile = (root) => join(root, ".specbook", "secrets.local.env");
|
|
381
|
+
var isEnvName = (s) => typeof s === "string" && ENV_NAME.test(s);
|
|
382
|
+
var isVarName = (s) => typeof s === "string" && VAR_NAME.test(s);
|
|
383
|
+
async function readParsed(file) {
|
|
384
|
+
return existsSync(file) ? parseDotenv(await readFile2(file, "utf8")) : {};
|
|
385
|
+
}
|
|
386
|
+
async function readEnvironment(root, env) {
|
|
387
|
+
if (!isEnvName(env))
|
|
388
|
+
return { vars: {}, secrets: {}, shared: await readParsed(sharedSecretsFile(root)) };
|
|
389
|
+
const [vars, secrets, shared] = await Promise.all([
|
|
390
|
+
readParsed(varsFile(root, env)),
|
|
391
|
+
readParsed(secretsFileFor(root, env)),
|
|
392
|
+
readParsed(sharedSecretsFile(root))
|
|
393
|
+
]);
|
|
394
|
+
return { vars, secrets, shared };
|
|
395
|
+
}
|
|
396
|
+
async function listEnvironmentFiles(root) {
|
|
397
|
+
const dir = environmentsDir(root);
|
|
398
|
+
if (!existsSync(dir)) return [];
|
|
399
|
+
const names = /* @__PURE__ */ new Set();
|
|
400
|
+
for (const f of await readdir(dir)) {
|
|
401
|
+
const m = /^([A-Za-z0-9_-]+)(\.secrets)?\.env$/.exec(f);
|
|
402
|
+
if (m) names.add(m[1]);
|
|
403
|
+
}
|
|
404
|
+
return [...names].sort();
|
|
405
|
+
}
|
|
406
|
+
async function readAllVars(root) {
|
|
407
|
+
const out = {};
|
|
408
|
+
const dir = environmentsDir(root);
|
|
409
|
+
if (!existsSync(dir)) return out;
|
|
410
|
+
for (const f of await readdir(dir)) {
|
|
411
|
+
const m = /^([A-Za-z0-9_-]+)\.env$/.exec(f);
|
|
412
|
+
if (m) out[m[1]] = parseDotenv(await readFile2(join(dir, f), "utf8"));
|
|
413
|
+
}
|
|
414
|
+
return out;
|
|
415
|
+
}
|
|
416
|
+
var quote = (v) => {
|
|
417
|
+
if (/[\r\n]/.test(v)) throw new Error("values must be a single line");
|
|
418
|
+
if (!/[\s#"'\\]/.test(v)) return v;
|
|
419
|
+
if (!v.includes('"')) return `"${v}"`;
|
|
420
|
+
if (!v.includes("'")) return `'${v}'`;
|
|
421
|
+
return v;
|
|
422
|
+
};
|
|
423
|
+
function setDotenvKey(text, key, value) {
|
|
424
|
+
const lines = text === "" ? [] : text.split("\n");
|
|
425
|
+
const at = lines.findIndex((l) => new RegExp(`^\\s*(?:export\\s+)?${escapeRe(key)}\\s*=`).test(l));
|
|
426
|
+
if (value === void 0) {
|
|
427
|
+
if (at >= 0) lines.splice(at, 1);
|
|
428
|
+
} else if (at >= 0) lines[at] = `${key}=${quote(value)}`;
|
|
429
|
+
else {
|
|
430
|
+
if (lines.length && lines[lines.length - 1] === "") lines.pop();
|
|
431
|
+
lines.push(`${key}=${quote(value)}`);
|
|
432
|
+
}
|
|
433
|
+
const out = lines.join("\n");
|
|
434
|
+
return out === "" || out.endsWith("\n") ? out : `${out}
|
|
435
|
+
`;
|
|
436
|
+
}
|
|
437
|
+
var escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
438
|
+
async function ensureEnvironmentsDir(root) {
|
|
439
|
+
const dir = environmentsDir(root);
|
|
440
|
+
await mkdir(dir, { recursive: true });
|
|
441
|
+
const ignore = join(dir, ".gitignore");
|
|
442
|
+
if (!existsSync(ignore))
|
|
443
|
+
await writeFile(ignore, "# secrets never leave this machine\n*.secrets.env\n");
|
|
444
|
+
return dir;
|
|
445
|
+
}
|
|
446
|
+
async function writeEnvKey(file, key, value) {
|
|
447
|
+
const current = existsSync(file) ? await readFile2(file, "utf8") : "";
|
|
448
|
+
await writeFile(file, setDotenvKey(current, key, value));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// src/examples.ts
|
|
452
|
+
import { relative as relative2, sep as sep2 } from "path";
|
|
453
|
+
import fg2 from "fast-glob";
|
|
454
|
+
var IGNORE = ["**/node_modules/**", "**/.generated/**", "**/dist/**", "**/.git/**"];
|
|
455
|
+
var NAME = /\.example\.json$/;
|
|
456
|
+
async function scanExamples(root, outDir, operations) {
|
|
457
|
+
const examples = {};
|
|
458
|
+
const assets = {};
|
|
459
|
+
const dirs = /* @__PURE__ */ new Map();
|
|
460
|
+
const add = (key, entry) => {
|
|
461
|
+
examples[key] ??= [];
|
|
462
|
+
const list = examples[key];
|
|
463
|
+
const at = list.findIndex((e) => e.name === entry.name);
|
|
464
|
+
if (at >= 0) list[at] = entry;
|
|
465
|
+
else list.push(entry);
|
|
466
|
+
dirs.set(entry.dir, entry.abs.slice(0, entry.abs.lastIndexOf(sep2)));
|
|
467
|
+
};
|
|
468
|
+
const gen = `${root}/${outDir}`;
|
|
469
|
+
for (const abs of await fg2("*/examples/*/*.example.json", { cwd: gen, absolute: true })) {
|
|
470
|
+
const [svc, , id, file] = relative2(gen, abs).split(sep2);
|
|
471
|
+
add(`${svc}/${id}`, {
|
|
472
|
+
name: file.replace(NAME, ""),
|
|
473
|
+
abs,
|
|
474
|
+
source: "generated",
|
|
475
|
+
dir: `${svc}/${id}@generated`
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
for (const abs of await fg2("**/*.example.json", { cwd: root, absolute: true, ignore: IGNORE })) {
|
|
479
|
+
const parts = relative2(root, abs).split(sep2);
|
|
480
|
+
const file = parts.pop();
|
|
481
|
+
const id = parts.pop();
|
|
482
|
+
const svc = parts.pop();
|
|
483
|
+
const key = `${svc}/${id}`;
|
|
484
|
+
if (!svc || !id || !operations.has(key)) continue;
|
|
485
|
+
add(key, {
|
|
486
|
+
name: file.replace(NAME, ""),
|
|
487
|
+
abs,
|
|
488
|
+
source: "project",
|
|
489
|
+
dir: `${svc}/${id}@project`
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
for (const [dir, abs] of dirs) {
|
|
493
|
+
for (const f of await fg2("*", { cwd: abs, absolute: false, ignore: ["*.json"] })) {
|
|
494
|
+
assets[`${dir}/${f}`] = `${abs}/${f}`;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
for (const list of Object.values(examples))
|
|
498
|
+
list.sort((a, b) => order(a) - order(b) || a.name.localeCompare(b.name));
|
|
499
|
+
return { examples, assets };
|
|
500
|
+
}
|
|
501
|
+
var order = (e) => e.source === "project" ? 0 : e.name === "default" ? 2 : e.name === "dynamic" ? 3 : 1;
|
|
502
|
+
|
|
503
|
+
// src/files.ts
|
|
504
|
+
import { existsSync as existsSync2 } from "fs";
|
|
505
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
506
|
+
import { join as join2, relative as relative3, resolve, sep as sep3 } from "path";
|
|
507
|
+
import fg3 from "fast-glob";
|
|
508
|
+
var IGNORE2 = ["**/node_modules/**", "**/.generated/**", "**/dist/**", "**/.git/**"];
|
|
509
|
+
async function scanProject(root) {
|
|
510
|
+
const rel = (e) => e.map((f) => relative3(root, f).split(sep3).join("/")).sort();
|
|
511
|
+
const [requests, sequences, sources] = await Promise.all([
|
|
512
|
+
fg3("**/*.req.ts", { cwd: root, absolute: true, ignore: IGNORE2 }),
|
|
513
|
+
fg3("**/*.seq.ts", { cwd: root, absolute: true, ignore: IGNORE2 }),
|
|
514
|
+
fg3(["**/*.ts", "**/*.json"], {
|
|
515
|
+
cwd: root,
|
|
516
|
+
absolute: true,
|
|
517
|
+
ignore: [
|
|
518
|
+
...IGNORE2,
|
|
519
|
+
"**/package.json",
|
|
520
|
+
"**/tsconfig*.json",
|
|
521
|
+
"**/openapi/**",
|
|
522
|
+
"**/*.d.ts",
|
|
523
|
+
"**/*.example.json"
|
|
524
|
+
]
|
|
525
|
+
})
|
|
526
|
+
]);
|
|
527
|
+
return { requests: rel(requests), sequences: rel(sequences), sources: rel(sources) };
|
|
528
|
+
}
|
|
529
|
+
var WRITABLE = /\.(ts|json|txt|csv|png|jpe?g|gif|webp|pdf|zip|bin)$/;
|
|
530
|
+
var BLOCKED = /(^|\/)(node_modules|\.generated|\.git|dist)(\/|$)|secrets\.local\.env/;
|
|
531
|
+
function safeProjectPath(root, relPath) {
|
|
532
|
+
if (typeof relPath !== "string" || relPath.includes("\0")) return void 0;
|
|
533
|
+
const abs = resolve(root, relPath);
|
|
534
|
+
const rel = relative3(root, abs).split(sep3).join("/");
|
|
535
|
+
if (rel.startsWith("..") || rel === "" || resolve(rel) === rel) return void 0;
|
|
536
|
+
if (BLOCKED.test(rel) || !WRITABLE.test(rel)) return void 0;
|
|
537
|
+
return abs;
|
|
538
|
+
}
|
|
539
|
+
function requestsDirFor(root) {
|
|
540
|
+
return existsSync2(join2(root, "apis")) && !existsSync2(join2(root, "requests")) ? "apis" : "requests";
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// src/proxy.ts
|
|
544
|
+
var MOUNT = "/__specbook/proxy";
|
|
545
|
+
function readBody(req) {
|
|
546
|
+
return new Promise((res, rej) => {
|
|
547
|
+
const parts = [];
|
|
548
|
+
req.on("data", (c) => parts.push(c));
|
|
549
|
+
req.on("end", () => res(new Uint8Array(Buffer.concat(parts))));
|
|
550
|
+
req.on("error", rej);
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
async function handleProxy2(req, res, deps) {
|
|
554
|
+
const url = req.url ?? "/";
|
|
555
|
+
if (!url.startsWith(`${MOUNT}/`)) return false;
|
|
556
|
+
const config = await deps.getConfig();
|
|
557
|
+
const secretsByEnv = /* @__PURE__ */ new Map();
|
|
558
|
+
const method = req.method ?? "GET";
|
|
559
|
+
const headers = new Headers();
|
|
560
|
+
for (const [k, v] of Object.entries(req.headers))
|
|
561
|
+
if (v !== void 0) headers.set(k, Array.isArray(v) ? v.join(", ") : v);
|
|
562
|
+
const body = method === "GET" || method === "HEAD" ? void 0 : await readBody(req);
|
|
563
|
+
const request = new Request(`http://${req.headers.host ?? "localhost"}${url}`, {
|
|
564
|
+
method,
|
|
565
|
+
headers,
|
|
566
|
+
body: body?.length ? body : void 0
|
|
567
|
+
});
|
|
568
|
+
const response = await handleProxy(request, {
|
|
569
|
+
mount: MOUNT,
|
|
570
|
+
policy: {
|
|
571
|
+
upstream: (env, service) => config.services[service]?.baseUrl[env],
|
|
572
|
+
// a developer's own machine: their local services are the point
|
|
573
|
+
allowPrivateHosts: true
|
|
574
|
+
},
|
|
575
|
+
lookup: async (name, ctx) => {
|
|
576
|
+
const env = ctx.env ?? "";
|
|
577
|
+
const key = config.secretNames[name]?.env ?? name;
|
|
578
|
+
const fromMemory = deps.memory?.[env]?.[name] ?? deps.memory?.["*"]?.[name];
|
|
579
|
+
if (fromMemory !== void 0) return fromMemory;
|
|
580
|
+
if (deps.env[key] !== void 0) return deps.env[key];
|
|
581
|
+
let local = secretsByEnv.get(env);
|
|
582
|
+
if (!local) {
|
|
583
|
+
local = await deps.readSecrets(env);
|
|
584
|
+
secretsByEnv.set(env, local);
|
|
585
|
+
}
|
|
586
|
+
return local[key];
|
|
587
|
+
},
|
|
588
|
+
fetch: deps.fetch
|
|
589
|
+
});
|
|
590
|
+
if (!response) return false;
|
|
591
|
+
res.statusCode = response.status;
|
|
592
|
+
response.headers.forEach((v, k) => {
|
|
593
|
+
res.setHeader(k, v);
|
|
594
|
+
});
|
|
595
|
+
if (response.body) for await (const chunk of response.body) res.write(chunk);
|
|
596
|
+
res.end();
|
|
597
|
+
return true;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// src/sequences.ts
|
|
601
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
602
|
+
import { join as join3 } from "path";
|
|
603
|
+
var KEYS = ["id", "title", "description", "group", "tags"];
|
|
604
|
+
function readString(src, i) {
|
|
605
|
+
const q = src[i];
|
|
606
|
+
if (q !== "'" && q !== '"' && q !== "`") return void 0;
|
|
607
|
+
let out = "";
|
|
608
|
+
for (let j = i + 1; j < src.length; j++) {
|
|
609
|
+
const c = src[j];
|
|
610
|
+
if (c === "\\") {
|
|
611
|
+
const n = src[j + 1];
|
|
612
|
+
out += n === "n" ? "\n" : n === "t" ? " " : n;
|
|
613
|
+
j++;
|
|
614
|
+
} else if (c === q) return [out, j + 1];
|
|
615
|
+
else if (q === "`" && c === "$" && src[j + 1] === "{") return void 0;
|
|
616
|
+
else out += c;
|
|
617
|
+
}
|
|
618
|
+
return void 0;
|
|
619
|
+
}
|
|
620
|
+
var skipSpace = (src, i) => {
|
|
621
|
+
while (i < src.length) {
|
|
622
|
+
if (/\s/.test(src[i])) i++;
|
|
623
|
+
else if (src.startsWith("//", i))
|
|
624
|
+
i = src.indexOf("\n", i) < 0 ? src.length : src.indexOf("\n", i);
|
|
625
|
+
else if (src.startsWith("/*", i))
|
|
626
|
+
i = src.indexOf("*/", i) < 0 ? src.length : src.indexOf("*/", i) + 2;
|
|
627
|
+
else break;
|
|
628
|
+
}
|
|
629
|
+
return i;
|
|
630
|
+
};
|
|
631
|
+
function skipValue(src, i) {
|
|
632
|
+
let depth = 0;
|
|
633
|
+
for (let j = i; j < src.length; j++) {
|
|
634
|
+
const c = src[j];
|
|
635
|
+
const s = readString(src, j);
|
|
636
|
+
if (s) j = s[1] - 1;
|
|
637
|
+
else if (c === "`" || c === "'" || c === '"') return src.length;
|
|
638
|
+
else if ("([{".includes(c)) depth++;
|
|
639
|
+
else if (")]}".includes(c)) {
|
|
640
|
+
if (depth === 0) return j;
|
|
641
|
+
depth--;
|
|
642
|
+
} else if (c === "," && depth === 0) return j;
|
|
643
|
+
}
|
|
644
|
+
return src.length;
|
|
645
|
+
}
|
|
646
|
+
function parseSequenceMeta(src) {
|
|
647
|
+
const call = /\bsequence\s*(?:<[^>]*>)?\s*\(\s*\{/.exec(src);
|
|
648
|
+
if (!call) return void 0;
|
|
649
|
+
const found = {};
|
|
650
|
+
let i = call.index + call[0].length;
|
|
651
|
+
for (; ; ) {
|
|
652
|
+
i = skipSpace(src, i);
|
|
653
|
+
if (i >= src.length || src[i] === "}") break;
|
|
654
|
+
const key = /^[A-Za-z_$][\w$]*|^(['"])(.*?)\1/.exec(src.slice(i));
|
|
655
|
+
if (!key) return void 0;
|
|
656
|
+
const name = key[2] ?? key[0];
|
|
657
|
+
i = skipSpace(src, i + key[0].length);
|
|
658
|
+
if (src[i] !== ":") {
|
|
659
|
+
if (KEYS.includes(name)) return void 0;
|
|
660
|
+
i = skipValue(src, i);
|
|
661
|
+
} else {
|
|
662
|
+
i = skipSpace(src, i + 1);
|
|
663
|
+
if (KEYS.includes(name)) {
|
|
664
|
+
const str = readString(src, i);
|
|
665
|
+
if (str) {
|
|
666
|
+
found[name] = str[0];
|
|
667
|
+
i = str[1];
|
|
668
|
+
} else if (src[i] === "[" && name === "tags") {
|
|
669
|
+
const tags = [];
|
|
670
|
+
i++;
|
|
671
|
+
for (; ; ) {
|
|
672
|
+
i = skipSpace(src, i);
|
|
673
|
+
if (src[i] === "]") {
|
|
674
|
+
i++;
|
|
675
|
+
break;
|
|
676
|
+
}
|
|
677
|
+
const t = readString(src, i);
|
|
678
|
+
if (!t) return void 0;
|
|
679
|
+
tags.push(t[0]);
|
|
680
|
+
i = skipSpace(src, t[1]);
|
|
681
|
+
if (src[i] === ",") i++;
|
|
682
|
+
}
|
|
683
|
+
found[name] = tags;
|
|
684
|
+
} else return void 0;
|
|
685
|
+
i = skipSpace(src, i);
|
|
686
|
+
if (src[i] !== "," && src[i] !== "}") return void 0;
|
|
687
|
+
} else i = skipValue(src, i);
|
|
688
|
+
}
|
|
689
|
+
i = skipSpace(src, i);
|
|
690
|
+
if (src[i] === ",") i++;
|
|
691
|
+
}
|
|
692
|
+
if (typeof found.id !== "string" || typeof found.title !== "string") return void 0;
|
|
693
|
+
return found;
|
|
694
|
+
}
|
|
695
|
+
async function scanSequences(root, files) {
|
|
696
|
+
const meta = {};
|
|
697
|
+
const unresolved = [];
|
|
698
|
+
await Promise.all(
|
|
699
|
+
files.map(async (file) => {
|
|
700
|
+
const parsed = parseSequenceMeta(await readFile4(join3(root, file), "utf8").catch(() => ""));
|
|
701
|
+
if (parsed) meta[parsed.id] = { ...parsed, file };
|
|
702
|
+
else unresolved.push(file);
|
|
703
|
+
})
|
|
704
|
+
);
|
|
705
|
+
return { meta, unresolved: unresolved.sort() };
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// src/index.ts
|
|
709
|
+
var VIRTUAL = "virtual:specbook/registry";
|
|
710
|
+
var RESOLVED = `\0${VIRTUAL}`;
|
|
711
|
+
var VIRTUAL_TYPES = "virtual:specbook/types";
|
|
712
|
+
var RESOLVED_TYPES = `\0${VIRTUAL_TYPES}`;
|
|
713
|
+
var VIRTUAL_FAKER = "virtual:specbook/faker";
|
|
714
|
+
var RESOLVED_FAKER = `\0${VIRTUAL_FAKER}`;
|
|
715
|
+
var VIRTUAL_FAKER_LOCALE = "virtual:specbook/faker-locale/";
|
|
716
|
+
var RESOLVED_FAKER_LOCALE = `\0${VIRTUAL_FAKER_LOCALE}`;
|
|
717
|
+
var VIRTUAL_MANIFESTS = "virtual:specbook/manifests";
|
|
718
|
+
var RESOLVED_MANIFESTS = `\0${VIRTUAL_MANIFESTS}`;
|
|
719
|
+
var VIRTUAL_OPERATIONS = "virtual:specbook/operations";
|
|
720
|
+
var RESOLVED_OPERATIONS = `\0${VIRTUAL_OPERATIONS}`;
|
|
721
|
+
var VIRTUAL_DOC = "virtual:specbook/doc/";
|
|
722
|
+
var RESOLVED_DOC = `\0${VIRTUAL_DOC}`;
|
|
723
|
+
function findConfig(root) {
|
|
724
|
+
return [join4(root, ".specbook/specbook.config.ts"), join4(root, "specbook.config.ts")].find(
|
|
725
|
+
existsSync3
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
function json2(res, status, body) {
|
|
729
|
+
res.statusCode = status;
|
|
730
|
+
res.setHeader("content-type", "application/json");
|
|
731
|
+
res.end(JSON.stringify(body));
|
|
732
|
+
}
|
|
733
|
+
async function readBody2(req) {
|
|
734
|
+
return new Promise((ok, fail) => {
|
|
735
|
+
let d = "";
|
|
736
|
+
req.on("data", (c) => {
|
|
737
|
+
d += c;
|
|
738
|
+
});
|
|
739
|
+
req.on("end", () => ok(d));
|
|
740
|
+
req.on("error", fail);
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
function fakerLocales(entry) {
|
|
744
|
+
const names = /* @__PURE__ */ new Set(["en", "base"]);
|
|
745
|
+
const dir = join4(dirname(entry), "locale");
|
|
746
|
+
if (existsSync3(dir))
|
|
747
|
+
for (const f of readdirSync(dir)) {
|
|
748
|
+
const m = /^([A-Za-z0-9_]+)\.js$/.exec(f);
|
|
749
|
+
if (m) names.add(m[1]);
|
|
750
|
+
}
|
|
751
|
+
return [...names].sort();
|
|
752
|
+
}
|
|
753
|
+
function slim(manifests) {
|
|
754
|
+
return Object.fromEntries(
|
|
755
|
+
Object.entries(manifests).map(([k, m]) => [
|
|
756
|
+
k,
|
|
757
|
+
{
|
|
758
|
+
...m,
|
|
759
|
+
operations: m.operations.map(
|
|
760
|
+
({ description, examples, file, ...o }) => o
|
|
761
|
+
)
|
|
762
|
+
}
|
|
763
|
+
])
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
async function collectTypes(root, outDir) {
|
|
767
|
+
const libs = {};
|
|
768
|
+
const req = createRequire(join4(root, "package.json"));
|
|
769
|
+
try {
|
|
770
|
+
const entry = req.resolve("@specbook/core");
|
|
771
|
+
const srcDir = dirname(entry);
|
|
772
|
+
const files = await fg4("**/*.ts", { cwd: srcDir, ignore: ["**/*.test.ts"] });
|
|
773
|
+
for (const f of files) {
|
|
774
|
+
libs[`file:///node_modules/@specbook/core/src/${f}`] = await readFile5(join4(srcDir, f), "utf8");
|
|
775
|
+
}
|
|
776
|
+
libs["file:///node_modules/@specbook/core/package.json"] = JSON.stringify({
|
|
777
|
+
name: "@specbook/core",
|
|
778
|
+
types: "./src/index.ts"
|
|
779
|
+
});
|
|
780
|
+
} catch {
|
|
781
|
+
}
|
|
782
|
+
const genDir = join4(root, outDir);
|
|
783
|
+
for (const f of await fg4("**/*.ts", { cwd: genDir })) {
|
|
784
|
+
libs[`file:///${outDir}/${f}`] = await readFile5(join4(genDir, f), "utf8");
|
|
785
|
+
}
|
|
786
|
+
const project = await scanProject(root);
|
|
787
|
+
const sources = {};
|
|
788
|
+
for (const f of project.sources.filter((s) => s.endsWith(".ts"))) {
|
|
789
|
+
sources[f] = await readFile5(join4(root, f), "utf8");
|
|
790
|
+
}
|
|
791
|
+
return { libs, sources };
|
|
792
|
+
}
|
|
793
|
+
function specbook(options) {
|
|
794
|
+
const root = resolve2(options.projectRoot);
|
|
795
|
+
const outDir = options.outDir ?? ".generated";
|
|
796
|
+
let server;
|
|
797
|
+
let isBuild = false;
|
|
798
|
+
let docHtml = [];
|
|
799
|
+
let manifestsCode = "export default {}";
|
|
800
|
+
let operationsCode = "export const operations = {}";
|
|
801
|
+
let bundlePlan = /* @__PURE__ */ new Map();
|
|
802
|
+
let definitionFiles = /* @__PURE__ */ new Map();
|
|
803
|
+
let operationChunks = /* @__PURE__ */ new Map();
|
|
804
|
+
const seqHeaders = /* @__PURE__ */ new Map();
|
|
805
|
+
let notifyRegistryChanged = () => {
|
|
806
|
+
};
|
|
807
|
+
const memorySecrets = {};
|
|
808
|
+
async function registryCode() {
|
|
809
|
+
const files = await scanProject(root);
|
|
810
|
+
const manifests = {};
|
|
811
|
+
for (const m of await fg4("*/manifest.json", { cwd: join4(root, outDir), absolute: true })) {
|
|
812
|
+
const parsed = JSON.parse(await readFile5(m, "utf8"));
|
|
813
|
+
manifests[parsed.service] = parsed;
|
|
814
|
+
}
|
|
815
|
+
const examples = await fg4("*/examples/*/*.response.json", {
|
|
816
|
+
cwd: join4(root, outDir),
|
|
817
|
+
absolute: true
|
|
818
|
+
});
|
|
819
|
+
const imp = (abs) => {
|
|
820
|
+
if (isBuild) return `() => import(${JSON.stringify(abs)})`;
|
|
821
|
+
if (abs.endsWith(".json"))
|
|
822
|
+
return `() => import(/* @vite-ignore */ ${JSON.stringify(`/@fs${abs}`)})`;
|
|
823
|
+
return `(v) => import(/* @vite-ignore */ ${JSON.stringify(`/@fs${abs}`)} + '?t=' + v)`;
|
|
824
|
+
};
|
|
825
|
+
const raw = (abs) => `() => import(${JSON.stringify(`${abs}?raw`)}).then((m) => m.default)`;
|
|
826
|
+
const entry = (file) => `{ file: ${JSON.stringify(file)}, load: ${imp(join4(root, file))}${isBuild ? `, source: ${raw(join4(root, file))}` : ""} }`;
|
|
827
|
+
const cfg = findConfig(root);
|
|
828
|
+
const requestsDir = requestsDirFor(root);
|
|
829
|
+
existsSync3(join4(root, "apis")) && !existsSync3(join4(root, "requests")) ? "apis" : "requests";
|
|
830
|
+
let fakerEntry;
|
|
831
|
+
try {
|
|
832
|
+
fakerEntry = createRequire(join4(root, "package.json")).resolve("@faker-js/faker");
|
|
833
|
+
} catch {
|
|
834
|
+
}
|
|
835
|
+
const operationEntries = Object.values(manifests).flatMap((m) => {
|
|
836
|
+
const mm = m;
|
|
837
|
+
return mm.operations.map(
|
|
838
|
+
(o) => ` ${JSON.stringify(`${mm.service}/${o.id}`)}: ${imp(join4(root, outDir, mm.service, "operations", `${o.id}.ts`))}`
|
|
839
|
+
);
|
|
840
|
+
});
|
|
841
|
+
const schemaFiles = await fg4("*/schemas/*.request.schema.json", {
|
|
842
|
+
cwd: join4(root, outDir),
|
|
843
|
+
absolute: true
|
|
844
|
+
});
|
|
845
|
+
const responseFiles = await fg4("*/schemas/*.responses.schema.json", {
|
|
846
|
+
cwd: join4(root, outDir),
|
|
847
|
+
absolute: true
|
|
848
|
+
});
|
|
849
|
+
const responseEntries = responseFiles.map((abs) => {
|
|
850
|
+
const rel = relative4(join4(root, outDir), abs).split(sep4);
|
|
851
|
+
const op = rel[2].replace(/\.responses\.schema\.json$/, "");
|
|
852
|
+
return ` ${JSON.stringify(`${rel[0]}/${op}`)}: ${imp(abs)}`;
|
|
853
|
+
});
|
|
854
|
+
const defFiles = await fg4("*/schemas/definitions.schema.json", {
|
|
855
|
+
cwd: join4(root, outDir),
|
|
856
|
+
absolute: true
|
|
857
|
+
});
|
|
858
|
+
const schemaEntries = schemaFiles.map((abs) => {
|
|
859
|
+
const rel = relative4(join4(root, outDir), abs).split(sep4);
|
|
860
|
+
const op = rel[2].replace(/\.request\.schema\.json$/, "");
|
|
861
|
+
return ` ${JSON.stringify(`${rel[0]}/${op}`)}: ${imp(abs)}`;
|
|
862
|
+
});
|
|
863
|
+
const docFiles = await fg4("*/docs/*.json", { cwd: join4(root, outDir), absolute: true });
|
|
864
|
+
const operationDocEntries = docFiles.map((abs) => {
|
|
865
|
+
const rel = relative4(join4(root, outDir), abs).split(sep4);
|
|
866
|
+
return ` ${JSON.stringify(`${rel[0]}/${rel[2].replace(/\.json$/, "")}`)}: ${imp(abs)}`;
|
|
867
|
+
});
|
|
868
|
+
const defEntries = defFiles.map((abs) => {
|
|
869
|
+
const service = relative4(join4(root, outDir), abs).split(sep4)[0];
|
|
870
|
+
return ` ${JSON.stringify(service)}: ${imp(abs)}`;
|
|
871
|
+
});
|
|
872
|
+
const known = new Set(
|
|
873
|
+
Object.values(manifests).flatMap((m) => {
|
|
874
|
+
const mm = m;
|
|
875
|
+
return mm.operations.map((o) => `${mm.service}/${o.id}`);
|
|
876
|
+
})
|
|
877
|
+
);
|
|
878
|
+
const sequenceScan = await scanSequences(root, files.sequences);
|
|
879
|
+
for (const f of files.sequences) {
|
|
880
|
+
const src = await readFile5(join4(root, f), "utf8").catch(() => "");
|
|
881
|
+
seqHeaders.set(join4(root, f), JSON.stringify(parseSequenceMeta(src)));
|
|
882
|
+
}
|
|
883
|
+
const scanned = await scanExamples(root, outDir, known);
|
|
884
|
+
const url = (abs) => isBuild ? `() => import(${JSON.stringify(`${abs}?url`)}).then((m) => m.default)` : `() => import(/* @vite-ignore */ ${JSON.stringify(`/@fs${abs}?url`)}).then((m) => m.default)`;
|
|
885
|
+
const requestExampleEntries = Object.entries(scanned.examples).map(
|
|
886
|
+
([key, list]) => ` ${JSON.stringify(key)}: [${list.map(
|
|
887
|
+
(e) => `{ name: ${JSON.stringify(e.name)}, source: ${JSON.stringify(e.source)}, dir: ${JSON.stringify(e.dir)}, load: ${imp(e.abs)} }`
|
|
888
|
+
).join(", ")}]`
|
|
889
|
+
);
|
|
890
|
+
const brandingFiles = await fg4(
|
|
891
|
+
".specbook/branding/{logo,favicon}.{svg,png,jpg,jpeg,webp,ico}",
|
|
892
|
+
{
|
|
893
|
+
cwd: root,
|
|
894
|
+
absolute: true,
|
|
895
|
+
dot: true
|
|
896
|
+
}
|
|
897
|
+
);
|
|
898
|
+
const brandingEntries = brandingFiles.map((abs) => {
|
|
899
|
+
const name = abs.slice(abs.lastIndexOf("/") + 1).replace(/\.[^.]+$/, "");
|
|
900
|
+
return ` ${JSON.stringify(name)}: ${url(abs)}`;
|
|
901
|
+
});
|
|
902
|
+
const assetEntries = Object.entries(scanned.assets).map(
|
|
903
|
+
([key, abs]) => ` ${JSON.stringify(key)}: ${url(abs)}`
|
|
904
|
+
);
|
|
905
|
+
const pages = await scanDocs(root);
|
|
906
|
+
docHtml = pages.map((p) => p.html);
|
|
907
|
+
const docEntries = pages.map(
|
|
908
|
+
(p, i) => `{ 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) }`
|
|
909
|
+
);
|
|
910
|
+
const exampleEntries = examples.map((abs) => {
|
|
911
|
+
const rel = relative4(join4(root, outDir), abs).split(sep4);
|
|
912
|
+
const status = rel[3].replace(/\.response\.json$/, "");
|
|
913
|
+
const key = `${rel[0]}/${rel[2]}.${status}`;
|
|
914
|
+
return ` ${JSON.stringify(key)}: ${imp(abs)}`;
|
|
915
|
+
});
|
|
916
|
+
if (isBuild) {
|
|
917
|
+
manifestsCode = `export default ${JSON.stringify(slim(manifests))}`;
|
|
918
|
+
operationsCode = `export const operations = {
|
|
919
|
+
${operationEntries.join(",\n")}
|
|
920
|
+
}`;
|
|
921
|
+
const gen = join4(root, outDir);
|
|
922
|
+
bundlePlan = /* @__PURE__ */ new Map();
|
|
923
|
+
const slot = (key) => {
|
|
924
|
+
const have = bundlePlan.get(key);
|
|
925
|
+
if (have) return have;
|
|
926
|
+
const fresh = { examples: {}, mock: {} };
|
|
927
|
+
bundlePlan.set(key, fresh);
|
|
928
|
+
return fresh;
|
|
929
|
+
};
|
|
930
|
+
for (const abs of schemaFiles) {
|
|
931
|
+
const rel = relative4(gen, abs).split(sep4);
|
|
932
|
+
slot(`${rel[0]}/${rel[2].replace(/\.request\.schema\.json$/, "")}`).schema = abs;
|
|
933
|
+
}
|
|
934
|
+
for (const abs of responseFiles) {
|
|
935
|
+
const rel = relative4(gen, abs).split(sep4);
|
|
936
|
+
slot(
|
|
937
|
+
`${rel[0]}/${rel[2].replace(/\.responses\.schema\.json$/, "")}`
|
|
938
|
+
).responses = abs;
|
|
939
|
+
}
|
|
940
|
+
for (const abs of docFiles) {
|
|
941
|
+
const rel = relative4(gen, abs).split(sep4);
|
|
942
|
+
slot(`${rel[0]}/${rel[2].replace(/\.json$/, "")}`).docs = abs;
|
|
943
|
+
}
|
|
944
|
+
for (const abs of examples) {
|
|
945
|
+
const rel = relative4(gen, abs).split(sep4);
|
|
946
|
+
slot(`${rel[0]}/${rel[2]}`).mock[rel[3].replace(/\.response\.json$/, "")] = abs;
|
|
947
|
+
}
|
|
948
|
+
for (const [key, list] of Object.entries(scanned.examples))
|
|
949
|
+
for (const e of list) if (e.source === "generated") slot(key).examples[e.name] = e.abs;
|
|
950
|
+
definitionFiles = new Map(
|
|
951
|
+
defFiles.map((abs) => [relative4(gen, abs).split(sep4)[0], abs])
|
|
952
|
+
);
|
|
953
|
+
operationChunks = /* @__PURE__ */ new Map();
|
|
954
|
+
for (const m of Object.values(manifests)) {
|
|
955
|
+
const counts = /* @__PURE__ */ new Map();
|
|
956
|
+
for (const o of m.operations) {
|
|
957
|
+
const tag = (o.tags[0] ?? "default").replace(/[^A-Za-z0-9_-]+/g, "-");
|
|
958
|
+
const n = counts.get(tag) ?? 0;
|
|
959
|
+
counts.set(tag, n + 1);
|
|
960
|
+
operationChunks.set(
|
|
961
|
+
join4(gen, m.service, "operations", `${o.id}.ts`),
|
|
962
|
+
`ops-${m.service}-${tag}-${Math.floor(n / 40)}`
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
const index = {};
|
|
967
|
+
const projectLoads = [];
|
|
968
|
+
for (const [key, list] of Object.entries(scanned.examples)) {
|
|
969
|
+
index[key] = list.map((e) => [e.name, e.source === "project" ? "p" : "g"]);
|
|
970
|
+
for (const e of list)
|
|
971
|
+
if (e.source === "project")
|
|
972
|
+
projectLoads.push(` ${JSON.stringify(`${key}/${e.name}`)}: ${imp(e.abs)}`);
|
|
973
|
+
}
|
|
974
|
+
const services = Object.keys(manifests);
|
|
975
|
+
return `import manifests from ${JSON.stringify(VIRTUAL_MANIFESTS)}
|
|
976
|
+
export const mode = "static"
|
|
977
|
+
export const project = ${JSON.stringify({ root, outDir, requestsDir })}
|
|
978
|
+
// where the data files are: dist/data, whether this chunk is in dist/ or dist/assets/
|
|
979
|
+
const dataBase = import.meta.url.replace(/(assets\\/)?[^/]*$/, '') + 'data/'
|
|
980
|
+
const fetchJson = (rel) =>
|
|
981
|
+
fetch(dataBase + rel).then((r) => (r.ok ? r.json() : undefined), () => undefined)
|
|
982
|
+
// one file per operation holds its schemas, docs, examples and mock responses
|
|
983
|
+
const bundles = new Map()
|
|
984
|
+
const bundle = (key) => {
|
|
985
|
+
if (!bundles.has(key)) bundles.set(key, fetchJson(key + '.json').then((b) => b ?? {}))
|
|
986
|
+
return bundles.get(key)
|
|
987
|
+
}
|
|
988
|
+
const part = (pick) =>
|
|
989
|
+
new Proxy({}, { get: (_, key) => (typeof key === 'string' && key.includes('/') ? () => bundle(key).then((b) => ({ default: pick(b) })) : undefined) })
|
|
990
|
+
export { manifests }
|
|
991
|
+
export const requests = [${files.requests.map(entry).join(",\n")}]
|
|
992
|
+
export const sequences = [${files.sequences.map(entry).join(",\n")}]
|
|
993
|
+
export const sequenceMeta = ${JSON.stringify(sequenceScan.meta)}
|
|
994
|
+
export const unresolvedSequences = ${JSON.stringify(sequenceScan.unresolved)}
|
|
995
|
+
export const brandingAssets = {
|
|
996
|
+
${brandingEntries.join(",\n")}
|
|
997
|
+
}
|
|
998
|
+
export const environmentVars = ${JSON.stringify(await readAllVars(root))}
|
|
999
|
+
const loadOperations = () => import(${JSON.stringify(VIRTUAL_OPERATIONS)}).then((m) => m.operations)
|
|
1000
|
+
export const operations = new Proxy({}, {
|
|
1001
|
+
get: (_, key) => typeof key === 'string' ? (v) => loadOperations().then((ops) => {
|
|
1002
|
+
if (!ops[key]) throw new Error('Unknown operation ' + key)
|
|
1003
|
+
return ops[key](v)
|
|
1004
|
+
}) : undefined,
|
|
1005
|
+
})
|
|
1006
|
+
export const schemas = part((b) => b.schema)
|
|
1007
|
+
export const responseSchemas = part((b) => b.responses)
|
|
1008
|
+
export const operationDocs = part((b) => b.docs)
|
|
1009
|
+
export const examples = new Proxy({}, {
|
|
1010
|
+
get: (_, key) => {
|
|
1011
|
+
if (typeof key !== 'string' || !key.includes('.')) return undefined
|
|
1012
|
+
const at = key.lastIndexOf('.')
|
|
1013
|
+
return () => bundle(key.slice(0, at)).then((b) => ({ default: b.mock?.[key.slice(at + 1)] }))
|
|
1014
|
+
},
|
|
1015
|
+
})
|
|
1016
|
+
export const definitions = {${services.map((svc) => `
|
|
1017
|
+
${JSON.stringify(svc)}: () => fetchJson(${JSON.stringify(`${svc}/_definitions.json`)}).then((d) => ({ default: d }))`).join(",")}
|
|
1018
|
+
}
|
|
1019
|
+
const exampleIndex = ${JSON.stringify(index)}
|
|
1020
|
+
const projectLoad = {
|
|
1021
|
+
${projectLoads.join(",\n")}
|
|
1022
|
+
}
|
|
1023
|
+
const exampleLists = new Map()
|
|
1024
|
+
export const requestExamples = new Proxy({}, {
|
|
1025
|
+
get: (_, key) => {
|
|
1026
|
+
if (typeof key !== 'string' || !exampleIndex[key]) return undefined
|
|
1027
|
+
if (!exampleLists.has(key)) {
|
|
1028
|
+
exampleLists.set(key, exampleIndex[key].map(([name, src]) => ({
|
|
1029
|
+
name,
|
|
1030
|
+
source: src === 'p' ? 'project' : 'generated',
|
|
1031
|
+
dir: key + (src === 'p' ? '@project' : '@generated'),
|
|
1032
|
+
load: src === 'p' ? projectLoad[key + '/' + name] : () => bundle(key).then((b) => ({ default: b.examples?.[name] })),
|
|
1033
|
+
})))
|
|
1034
|
+
}
|
|
1035
|
+
return exampleLists.get(key)
|
|
1036
|
+
},
|
|
1037
|
+
})
|
|
1038
|
+
export const assets = {
|
|
1039
|
+
${assetEntries.join(",\n")}
|
|
1040
|
+
}
|
|
1041
|
+
export const docs = [${docEntries.join(",\n")}]
|
|
1042
|
+
export const loadFaker = ${fakerEntry ? `() => import('virtual:specbook/faker')` : "null"}
|
|
1043
|
+
export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: { project: "specbook", services: {} } })'}
|
|
1044
|
+
`;
|
|
1045
|
+
}
|
|
1046
|
+
return `export const mode = ${JSON.stringify(isBuild ? "static" : "dev")}
|
|
1047
|
+
export const project = ${JSON.stringify({ root, outDir, requestsDir })}
|
|
1048
|
+
export const manifests = ${JSON.stringify(slim(manifests))}
|
|
1049
|
+
export const requests = [${files.requests.map(entry).join(",\n")}]
|
|
1050
|
+
export const sequences = [${files.sequences.map(entry).join(",\n")}]
|
|
1051
|
+
export const sequenceMeta = ${JSON.stringify(sequenceScan.meta)}
|
|
1052
|
+
export const unresolvedSequences = ${JSON.stringify(sequenceScan.unresolved)}
|
|
1053
|
+
export const brandingAssets = {
|
|
1054
|
+
${brandingEntries.join(",\n")}
|
|
1055
|
+
}
|
|
1056
|
+
export const environmentVars = ${JSON.stringify(await readAllVars(root))}
|
|
1057
|
+
export const operations = {
|
|
1058
|
+
${operationEntries.join(",\n")}
|
|
1059
|
+
}
|
|
1060
|
+
export const schemas = {
|
|
1061
|
+
${schemaEntries.join(",\n")}
|
|
1062
|
+
}
|
|
1063
|
+
export const responseSchemas = {
|
|
1064
|
+
${responseEntries.join(",\n")}
|
|
1065
|
+
}
|
|
1066
|
+
export const operationDocs = {
|
|
1067
|
+
${operationDocEntries.join(",\n")}
|
|
1068
|
+
}
|
|
1069
|
+
export const definitions = {
|
|
1070
|
+
${defEntries.join(",\n")}
|
|
1071
|
+
}
|
|
1072
|
+
export const examples = {
|
|
1073
|
+
${exampleEntries.join(",\n")}
|
|
1074
|
+
}
|
|
1075
|
+
export const requestExamples = {
|
|
1076
|
+
${requestExampleEntries.join(",\n")}
|
|
1077
|
+
}
|
|
1078
|
+
export const assets = {
|
|
1079
|
+
${assetEntries.join(",\n")}
|
|
1080
|
+
}
|
|
1081
|
+
export const docs = [${docEntries.join(",\n")}]
|
|
1082
|
+
export const loadFaker = ${fakerEntry ? `() => import('virtual:specbook/faker')` : "null"}
|
|
1083
|
+
export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: { project: "specbook", services: {} } })'}
|
|
1084
|
+
`;
|
|
1085
|
+
}
|
|
1086
|
+
async function getProxyConfig() {
|
|
1087
|
+
const cfgFile = findConfig(root);
|
|
1088
|
+
if (!server || !cfgFile) return { services: {}, secretNames: {} };
|
|
1089
|
+
const mod = await server.ssrLoadModule(cfgFile);
|
|
1090
|
+
const cfg = mod.default ?? {};
|
|
1091
|
+
return {
|
|
1092
|
+
services: Object.fromEntries(
|
|
1093
|
+
Object.entries(cfg.services ?? {}).map(([k, v]) => [k, { baseUrl: v.baseUrl ?? {} }])
|
|
1094
|
+
),
|
|
1095
|
+
secretNames: cfg.secrets ?? {}
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
return {
|
|
1099
|
+
name: "specbook",
|
|
1100
|
+
configResolved(c) {
|
|
1101
|
+
isBuild = c.command === "build";
|
|
1102
|
+
},
|
|
1103
|
+
async generateBundle() {
|
|
1104
|
+
const read = async (abs) => JSON.parse(await readFile5(abs, "utf8"));
|
|
1105
|
+
for (const [key, p] of bundlePlan) {
|
|
1106
|
+
const out = {};
|
|
1107
|
+
if (p.schema) out.schema = await read(p.schema);
|
|
1108
|
+
if (p.responses) out.responses = await read(p.responses);
|
|
1109
|
+
if (p.docs) {
|
|
1110
|
+
const docs = await read(p.docs);
|
|
1111
|
+
if (docs.description || (docs.examples?.length ?? 0) > 2) out.docs = docs;
|
|
1112
|
+
}
|
|
1113
|
+
const names = Object.keys(p.examples);
|
|
1114
|
+
if (names.length) {
|
|
1115
|
+
out.examples = Object.fromEntries(
|
|
1116
|
+
await Promise.all(names.map(async (n) => [n, await read(p.examples[n])]))
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
const statuses = Object.keys(p.mock);
|
|
1120
|
+
if (statuses.length) {
|
|
1121
|
+
out.mock = Object.fromEntries(
|
|
1122
|
+
await Promise.all(statuses.map(async (n) => [n, await read(p.mock[n])]))
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
this.emitFile({
|
|
1126
|
+
type: "asset",
|
|
1127
|
+
fileName: `data/${key}.json`,
|
|
1128
|
+
source: JSON.stringify(out)
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
for (const [svc, abs] of definitionFiles)
|
|
1132
|
+
this.emitFile({
|
|
1133
|
+
type: "asset",
|
|
1134
|
+
fileName: `data/${svc}/_definitions.json`,
|
|
1135
|
+
source: await readFile5(abs)
|
|
1136
|
+
});
|
|
1137
|
+
},
|
|
1138
|
+
config(_, env) {
|
|
1139
|
+
if (env.command !== "build") return void 0;
|
|
1140
|
+
return {
|
|
1141
|
+
build: {
|
|
1142
|
+
rollupOptions: {
|
|
1143
|
+
output: {
|
|
1144
|
+
// manifests are their own chunk (preloaded with the app, so no extra round trip), and
|
|
1145
|
+
// operation modules are grouped by tag instead of one chunk per operation
|
|
1146
|
+
manualChunks(id) {
|
|
1147
|
+
if (id === RESOLVED_MANIFESTS) return "manifests";
|
|
1148
|
+
return operationChunks.get(id);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
};
|
|
1154
|
+
},
|
|
1155
|
+
resolveId(id) {
|
|
1156
|
+
if (id === VIRTUAL) return RESOLVED;
|
|
1157
|
+
if (id === VIRTUAL_TYPES) return RESOLVED_TYPES;
|
|
1158
|
+
if (id === VIRTUAL_FAKER) return RESOLVED_FAKER;
|
|
1159
|
+
if (id === VIRTUAL_MANIFESTS) return RESOLVED_MANIFESTS;
|
|
1160
|
+
if (id === VIRTUAL_OPERATIONS) return RESOLVED_OPERATIONS;
|
|
1161
|
+
if (id.startsWith(VIRTUAL_FAKER_LOCALE)) return `\0${id}`;
|
|
1162
|
+
if (id.startsWith(VIRTUAL_DOC)) return `\0${id}`;
|
|
1163
|
+
return void 0;
|
|
1164
|
+
},
|
|
1165
|
+
async load(id) {
|
|
1166
|
+
if (id === RESOLVED) return registryCode();
|
|
1167
|
+
if (id === RESOLVED_MANIFESTS) return manifestsCode;
|
|
1168
|
+
if (id === RESOLVED_OPERATIONS) return operationsCode;
|
|
1169
|
+
if (id === RESOLVED_FAKER || id.startsWith(RESOLVED_FAKER_LOCALE)) {
|
|
1170
|
+
const r = await this.resolve("@faker-js/faker", join4(root, "package.json"), {
|
|
1171
|
+
skipSelf: true
|
|
1172
|
+
});
|
|
1173
|
+
if (!r) return "export {}";
|
|
1174
|
+
const pkg = JSON.stringify(r.id);
|
|
1175
|
+
if (id.startsWith(RESOLVED_FAKER_LOCALE))
|
|
1176
|
+
return `export { ${id.slice(RESOLVED_FAKER_LOCALE.length)} as default } from ${pkg}`;
|
|
1177
|
+
let entry = r.id;
|
|
1178
|
+
try {
|
|
1179
|
+
entry = createRequire(join4(root, "package.json")).resolve("@faker-js/faker");
|
|
1180
|
+
} catch {
|
|
1181
|
+
}
|
|
1182
|
+
const names = fakerLocales(entry);
|
|
1183
|
+
return `export { Faker, base } from ${pkg}
|
|
1184
|
+
export const locales = {
|
|
1185
|
+
${names.map((n) => ` ${JSON.stringify(n)}: () => import(${JSON.stringify(`${VIRTUAL_FAKER_LOCALE}${n}`)}).then((m) => m.default)`).join(",\n")}
|
|
1186
|
+
}`;
|
|
1187
|
+
}
|
|
1188
|
+
if (id === RESOLVED_TYPES)
|
|
1189
|
+
return `export default ${JSON.stringify(await collectTypes(root, outDir))}`;
|
|
1190
|
+
if (id.startsWith(RESOLVED_DOC))
|
|
1191
|
+
return `export default ${JSON.stringify(docHtml[Number(id.slice(RESOLVED_DOC.length))] ?? "")}`;
|
|
1192
|
+
return void 0;
|
|
1193
|
+
},
|
|
1194
|
+
configureServer(s) {
|
|
1195
|
+
server = s;
|
|
1196
|
+
const SKIP = /* @__PURE__ */ new Set([
|
|
1197
|
+
"node_modules",
|
|
1198
|
+
".git",
|
|
1199
|
+
"dist",
|
|
1200
|
+
"dist-docs",
|
|
1201
|
+
".turbo",
|
|
1202
|
+
"openapi",
|
|
1203
|
+
basename(outDir)
|
|
1204
|
+
]);
|
|
1205
|
+
for (const entry of readdirSync(root, { withFileTypes: true }))
|
|
1206
|
+
if (entry.isDirectory() && !SKIP.has(entry.name)) s.watcher.add(join4(root, entry.name));
|
|
1207
|
+
const relevant = (file) => {
|
|
1208
|
+
const rel = relative4(root, file);
|
|
1209
|
+
if (rel.startsWith("..") || /node_modules/.test(rel)) return false;
|
|
1210
|
+
return /\.(req|seq)\.ts$|\.example\.json$|\.md$/.test(rel) || rel.startsWith(outDir);
|
|
1211
|
+
};
|
|
1212
|
+
let pending;
|
|
1213
|
+
notifyRegistryChanged = () => {
|
|
1214
|
+
clearTimeout(pending);
|
|
1215
|
+
pending = setTimeout(() => {
|
|
1216
|
+
const mod = s.moduleGraph.getModuleById(RESOLVED);
|
|
1217
|
+
if (mod) s.moduleGraph.invalidateModule(mod);
|
|
1218
|
+
s.ws.send({ type: "custom", event: "specbook:registry-changed" });
|
|
1219
|
+
}, 60);
|
|
1220
|
+
};
|
|
1221
|
+
const refresh = (file) => {
|
|
1222
|
+
if (relevant(file)) notifyRegistryChanged();
|
|
1223
|
+
};
|
|
1224
|
+
s.watcher.on("add", refresh);
|
|
1225
|
+
s.watcher.on("unlink", refresh);
|
|
1226
|
+
s.watcher.on("change", async (file) => {
|
|
1227
|
+
if (file.endsWith(".md")) return refresh(file);
|
|
1228
|
+
if (!file.endsWith(".seq.ts")) return;
|
|
1229
|
+
const header = JSON.stringify(
|
|
1230
|
+
parseSequenceMeta(await readFile5(file, "utf8").catch(() => ""))
|
|
1231
|
+
);
|
|
1232
|
+
if (seqHeaders.get(file) !== header) {
|
|
1233
|
+
seqHeaders.set(file, header);
|
|
1234
|
+
refresh(file);
|
|
1235
|
+
}
|
|
1236
|
+
});
|
|
1237
|
+
s.middlewares.use(async (req, res, next) => {
|
|
1238
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
1239
|
+
if (!url.pathname.startsWith("/__specbook/")) return next();
|
|
1240
|
+
try {
|
|
1241
|
+
if (url.pathname === "/__specbook/save" && req.method === "POST") {
|
|
1242
|
+
const { path, content, encoding } = JSON.parse(await readBody2(req));
|
|
1243
|
+
const abs = safeProjectPath(root, path);
|
|
1244
|
+
if (!abs || typeof content !== "string")
|
|
1245
|
+
return json2(res, 400, { error: "path not allowed" });
|
|
1246
|
+
await mkdir2(dirname(abs), { recursive: true });
|
|
1247
|
+
await writeFile2(abs, encoding === "base64" ? Buffer.from(content, "base64") : content);
|
|
1248
|
+
notifyRegistryChanged();
|
|
1249
|
+
return json2(res, 200, { ok: true });
|
|
1250
|
+
}
|
|
1251
|
+
if (url.pathname === "/__specbook/source") {
|
|
1252
|
+
const abs = safeProjectPath(root, url.searchParams.get("path") ?? "");
|
|
1253
|
+
if (!abs || !existsSync3(abs)) return json2(res, 404, { error: "not found" });
|
|
1254
|
+
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
1255
|
+
return void res.end(await readFile5(abs, "utf8"));
|
|
1256
|
+
}
|
|
1257
|
+
if (url.pathname === "/__specbook/types")
|
|
1258
|
+
return json2(res, 200, await collectTypes(root, outDir));
|
|
1259
|
+
if (url.pathname === "/__specbook/environment" && req.method === "GET") {
|
|
1260
|
+
const env = url.searchParams.get("env") ?? "";
|
|
1261
|
+
if (!isEnvName(env)) return json2(res, 400, { error: "bad environment name" });
|
|
1262
|
+
const cfg = await getProxyConfig();
|
|
1263
|
+
const values = await readEnvironment(root, env);
|
|
1264
|
+
const secrets = Object.entries(cfg.secretNames).map(([name, ref]) => {
|
|
1265
|
+
const key = ref.env ?? name;
|
|
1266
|
+
const source = memorySecrets[env]?.[name] !== void 0 || memorySecrets["*"]?.[name] !== void 0 ? "session" : process.env[key] !== void 0 ? "process" : values.secrets[key] !== void 0 ? "environment" : values.shared[key] !== void 0 ? "shared" : void 0;
|
|
1267
|
+
return { name, key, source };
|
|
1268
|
+
});
|
|
1269
|
+
const warnings = secrets.filter((s2) => s2.key in values.vars).map(
|
|
1270
|
+
(s2) => `"${s2.key}" is a secret but is in ${env}.env, which is committed. Move it to ${env}.secrets.env.`
|
|
1271
|
+
);
|
|
1272
|
+
return json2(res, 200, { env, vars: values.vars, secrets, warnings });
|
|
1273
|
+
}
|
|
1274
|
+
if ((url.pathname === "/__specbook/secret" || url.pathname === "/__specbook/var") && req.method === "POST") {
|
|
1275
|
+
const origin = req.headers.origin;
|
|
1276
|
+
if (origin && new URL(origin).host !== req.headers.host)
|
|
1277
|
+
return json2(res, 403, { error: "cross-origin request refused" });
|
|
1278
|
+
const body = JSON.parse(await readBody2(req));
|
|
1279
|
+
const { name, env } = body;
|
|
1280
|
+
const value = body.value === null || body.value === "" ? void 0 : body.value;
|
|
1281
|
+
if (env !== void 0 && !isEnvName(env))
|
|
1282
|
+
return json2(res, 400, { error: "bad environment name" });
|
|
1283
|
+
if (url.pathname === "/__specbook/var") {
|
|
1284
|
+
if (!isVarName(name) || !env)
|
|
1285
|
+
return json2(res, 400, { error: "name and env required" });
|
|
1286
|
+
await ensureEnvironmentsDir(root);
|
|
1287
|
+
await writeEnvKey(varsFile(root, env), name, value);
|
|
1288
|
+
return json2(res, 200, { ok: true });
|
|
1289
|
+
}
|
|
1290
|
+
const cfg = await getProxyConfig();
|
|
1291
|
+
const ref = cfg.secretNames[name];
|
|
1292
|
+
if (!ref) return json2(res, 400, { error: `unknown secret "${name}"` });
|
|
1293
|
+
const scope = body.scope ?? "session";
|
|
1294
|
+
if (scope === "session") {
|
|
1295
|
+
const scopeKey = env ?? "*";
|
|
1296
|
+
const bucket = memorySecrets[scopeKey] ?? {};
|
|
1297
|
+
if (value) bucket[name] = value;
|
|
1298
|
+
else delete bucket[name];
|
|
1299
|
+
memorySecrets[scopeKey] = bucket;
|
|
1300
|
+
} else if (scope === "environment") {
|
|
1301
|
+
if (!env) return json2(res, 400, { error: "env required" });
|
|
1302
|
+
await ensureEnvironmentsDir(root);
|
|
1303
|
+
await writeEnvKey(secretsFileFor(root, env), ref.env ?? name, value);
|
|
1304
|
+
delete memorySecrets[env]?.[name];
|
|
1305
|
+
} else {
|
|
1306
|
+
await mkdir2(dirname(sharedSecretsFile(root)), { recursive: true });
|
|
1307
|
+
await writeEnvKey(sharedSecretsFile(root), ref.env ?? name, value);
|
|
1308
|
+
for (const bucket of Object.values(memorySecrets)) delete bucket[name];
|
|
1309
|
+
}
|
|
1310
|
+
return json2(res, 200, { ok: true });
|
|
1311
|
+
}
|
|
1312
|
+
if (await handleProxy2(req, res, {
|
|
1313
|
+
getConfig: getProxyConfig,
|
|
1314
|
+
readSecrets: async (env) => {
|
|
1315
|
+
const v = await readEnvironment(root, env);
|
|
1316
|
+
return { ...v.shared, ...v.secrets };
|
|
1317
|
+
},
|
|
1318
|
+
env: process.env,
|
|
1319
|
+
memory: memorySecrets
|
|
1320
|
+
}))
|
|
1321
|
+
return;
|
|
1322
|
+
next();
|
|
1323
|
+
} catch (e) {
|
|
1324
|
+
json2(res, 500, { error: e instanceof Error ? e.message : String(e) });
|
|
1325
|
+
}
|
|
1326
|
+
});
|
|
1327
|
+
}
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1330
|
+
export {
|
|
1331
|
+
ENV_NAME,
|
|
1332
|
+
ensureEnvironmentsDir,
|
|
1333
|
+
environmentsDir,
|
|
1334
|
+
handleProxy2 as handleProxy,
|
|
1335
|
+
isEnvName,
|
|
1336
|
+
isVarName,
|
|
1337
|
+
listEnvironmentFiles,
|
|
1338
|
+
readAllVars,
|
|
1339
|
+
readEnvironment,
|
|
1340
|
+
safeProjectPath,
|
|
1341
|
+
scanProject,
|
|
1342
|
+
secretsFileFor,
|
|
1343
|
+
setDotenvKey,
|
|
1344
|
+
sharedSecretsFile,
|
|
1345
|
+
specbook,
|
|
1346
|
+
varsFile,
|
|
1347
|
+
writeEnvKey
|
|
1348
|
+
};
|