@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/dist/index.js
CHANGED
|
@@ -1,81 +1,185 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { existsSync as existsSync3, readdirSync } from "fs";
|
|
3
|
-
import { mkdir as mkdir2, readFile as
|
|
3
|
+
import { mkdir as mkdir2, readFile as readFile6, writeFile as writeFile2 } from "fs/promises";
|
|
4
4
|
import { createRequire } from "module";
|
|
5
|
-
import { basename, dirname, join as
|
|
6
|
-
import fg4 from "fast-glob";
|
|
5
|
+
import { basename, dirname, join as join5, relative as relative4, resolve as resolve2, sep as sep4 } from "path";
|
|
7
6
|
|
|
8
|
-
// src/
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
meta[line.slice(0, i).trim()] = line.slice(i + 1).trim().replace(/^["']|["']$/g, "");
|
|
7
|
+
// ../core/src/faker.ts
|
|
8
|
+
var KEY = "__specbookFaker";
|
|
9
|
+
var store = globalThis;
|
|
10
|
+
var faker = new Proxy({}, {
|
|
11
|
+
get(_t, key) {
|
|
12
|
+
const current = store[KEY];
|
|
13
|
+
if (!current) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
'faker is not initialised. Install "@faker-js/faker" in your project; specbook sets it up before each run.'
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
return current[key];
|
|
21
19
|
}
|
|
22
|
-
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// ../core/src/template.ts
|
|
23
|
+
var WHOLE = /^\s*\{\{\s*([\w.$-]+)\s*\}\}\s*$/;
|
|
24
|
+
var EMBED = /\{\{\s*([\w.$-]+)\s*\}\}/g;
|
|
25
|
+
var DEFERRED_NAMESPACES = ["secrets", "session"];
|
|
26
|
+
var TemplateError = class extends Error {
|
|
27
|
+
constructor(variable) {
|
|
28
|
+
super(`Missing variable "{{${variable}}}"`);
|
|
29
|
+
this.variable = variable;
|
|
30
|
+
this.name = "TemplateError";
|
|
31
|
+
}
|
|
32
|
+
variable;
|
|
33
|
+
};
|
|
34
|
+
function isDeferred(name) {
|
|
35
|
+
const ns = name.split(".")[0];
|
|
36
|
+
return DEFERRED_NAMESPACES.includes(ns) && name.includes(".");
|
|
23
37
|
}
|
|
24
|
-
var
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
return { operation: `${flat[1]}/${flat[2]}` };
|
|
35
|
-
return void 0;
|
|
38
|
+
var isFakerName = (name) => name.startsWith("faker.");
|
|
39
|
+
function fromFaker(path) {
|
|
40
|
+
let parent = faker;
|
|
41
|
+
let cur = faker;
|
|
42
|
+
for (const part of path.split(".").slice(1)) {
|
|
43
|
+
parent = cur;
|
|
44
|
+
cur = cur?.[part];
|
|
45
|
+
if (cur === void 0) return { found: false };
|
|
46
|
+
}
|
|
47
|
+
return { found: true, value: typeof cur === "function" ? cur.call(parent) : cur };
|
|
36
48
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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);
|
|
49
|
+
function lookup(scope, path) {
|
|
50
|
+
if (isFakerName(path)) return fromFaker(path);
|
|
51
|
+
let cur = scope;
|
|
52
|
+
for (const part of path.split(".")) {
|
|
53
|
+
if (cur !== null && typeof cur === "object" && part in cur) {
|
|
54
|
+
cur = cur[part];
|
|
55
|
+
} else {
|
|
56
|
+
return { found: false };
|
|
60
57
|
}
|
|
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
58
|
}
|
|
72
|
-
return
|
|
59
|
+
return { found: true, value: cur };
|
|
60
|
+
}
|
|
61
|
+
function renderString(s, scope) {
|
|
62
|
+
const whole = WHOLE.exec(s);
|
|
63
|
+
if (whole) {
|
|
64
|
+
const name = whole[1];
|
|
65
|
+
if (isDeferred(name)) return s;
|
|
66
|
+
const r = lookup(scope, name);
|
|
67
|
+
if (!r.found) throw new TemplateError(name);
|
|
68
|
+
return r.value;
|
|
69
|
+
}
|
|
70
|
+
return s.replace(EMBED, (m, name) => {
|
|
71
|
+
if (isDeferred(name)) return m;
|
|
72
|
+
const r = lookup(scope, name);
|
|
73
|
+
if (!r.found) throw new TemplateError(name);
|
|
74
|
+
const v = r.value;
|
|
75
|
+
return typeof v === "object" && v !== null ? JSON.stringify(v) : String(v);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
function collectPlaceholders(value, into = /* @__PURE__ */ new Set()) {
|
|
79
|
+
if (typeof value === "string") {
|
|
80
|
+
for (const m of value.matchAll(EMBED)) into.add(m[1]);
|
|
81
|
+
} else if (Array.isArray(value)) {
|
|
82
|
+
for (const v of value) collectPlaceholders(v, into);
|
|
83
|
+
} else if (value !== null && typeof value === "object") {
|
|
84
|
+
for (const v of Object.values(value)) collectPlaceholders(v, into);
|
|
85
|
+
}
|
|
86
|
+
return into;
|
|
73
87
|
}
|
|
74
88
|
|
|
75
|
-
// src/
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
89
|
+
// ../core/src/environment.ts
|
|
90
|
+
var AddressError = class extends Error {
|
|
91
|
+
constructor(message, variable) {
|
|
92
|
+
super(message);
|
|
93
|
+
this.variable = variable;
|
|
94
|
+
this.name = "AddressError";
|
|
95
|
+
}
|
|
96
|
+
variable;
|
|
97
|
+
};
|
|
98
|
+
var OAUTH_ADDRESS_FIELDS = [
|
|
99
|
+
"authorizationUrl",
|
|
100
|
+
"tokenUrl",
|
|
101
|
+
"openIdConnectUrl",
|
|
102
|
+
"clientId"
|
|
103
|
+
];
|
|
104
|
+
function environmentScope(cfg, env, fileVars = {}, extra = {}) {
|
|
105
|
+
return {
|
|
106
|
+
...cfg.vars,
|
|
107
|
+
...cfg.environments?.[env]?.vars,
|
|
108
|
+
...fileVars,
|
|
109
|
+
...extra
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function renderAddress(template, scope, what) {
|
|
113
|
+
for (const name of collectPlaceholders(template)) {
|
|
114
|
+
if (isDeferred(name) || isFakerName(name))
|
|
115
|
+
throw new AddressError(
|
|
116
|
+
`${what} cannot use {{${name}}}: only environment variables are available`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
let value;
|
|
120
|
+
try {
|
|
121
|
+
value = renderString(template, scope);
|
|
122
|
+
} catch (e) {
|
|
123
|
+
if (e instanceof TemplateError) throw new AddressError(`${what}: set ${e.variable}`, e.variable);
|
|
124
|
+
throw e;
|
|
125
|
+
}
|
|
126
|
+
if (value !== null && typeof value === "object")
|
|
127
|
+
throw new AddressError(`${what} is not a text value`);
|
|
128
|
+
const out = value === void 0 || value === null ? "" : String(value).trim();
|
|
129
|
+
if (/\{\{/.test(out)) throw new AddressError(`${what} still contains a placeholder: ${out}`);
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
function baseUrlFor(cfg, service, env, scope) {
|
|
133
|
+
const base = cfg.services[service]?.baseUrl;
|
|
134
|
+
const template = typeof base === "string" ? base : base?.[env];
|
|
135
|
+
if (!template) return void 0;
|
|
136
|
+
const what = `No base URL for ${service} in ${env}`;
|
|
137
|
+
const url = /\{\{/.test(template) ? renderAddress(template, scope, what) : template.trim();
|
|
138
|
+
return url ? url.replace(/\/+$/, "") : void 0;
|
|
139
|
+
}
|
|
140
|
+
function oauthFor(oauth, scope, profile = "sign-in") {
|
|
141
|
+
const out = { ...oauth };
|
|
142
|
+
for (const field of OAUTH_ADDRESS_FIELDS) {
|
|
143
|
+
const v = out[field];
|
|
144
|
+
if (typeof v !== "string" || !/\{\{/.test(v)) continue;
|
|
145
|
+
const rendered = renderAddress(v, scope, `${profile} ${field}`);
|
|
146
|
+
if (rendered) out[field] = rendered;
|
|
147
|
+
else delete out[field];
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
function environmentNames(cfg, fileEnvs = [], extra = []) {
|
|
152
|
+
const names = /* @__PURE__ */ new Set(["mock"]);
|
|
153
|
+
const services = Object.values(cfg.services ?? {});
|
|
154
|
+
if (services.some((s) => typeof s.baseUrl === "string")) names.add("live");
|
|
155
|
+
for (const s of services) {
|
|
156
|
+
if (s.baseUrl && typeof s.baseUrl === "object")
|
|
157
|
+
for (const k of Object.keys(s.baseUrl)) names.add(k);
|
|
158
|
+
}
|
|
159
|
+
for (const k of Object.keys(cfg.environments ?? {})) names.add(k);
|
|
160
|
+
for (const k of fileEnvs) names.add(k);
|
|
161
|
+
for (const k of extra) names.add(k);
|
|
162
|
+
return [...names];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ../core/src/oauth.ts
|
|
166
|
+
var OAuthError = class extends Error {
|
|
167
|
+
constructor(message) {
|
|
168
|
+
super(message);
|
|
169
|
+
this.name = "OAuthError";
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
function parseDiscovery(json3) {
|
|
173
|
+
const d = json3 && typeof json3 === "object" ? json3 : {};
|
|
174
|
+
const url = (v) => typeof v === "string" && /^https?:\/\//i.test(v) ? v : void 0;
|
|
175
|
+
const authorizationUrl = url(d.authorization_endpoint);
|
|
176
|
+
const tokenUrl = url(d.token_endpoint);
|
|
177
|
+
if (!authorizationUrl || !tokenUrl)
|
|
178
|
+
throw new OAuthError("The provider\u2019s discovery document has no authorization or token endpoint");
|
|
179
|
+
const supported = Array.isArray(d.scopes_supported) ? d.scopes_supported.filter((x) => typeof x === "string") : [];
|
|
180
|
+
const names = ["openid", ...supported.filter((x) => x !== "openid")];
|
|
181
|
+
return { authorizationUrl, tokenUrl, scopes: Object.fromEntries(names.map((n) => [n, ""])) };
|
|
182
|
+
}
|
|
79
183
|
|
|
80
184
|
// ../core/src/proxy/policy.ts
|
|
81
185
|
var DEFAULT_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
|
|
@@ -371,7 +475,178 @@ function parseDotenv(text) {
|
|
|
371
475
|
return out;
|
|
372
476
|
}
|
|
373
477
|
|
|
478
|
+
// src/index.ts
|
|
479
|
+
import fg4 from "fast-glob";
|
|
480
|
+
|
|
481
|
+
// src/addresses.ts
|
|
482
|
+
var NO_ADDRESSES = {
|
|
483
|
+
services: {},
|
|
484
|
+
tokenOrigins: {},
|
|
485
|
+
discoveryOrigins: {}
|
|
486
|
+
};
|
|
487
|
+
async function addressesFor(cfg, env, files, tokenOriginOf) {
|
|
488
|
+
const services = cfg.services ?? {};
|
|
489
|
+
const known = environmentNames({ services, environments: cfg.environments }, files.environments);
|
|
490
|
+
if (!known.includes(env)) return NO_ADDRESSES;
|
|
491
|
+
const scope = environmentScope(cfg, env, await files.vars());
|
|
492
|
+
const out = { services: {}, tokenOrigins: {}, discoveryOrigins: {} };
|
|
493
|
+
for (const [name, svc] of Object.entries(services)) {
|
|
494
|
+
if (env === "mock" && typeof svc.baseUrl === "string") continue;
|
|
495
|
+
try {
|
|
496
|
+
out.services[name] = baseUrlFor({ services }, name, env, scope);
|
|
497
|
+
} catch {
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
for (const [name, profile] of Object.entries(cfg.auth ?? {})) {
|
|
501
|
+
try {
|
|
502
|
+
if (!profile.oauth) continue;
|
|
503
|
+
const oauth = oauthFor(profile.oauth, scope, name);
|
|
504
|
+
if ("tokenUrl" in oauth && oauth.tokenUrl)
|
|
505
|
+
out.tokenOrigins[name] = new URL(oauth.tokenUrl).origin;
|
|
506
|
+
else if ("openIdConnectUrl" in oauth && oauth.openIdConnectUrl) {
|
|
507
|
+
out.discoveryOrigins[name] = new URL(oauth.openIdConnectUrl).origin;
|
|
508
|
+
const token = await tokenOriginOf(oauth.openIdConnectUrl);
|
|
509
|
+
if (token) out.tokenOrigins[name] = token;
|
|
510
|
+
}
|
|
511
|
+
} catch {
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return out;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// src/definitions.ts
|
|
518
|
+
function definitionRefs(text) {
|
|
519
|
+
const names = [];
|
|
520
|
+
for (const m of text.matchAll(/"\$ref":"(?:specbook:\/\/[^"#]*)?#\/\$defs\/([^"]+)"/g))
|
|
521
|
+
names.push(m[1].replace(/~1/g, "/").replace(/~0/g, "~"));
|
|
522
|
+
return names;
|
|
523
|
+
}
|
|
524
|
+
function pruneDefinitions(defs, roots) {
|
|
525
|
+
const all = defs.$defs ?? {};
|
|
526
|
+
const keep = /* @__PURE__ */ new Set();
|
|
527
|
+
const queue = [...roots ?? []];
|
|
528
|
+
while (queue.length) {
|
|
529
|
+
const name = queue.pop();
|
|
530
|
+
if (keep.has(name) || !(name in all)) continue;
|
|
531
|
+
keep.add(name);
|
|
532
|
+
queue.push(...definitionRefs(JSON.stringify(all[name])));
|
|
533
|
+
}
|
|
534
|
+
return { ...defs, $defs: Object.fromEntries(Object.entries(all).filter(([k]) => keep.has(k))) };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// src/discovery.ts
|
|
538
|
+
var FETCH_TIMEOUT_MS = 5e3;
|
|
539
|
+
function createDiscovery(opts = {}) {
|
|
540
|
+
const f = opts.fetch ?? fetch;
|
|
541
|
+
const now = opts.now ?? Date.now;
|
|
542
|
+
const retryAfter = opts.retryAfterMs ?? 3e4;
|
|
543
|
+
const found = /* @__PURE__ */ new Map();
|
|
544
|
+
const failedAt = /* @__PURE__ */ new Map();
|
|
545
|
+
const inFlight = /* @__PURE__ */ new Map();
|
|
546
|
+
async function look(url) {
|
|
547
|
+
try {
|
|
548
|
+
const res = await f(url, {
|
|
549
|
+
headers: { accept: "application/json" },
|
|
550
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
|
551
|
+
});
|
|
552
|
+
if (!res.ok) throw new Error(`answered ${res.status}`);
|
|
553
|
+
const origin = new URL(parseDiscovery(await res.json()).tokenUrl).origin;
|
|
554
|
+
found.set(url, origin);
|
|
555
|
+
failedAt.delete(url);
|
|
556
|
+
return origin;
|
|
557
|
+
} catch (e) {
|
|
558
|
+
failedAt.set(url, now());
|
|
559
|
+
opts.warn?.(
|
|
560
|
+
`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.`
|
|
561
|
+
);
|
|
562
|
+
return void 0;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return function tokenOrigin(url) {
|
|
566
|
+
const known = found.get(url);
|
|
567
|
+
if (known) return Promise.resolve(known);
|
|
568
|
+
const failed = failedAt.get(url);
|
|
569
|
+
if (failed !== void 0 && now() - failed < retryAfter) return Promise.resolve(void 0);
|
|
570
|
+
let pending = inFlight.get(url);
|
|
571
|
+
if (!pending) {
|
|
572
|
+
pending = look(url).finally(() => inFlight.delete(url));
|
|
573
|
+
inFlight.set(url, pending);
|
|
574
|
+
}
|
|
575
|
+
return pending;
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// src/docs.ts
|
|
580
|
+
import { readFile } from "fs/promises";
|
|
581
|
+
import { relative, sep } from "path";
|
|
582
|
+
import fg from "fast-glob";
|
|
583
|
+
import { marked } from "marked";
|
|
584
|
+
function frontmatter(src) {
|
|
585
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(src);
|
|
586
|
+
if (!m) return { meta: {}, body: src };
|
|
587
|
+
const meta = {};
|
|
588
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
589
|
+
const i = line.indexOf(":");
|
|
590
|
+
if (i > 0)
|
|
591
|
+
meta[line.slice(0, i).trim()] = line.slice(i + 1).trim().replace(/^["']|["']$/g, "");
|
|
592
|
+
}
|
|
593
|
+
return { meta, body: src.slice(m[0].length) };
|
|
594
|
+
}
|
|
595
|
+
var OP_FOLDER = /(?:^|\/)(?:requests|apis)\/([^/]+)\/([^/]+)\/(?:readme|index)\.md$/i;
|
|
596
|
+
var OP_FLAT = /(?:^|\/)(?:requests|apis)\/([^/]+)\/docs\/([^/]+)\.md$/i;
|
|
597
|
+
var NOT_AN_OPERATION = /^(readme|index)$/i;
|
|
598
|
+
function colocated(file) {
|
|
599
|
+
if (/\.seq\.md$/.test(file)) return { sequenceFile: file.replace(/\.md$/, ".ts") };
|
|
600
|
+
if (/\.req\.md$/.test(file)) return { requestFile: file.replace(/\.md$/, ".ts") };
|
|
601
|
+
const folder = OP_FOLDER.exec(file);
|
|
602
|
+
if (folder && folder[2] !== "docs") return { operation: `${folder[1]}/${folder[2]}` };
|
|
603
|
+
const flat = OP_FLAT.exec(file);
|
|
604
|
+
if (flat && !NOT_AN_OPERATION.test(flat[2]))
|
|
605
|
+
return { operation: `${flat[1]}/${flat[2]}` };
|
|
606
|
+
return void 0;
|
|
607
|
+
}
|
|
608
|
+
async function scanDocs(root) {
|
|
609
|
+
const files = await fg("**/*.md", {
|
|
610
|
+
cwd: root,
|
|
611
|
+
absolute: true,
|
|
612
|
+
ignore: ["**/node_modules/**", "**/.generated/**", "**/dist/**", "**/.git/**"]
|
|
613
|
+
});
|
|
614
|
+
const pages = [];
|
|
615
|
+
const claimed = /* @__PURE__ */ new Map();
|
|
616
|
+
for (const abs of files.sort()) {
|
|
617
|
+
const { meta, body } = frontmatter(await readFile(abs, "utf8"));
|
|
618
|
+
const file = relative(root, abs).split(sep).join("/");
|
|
619
|
+
const near = colocated(file);
|
|
620
|
+
if (!near && !meta.operation && !meta.sequence && !meta.page) continue;
|
|
621
|
+
const operation = meta.operation ?? near?.operation;
|
|
622
|
+
if (operation) {
|
|
623
|
+
const first = claimed.get(operation);
|
|
624
|
+
if (first) {
|
|
625
|
+
console.warn(
|
|
626
|
+
`[specbook] ${file} documents ${operation}, which ${first} already does; ignored`
|
|
627
|
+
);
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
claimed.set(operation, file);
|
|
631
|
+
}
|
|
632
|
+
pages.push({
|
|
633
|
+
file,
|
|
634
|
+
operation,
|
|
635
|
+
sequence: meta.sequence,
|
|
636
|
+
sequenceFile: near?.sequenceFile,
|
|
637
|
+
requestFile: near?.requestFile,
|
|
638
|
+
replace: meta.replace === "true" || void 0,
|
|
639
|
+
title: meta.title ?? meta.page,
|
|
640
|
+
html: marked.parse(body, { async: false })
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
return pages;
|
|
644
|
+
}
|
|
645
|
+
|
|
374
646
|
// src/environments.ts
|
|
647
|
+
import { existsSync } from "fs";
|
|
648
|
+
import { mkdir, readdir, readFile as readFile2, writeFile } from "fs/promises";
|
|
649
|
+
import { join } from "path";
|
|
375
650
|
var ENV_NAME = /^[A-Za-z0-9_-]+$/;
|
|
376
651
|
var VAR_NAME = /^[A-Za-z_][\w.-]*$/;
|
|
377
652
|
var environmentsDir = (root) => join(root, ".specbook", "environments");
|
|
@@ -468,6 +743,7 @@ async function scanExamples(root, outDir, operations) {
|
|
|
468
743
|
const gen = `${root}/${outDir}`;
|
|
469
744
|
for (const abs of await fg2("*/examples/*/*.example.json", { cwd: gen, absolute: true })) {
|
|
470
745
|
const [svc, , id, file] = relative2(gen, abs).split(sep2);
|
|
746
|
+
if (!operations.has(`${svc}/${id}`)) continue;
|
|
471
747
|
add(`${svc}/${id}`, {
|
|
472
748
|
name: file.replace(NAME, ""),
|
|
473
749
|
abs,
|
|
@@ -541,6 +817,15 @@ function requestsDirFor(root) {
|
|
|
541
817
|
}
|
|
542
818
|
|
|
543
819
|
// src/proxy.ts
|
|
820
|
+
var AUTH_SERVICE_PREFIX = "auth:";
|
|
821
|
+
var DISCOVERY_SERVICE_PREFIX = "discovery:";
|
|
822
|
+
var decode = (s) => {
|
|
823
|
+
try {
|
|
824
|
+
return decodeURIComponent(s);
|
|
825
|
+
} catch {
|
|
826
|
+
return s;
|
|
827
|
+
}
|
|
828
|
+
};
|
|
544
829
|
var MOUNT = "/__specbook/proxy";
|
|
545
830
|
function readBody(req) {
|
|
546
831
|
return new Promise((res, rej) => {
|
|
@@ -554,6 +839,15 @@ async function handleProxy2(req, res, deps) {
|
|
|
554
839
|
const url = req.url ?? "/";
|
|
555
840
|
if (!url.startsWith(`${MOUNT}/`)) return false;
|
|
556
841
|
const config = await deps.getConfig();
|
|
842
|
+
const addressesByEnv = /* @__PURE__ */ new Map();
|
|
843
|
+
const addressesFor2 = (env) => {
|
|
844
|
+
let a = addressesByEnv.get(env);
|
|
845
|
+
if (!a) {
|
|
846
|
+
a = config.resolve(env);
|
|
847
|
+
addressesByEnv.set(env, a);
|
|
848
|
+
}
|
|
849
|
+
return a;
|
|
850
|
+
};
|
|
557
851
|
const secretsByEnv = /* @__PURE__ */ new Map();
|
|
558
852
|
const method = req.method ?? "GET";
|
|
559
853
|
const headers = new Headers();
|
|
@@ -568,7 +862,14 @@ async function handleProxy2(req, res, deps) {
|
|
|
568
862
|
const response = await handleProxy(request, {
|
|
569
863
|
mount: MOUNT,
|
|
570
864
|
policy: {
|
|
571
|
-
upstream: (env, service) =>
|
|
865
|
+
upstream: async (env, service) => {
|
|
866
|
+
const a = await addressesFor2(env);
|
|
867
|
+
if (service.startsWith(AUTH_SERVICE_PREFIX))
|
|
868
|
+
return a.tokenOrigins[decode(service.slice(AUTH_SERVICE_PREFIX.length))];
|
|
869
|
+
if (service.startsWith(DISCOVERY_SERVICE_PREFIX))
|
|
870
|
+
return a.discoveryOrigins[decode(service.slice(DISCOVERY_SERVICE_PREFIX.length))];
|
|
871
|
+
return a.services[service];
|
|
872
|
+
},
|
|
572
873
|
// a developer's own machine: their local services are the point
|
|
573
874
|
allowPrivateHosts: true
|
|
574
875
|
},
|
|
@@ -597,9 +898,47 @@ async function handleProxy2(req, res, deps) {
|
|
|
597
898
|
return true;
|
|
598
899
|
}
|
|
599
900
|
|
|
600
|
-
// src/
|
|
901
|
+
// src/requests.ts
|
|
601
902
|
import { readFile as readFile4 } from "fs/promises";
|
|
602
903
|
import { join as join3 } from "path";
|
|
904
|
+
function parseRequestTarget(src, services) {
|
|
905
|
+
const local = /* @__PURE__ */ new Map();
|
|
906
|
+
for (const m of src.matchAll(/import\s*(?:type\s*)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g)) {
|
|
907
|
+
const service = m[2].split("/").pop();
|
|
908
|
+
if (!services.has(service) || /^import\s*type/.test(m[0])) continue;
|
|
909
|
+
for (const part of m[1].split(",")) {
|
|
910
|
+
const spec = part.trim();
|
|
911
|
+
if (!spec || spec.startsWith("type ")) continue;
|
|
912
|
+
const [imported, as] = spec.split(/\s+as\s+/);
|
|
913
|
+
local.set(as ?? imported, `${service}/${imported}`);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
const used = /* @__PURE__ */ new Set();
|
|
917
|
+
for (const m of src.matchAll(/\b([A-Za-z_$][\w$]*)\s*\.\s*request\s*\(/g)) {
|
|
918
|
+
const key = local.get(m[1]);
|
|
919
|
+
if (key) used.add(key);
|
|
920
|
+
}
|
|
921
|
+
return used.size === 1 ? [...used][0] : void 0;
|
|
922
|
+
}
|
|
923
|
+
async function scanRequests(root, files, services) {
|
|
924
|
+
const meta = {};
|
|
925
|
+
const unresolved = [];
|
|
926
|
+
await Promise.all(
|
|
927
|
+
files.map(async (file) => {
|
|
928
|
+
const key = parseRequestTarget(
|
|
929
|
+
await readFile4(join3(root, file), "utf8").catch(() => ""),
|
|
930
|
+
services
|
|
931
|
+
);
|
|
932
|
+
if (key) meta[file] = key;
|
|
933
|
+
else unresolved.push(file);
|
|
934
|
+
})
|
|
935
|
+
);
|
|
936
|
+
return { meta, unresolved: unresolved.sort() };
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// src/sequences.ts
|
|
940
|
+
import { readFile as readFile5 } from "fs/promises";
|
|
941
|
+
import { join as join4 } from "path";
|
|
603
942
|
var KEYS = ["id", "title", "description", "group", "tags"];
|
|
604
943
|
function readString(src, i) {
|
|
605
944
|
const q = src[i];
|
|
@@ -697,7 +1036,7 @@ async function scanSequences(root, files) {
|
|
|
697
1036
|
const unresolved = [];
|
|
698
1037
|
await Promise.all(
|
|
699
1038
|
files.map(async (file) => {
|
|
700
|
-
const parsed = parseSequenceMeta(await
|
|
1039
|
+
const parsed = parseSequenceMeta(await readFile5(join4(root, file), "utf8").catch(() => ""));
|
|
701
1040
|
if (parsed) meta[parsed.id] = { ...parsed, file };
|
|
702
1041
|
else unresolved.push(file);
|
|
703
1042
|
})
|
|
@@ -714,14 +1053,14 @@ var VIRTUAL_FAKER = "virtual:specbook/faker";
|
|
|
714
1053
|
var RESOLVED_FAKER = `\0${VIRTUAL_FAKER}`;
|
|
715
1054
|
var VIRTUAL_FAKER_LOCALE = "virtual:specbook/faker-locale/";
|
|
716
1055
|
var RESOLVED_FAKER_LOCALE = `\0${VIRTUAL_FAKER_LOCALE}`;
|
|
717
|
-
var
|
|
718
|
-
var
|
|
1056
|
+
var VIRTUAL_MANIFEST = "virtual:specbook/manifest/";
|
|
1057
|
+
var RESOLVED_MANIFEST = `\0${VIRTUAL_MANIFEST}`;
|
|
719
1058
|
var VIRTUAL_OPERATIONS = "virtual:specbook/operations";
|
|
720
1059
|
var RESOLVED_OPERATIONS = `\0${VIRTUAL_OPERATIONS}`;
|
|
721
1060
|
var VIRTUAL_DOC = "virtual:specbook/doc/";
|
|
722
1061
|
var RESOLVED_DOC = `\0${VIRTUAL_DOC}`;
|
|
723
1062
|
function findConfig(root) {
|
|
724
|
-
return [
|
|
1063
|
+
return [join5(root, ".specbook/specbook.config.ts"), join5(root, "specbook.config.ts")].find(
|
|
725
1064
|
existsSync3
|
|
726
1065
|
);
|
|
727
1066
|
}
|
|
@@ -742,7 +1081,7 @@ async function readBody2(req) {
|
|
|
742
1081
|
}
|
|
743
1082
|
function fakerLocales(entry) {
|
|
744
1083
|
const names = /* @__PURE__ */ new Set(["en", "base"]);
|
|
745
|
-
const dir =
|
|
1084
|
+
const dir = join5(dirname(entry), "locale");
|
|
746
1085
|
if (existsSync3(dir))
|
|
747
1086
|
for (const f of readdirSync(dir)) {
|
|
748
1087
|
const m = /^([A-Za-z0-9_]+)\.js$/.exec(f);
|
|
@@ -765,13 +1104,13 @@ function slim(manifests) {
|
|
|
765
1104
|
}
|
|
766
1105
|
async function collectTypes(root, outDir) {
|
|
767
1106
|
const libs = {};
|
|
768
|
-
const req = createRequire(
|
|
1107
|
+
const req = createRequire(join5(root, "package.json"));
|
|
769
1108
|
try {
|
|
770
1109
|
const entry = req.resolve("@specbook/core");
|
|
771
1110
|
const srcDir = dirname(entry);
|
|
772
1111
|
const files = await fg4("**/*.ts", { cwd: srcDir, ignore: ["**/*.test.ts"] });
|
|
773
1112
|
for (const f of files) {
|
|
774
|
-
libs[`file:///node_modules/@specbook/core/src/${f}`] = await
|
|
1113
|
+
libs[`file:///node_modules/@specbook/core/src/${f}`] = await readFile6(join5(srcDir, f), "utf8");
|
|
775
1114
|
}
|
|
776
1115
|
libs["file:///node_modules/@specbook/core/package.json"] = JSON.stringify({
|
|
777
1116
|
name: "@specbook/core",
|
|
@@ -779,24 +1118,27 @@ async function collectTypes(root, outDir) {
|
|
|
779
1118
|
});
|
|
780
1119
|
} catch {
|
|
781
1120
|
}
|
|
782
|
-
const genDir =
|
|
1121
|
+
const genDir = join5(root, outDir);
|
|
783
1122
|
for (const f of await fg4("**/*.ts", { cwd: genDir })) {
|
|
784
|
-
libs[`file:///${outDir}/${f}`] = await
|
|
1123
|
+
libs[`file:///${outDir}/${f}`] = await readFile6(join5(genDir, f), "utf8");
|
|
785
1124
|
}
|
|
786
1125
|
const project = await scanProject(root);
|
|
787
1126
|
const sources = {};
|
|
788
1127
|
for (const f of project.sources.filter((s) => s.endsWith(".ts"))) {
|
|
789
|
-
sources[f] = await
|
|
1128
|
+
sources[f] = await readFile6(join5(root, f), "utf8");
|
|
790
1129
|
}
|
|
791
1130
|
return { libs, sources };
|
|
792
1131
|
}
|
|
793
1132
|
function specbook(options) {
|
|
794
1133
|
const root = resolve2(options.projectRoot);
|
|
795
1134
|
const outDir = options.outDir ?? ".generated";
|
|
1135
|
+
const only = options.operations;
|
|
1136
|
+
const keep = (service, op) => !only || only.has(`${service}/${op}`);
|
|
1137
|
+
const neededDefs = /* @__PURE__ */ new Map();
|
|
796
1138
|
let server;
|
|
797
1139
|
let isBuild = false;
|
|
798
1140
|
let docHtml = [];
|
|
799
|
-
let
|
|
1141
|
+
let manifestCode = /* @__PURE__ */ new Map();
|
|
800
1142
|
let operationsCode = "export const operations = {}";
|
|
801
1143
|
let bundlePlan = /* @__PURE__ */ new Map();
|
|
802
1144
|
let definitionFiles = /* @__PURE__ */ new Map();
|
|
@@ -805,16 +1147,29 @@ function specbook(options) {
|
|
|
805
1147
|
let notifyRegistryChanged = () => {
|
|
806
1148
|
};
|
|
807
1149
|
const memorySecrets = {};
|
|
1150
|
+
const tokenOriginOf = createDiscovery({ warn: (m) => server?.config.logger.warn(m) });
|
|
808
1151
|
async function registryCode() {
|
|
809
1152
|
const files = await scanProject(root);
|
|
810
1153
|
const manifests = {};
|
|
811
|
-
for (const m of await fg4("*/manifest.json", { cwd:
|
|
812
|
-
const parsed = JSON.parse(await
|
|
1154
|
+
for (const m of await fg4("*/manifest.json", { cwd: join5(root, outDir), absolute: true })) {
|
|
1155
|
+
const parsed = JSON.parse(await readFile6(m, "utf8"));
|
|
813
1156
|
manifests[parsed.service] = parsed;
|
|
814
1157
|
}
|
|
815
|
-
|
|
816
|
-
|
|
1158
|
+
if (only) {
|
|
1159
|
+
for (const [svc, m] of Object.entries(manifests)) {
|
|
1160
|
+
const mm = m;
|
|
1161
|
+
mm.operations = mm.operations.filter((o) => keep(svc, o.id));
|
|
1162
|
+
if (mm.operations.length === 0) delete manifests[svc];
|
|
1163
|
+
else mm.tags = [...new Set(mm.operations.flatMap((o) => o.tags ?? []))];
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
const inScope = (svc, op) => svc in manifests && keep(svc, op);
|
|
1167
|
+
const examples = (await fg4("*/examples/*/*.response.json", {
|
|
1168
|
+
cwd: join5(root, outDir),
|
|
817
1169
|
absolute: true
|
|
1170
|
+
})).filter((abs) => {
|
|
1171
|
+
const rel = relative4(join5(root, outDir), abs).split(sep4);
|
|
1172
|
+
return inScope(rel[0], rel[2]);
|
|
818
1173
|
});
|
|
819
1174
|
const imp = (abs) => {
|
|
820
1175
|
if (isBuild) return `() => import(${JSON.stringify(abs)})`;
|
|
@@ -823,50 +1178,45 @@ function specbook(options) {
|
|
|
823
1178
|
return `(v) => import(/* @vite-ignore */ ${JSON.stringify(`/@fs${abs}`)} + '?t=' + v)`;
|
|
824
1179
|
};
|
|
825
1180
|
const raw = (abs) => `() => import(${JSON.stringify(`${abs}?raw`)}).then((m) => m.default)`;
|
|
826
|
-
const entry = (file) => `{ file: ${JSON.stringify(file)}, load: ${imp(
|
|
1181
|
+
const entry = (file) => `{ file: ${JSON.stringify(file)}, load: ${imp(join5(root, file))}${isBuild ? `, source: ${raw(join5(root, file))}` : ""} }`;
|
|
827
1182
|
const cfg = findConfig(root);
|
|
828
1183
|
const requestsDir = requestsDirFor(root);
|
|
829
|
-
existsSync3(
|
|
1184
|
+
existsSync3(join5(root, "apis")) && !existsSync3(join5(root, "requests")) ? "apis" : "requests";
|
|
830
1185
|
let fakerEntry;
|
|
831
1186
|
try {
|
|
832
|
-
fakerEntry = createRequire(
|
|
1187
|
+
fakerEntry = createRequire(join5(root, "package.json")).resolve("@faker-js/faker");
|
|
833
1188
|
} catch {
|
|
834
1189
|
}
|
|
835
1190
|
const operationEntries = Object.values(manifests).flatMap((m) => {
|
|
836
1191
|
const mm = m;
|
|
837
1192
|
return mm.operations.map(
|
|
838
|
-
(o) => ` ${JSON.stringify(`${mm.service}/${o.id}`)}: ${imp(
|
|
1193
|
+
(o) => ` ${JSON.stringify(`${mm.service}/${o.id}`)}: ${imp(join5(root, outDir, mm.service, "operations", `${o.id}.ts`))}`
|
|
839
1194
|
);
|
|
840
1195
|
});
|
|
841
|
-
const
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
}
|
|
845
|
-
const
|
|
846
|
-
|
|
847
|
-
absolute: true
|
|
848
|
-
});
|
|
1196
|
+
const opOf = (abs, suffix) => {
|
|
1197
|
+
const rel = relative4(join5(root, outDir), abs).split(sep4);
|
|
1198
|
+
return [rel[0], rel[2].replace(suffix, "")];
|
|
1199
|
+
};
|
|
1200
|
+
const schemaFiles = (await fg4("*/schemas/*.request.schema.json", { cwd: join5(root, outDir), absolute: true })).filter((abs) => inScope(...opOf(abs, /\.request\.schema\.json$/)));
|
|
1201
|
+
const responseFiles = (await fg4("*/schemas/*.responses.schema.json", { cwd: join5(root, outDir), absolute: true })).filter((abs) => inScope(...opOf(abs, /\.responses\.schema\.json$/)));
|
|
849
1202
|
const responseEntries = responseFiles.map((abs) => {
|
|
850
|
-
const rel = relative4(
|
|
1203
|
+
const rel = relative4(join5(root, outDir), abs).split(sep4);
|
|
851
1204
|
const op = rel[2].replace(/\.responses\.schema\.json$/, "");
|
|
852
1205
|
return ` ${JSON.stringify(`${rel[0]}/${op}`)}: ${imp(abs)}`;
|
|
853
1206
|
});
|
|
854
|
-
const defFiles = await fg4("*/schemas/definitions.schema.json", {
|
|
855
|
-
cwd: join4(root, outDir),
|
|
856
|
-
absolute: true
|
|
857
|
-
});
|
|
1207
|
+
const defFiles = (await fg4("*/schemas/definitions.schema.json", { cwd: join5(root, outDir), absolute: true })).filter((abs) => relative4(join5(root, outDir), abs).split(sep4)[0] in manifests);
|
|
858
1208
|
const schemaEntries = schemaFiles.map((abs) => {
|
|
859
|
-
const rel = relative4(
|
|
1209
|
+
const rel = relative4(join5(root, outDir), abs).split(sep4);
|
|
860
1210
|
const op = rel[2].replace(/\.request\.schema\.json$/, "");
|
|
861
1211
|
return ` ${JSON.stringify(`${rel[0]}/${op}`)}: ${imp(abs)}`;
|
|
862
1212
|
});
|
|
863
|
-
const docFiles = await fg4("*/docs/*.json", { cwd:
|
|
1213
|
+
const docFiles = (await fg4("*/docs/*.json", { cwd: join5(root, outDir), absolute: true })).filter((abs) => inScope(...opOf(abs, /\.json$/)));
|
|
864
1214
|
const operationDocEntries = docFiles.map((abs) => {
|
|
865
|
-
const rel = relative4(
|
|
1215
|
+
const rel = relative4(join5(root, outDir), abs).split(sep4);
|
|
866
1216
|
return ` ${JSON.stringify(`${rel[0]}/${rel[2].replace(/\.json$/, "")}`)}: ${imp(abs)}`;
|
|
867
1217
|
});
|
|
868
1218
|
const defEntries = defFiles.map((abs) => {
|
|
869
|
-
const service = relative4(
|
|
1219
|
+
const service = relative4(join5(root, outDir), abs).split(sep4)[0];
|
|
870
1220
|
return ` ${JSON.stringify(service)}: ${imp(abs)}`;
|
|
871
1221
|
});
|
|
872
1222
|
const known = new Set(
|
|
@@ -876,11 +1226,15 @@ function specbook(options) {
|
|
|
876
1226
|
})
|
|
877
1227
|
);
|
|
878
1228
|
const sequenceScan = await scanSequences(root, files.sequences);
|
|
1229
|
+
const requestScan = await scanRequests(root, files.requests, new Set(Object.keys(manifests)));
|
|
879
1230
|
for (const f of files.sequences) {
|
|
880
|
-
const src = await
|
|
881
|
-
seqHeaders.set(
|
|
1231
|
+
const src = await readFile6(join5(root, f), "utf8").catch(() => "");
|
|
1232
|
+
seqHeaders.set(join5(root, f), JSON.stringify(parseSequenceMeta(src)));
|
|
882
1233
|
}
|
|
883
1234
|
const scanned = await scanExamples(root, outDir, known);
|
|
1235
|
+
const keepExampleKey = (key) => !only || only.has(key);
|
|
1236
|
+
for (const key of Object.keys(scanned.examples))
|
|
1237
|
+
if (!keepExampleKey(key)) delete scanned.examples[key];
|
|
884
1238
|
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
1239
|
const requestExampleEntries = Object.entries(scanned.examples).map(
|
|
886
1240
|
([key, list]) => ` ${JSON.stringify(key)}: [${list.map(
|
|
@@ -902,23 +1256,28 @@ function specbook(options) {
|
|
|
902
1256
|
const assetEntries = Object.entries(scanned.assets).map(
|
|
903
1257
|
([key, abs]) => ` ${JSON.stringify(key)}: ${url(abs)}`
|
|
904
1258
|
);
|
|
905
|
-
const pages = await scanDocs(root)
|
|
1259
|
+
const pages = (await scanDocs(root)).filter(
|
|
1260
|
+
(p) => !only || !p.operation || only.has(p.operation)
|
|
1261
|
+
);
|
|
906
1262
|
docHtml = pages.map((p) => p.html);
|
|
907
1263
|
const docEntries = pages.map(
|
|
908
1264
|
(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
1265
|
);
|
|
910
1266
|
const exampleEntries = examples.map((abs) => {
|
|
911
|
-
const rel = relative4(
|
|
1267
|
+
const rel = relative4(join5(root, outDir), abs).split(sep4);
|
|
912
1268
|
const status = rel[3].replace(/\.response\.json$/, "");
|
|
913
1269
|
const key = `${rel[0]}/${rel[2]}.${status}`;
|
|
914
1270
|
return ` ${JSON.stringify(key)}: ${imp(abs)}`;
|
|
915
1271
|
});
|
|
916
1272
|
if (isBuild) {
|
|
917
|
-
|
|
1273
|
+
const slimmed = slim(manifests);
|
|
1274
|
+
manifestCode = new Map(
|
|
1275
|
+
Object.entries(slimmed).map(([svc, m]) => [svc, `export default ${JSON.stringify(m)}`])
|
|
1276
|
+
);
|
|
918
1277
|
operationsCode = `export const operations = {
|
|
919
1278
|
${operationEntries.join(",\n")}
|
|
920
1279
|
}`;
|
|
921
|
-
const gen =
|
|
1280
|
+
const gen = join5(root, outDir);
|
|
922
1281
|
bundlePlan = /* @__PURE__ */ new Map();
|
|
923
1282
|
const slot = (key) => {
|
|
924
1283
|
const have = bundlePlan.get(key);
|
|
@@ -958,7 +1317,7 @@ ${operationEntries.join(",\n")}
|
|
|
958
1317
|
const n = counts.get(tag) ?? 0;
|
|
959
1318
|
counts.set(tag, n + 1);
|
|
960
1319
|
operationChunks.set(
|
|
961
|
-
|
|
1320
|
+
join5(gen, m.service, "operations", `${o.id}.ts`),
|
|
962
1321
|
`ops-${m.service}-${tag}-${Math.floor(n / 40)}`
|
|
963
1322
|
);
|
|
964
1323
|
}
|
|
@@ -972,8 +1331,8 @@ ${operationEntries.join(",\n")}
|
|
|
972
1331
|
projectLoads.push(` ${JSON.stringify(`${key}/${e.name}`)}: ${imp(e.abs)}`);
|
|
973
1332
|
}
|
|
974
1333
|
const services = Object.keys(manifests);
|
|
975
|
-
return `
|
|
976
|
-
export const
|
|
1334
|
+
return `export const mode = "static"
|
|
1335
|
+
export const minimal = ${JSON.stringify(!!only)}
|
|
977
1336
|
export const project = ${JSON.stringify({ root, outDir, requestsDir })}
|
|
978
1337
|
// where the data files are: dist/data, whether this chunk is in dist/ or dist/assets/
|
|
979
1338
|
const dataBase = import.meta.url.replace(/(assets\\/)?[^/]*$/, '') + 'data/'
|
|
@@ -987,8 +1346,15 @@ const bundle = (key) => {
|
|
|
987
1346
|
}
|
|
988
1347
|
const part = (pick) =>
|
|
989
1348
|
new Proxy({}, { get: (_, key) => (typeof key === 'string' && key.includes('/') ? () => bundle(key).then((b) => ({ default: pick(b) })) : undefined) })
|
|
990
|
-
export
|
|
1349
|
+
export const manifests = {}
|
|
1350
|
+
const manifestLoaders = {${services.map((svc) => `
|
|
1351
|
+
${JSON.stringify(svc)}: () => import(${JSON.stringify(`${VIRTUAL_MANIFEST}${svc}`)})`).join(",")}
|
|
1352
|
+
}
|
|
1353
|
+
export const loadManifests = (names = Object.keys(manifestLoaders)) =>
|
|
1354
|
+
Promise.all(names.map((svc) => manifests[svc] ? undefined : manifestLoaders[svc]?.().then((m) => { manifests[svc] = m.default }))).then(() => undefined)
|
|
991
1355
|
export const requests = [${files.requests.map(entry).join(",\n")}]
|
|
1356
|
+
export const requestMeta = ${JSON.stringify(requestScan.meta)}
|
|
1357
|
+
export const unresolvedRequests = ${JSON.stringify(requestScan.unresolved)}
|
|
992
1358
|
export const sequences = [${files.sequences.map(entry).join(",\n")}]
|
|
993
1359
|
export const sequenceMeta = ${JSON.stringify(sequenceScan.meta)}
|
|
994
1360
|
export const unresolvedSequences = ${JSON.stringify(sequenceScan.unresolved)}
|
|
@@ -1044,9 +1410,13 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
|
|
|
1044
1410
|
`;
|
|
1045
1411
|
}
|
|
1046
1412
|
return `export const mode = ${JSON.stringify(isBuild ? "static" : "dev")}
|
|
1413
|
+
export const minimal = ${JSON.stringify(!!only)}
|
|
1047
1414
|
export const project = ${JSON.stringify({ root, outDir, requestsDir })}
|
|
1048
1415
|
export const manifests = ${JSON.stringify(slim(manifests))}
|
|
1416
|
+
export const loadManifests = () => Promise.resolve()
|
|
1049
1417
|
export const requests = [${files.requests.map(entry).join(",\n")}]
|
|
1418
|
+
export const requestMeta = ${JSON.stringify(requestScan.meta)}
|
|
1419
|
+
export const unresolvedRequests = ${JSON.stringify(requestScan.unresolved)}
|
|
1050
1420
|
export const sequences = [${files.sequences.map(entry).join(",\n")}]
|
|
1051
1421
|
export const sequenceMeta = ${JSON.stringify(sequenceScan.meta)}
|
|
1052
1422
|
export const unresolvedSequences = ${JSON.stringify(sequenceScan.unresolved)}
|
|
@@ -1083,17 +1453,24 @@ export const loadFaker = ${fakerEntry ? `() => import('virtual:specbook/faker')`
|
|
|
1083
1453
|
export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: { project: "specbook", services: {} } })'}
|
|
1084
1454
|
`;
|
|
1085
1455
|
}
|
|
1086
|
-
async function
|
|
1456
|
+
async function loadProjectConfig() {
|
|
1087
1457
|
const cfgFile = findConfig(root);
|
|
1088
|
-
if (!server || !cfgFile) return
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1458
|
+
if (!server || !cfgFile) return void 0;
|
|
1459
|
+
return (await server.ssrLoadModule(cfgFile)).default ?? {};
|
|
1460
|
+
}
|
|
1461
|
+
async function getProxyConfig() {
|
|
1462
|
+
const cfg = await loadProjectConfig();
|
|
1463
|
+
if (!cfg) return { secretNames: {}, resolve: async () => NO_ADDRESSES };
|
|
1464
|
+
const resolve3 = async (env) => isEnvName(env) ? addressesFor(
|
|
1465
|
+
cfg,
|
|
1466
|
+
env,
|
|
1467
|
+
{
|
|
1468
|
+
environments: await listEnvironmentFiles(root),
|
|
1469
|
+
vars: async () => (await readEnvironment(root, env)).vars
|
|
1470
|
+
},
|
|
1471
|
+
tokenOriginOf
|
|
1472
|
+
) : NO_ADDRESSES;
|
|
1473
|
+
return { secretNames: cfg.secrets ?? {}, resolve: resolve3 };
|
|
1097
1474
|
}
|
|
1098
1475
|
return {
|
|
1099
1476
|
name: "specbook",
|
|
@@ -1101,7 +1478,7 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
|
|
|
1101
1478
|
isBuild = c.command === "build";
|
|
1102
1479
|
},
|
|
1103
1480
|
async generateBundle() {
|
|
1104
|
-
const read = async (abs) => JSON.parse(await
|
|
1481
|
+
const read = async (abs) => JSON.parse(await readFile6(abs, "utf8"));
|
|
1105
1482
|
for (const [key, p] of bundlePlan) {
|
|
1106
1483
|
const out = {};
|
|
1107
1484
|
if (p.schema) out.schema = await read(p.schema);
|
|
@@ -1122,6 +1499,13 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
|
|
|
1122
1499
|
await Promise.all(statuses.map(async (n) => [n, await read(p.mock[n])]))
|
|
1123
1500
|
);
|
|
1124
1501
|
}
|
|
1502
|
+
if (only) {
|
|
1503
|
+
const svc = key.slice(0, key.indexOf("/"));
|
|
1504
|
+
const set = neededDefs.get(svc) ?? /* @__PURE__ */ new Set();
|
|
1505
|
+
for (const name of definitionRefs(JSON.stringify([out.schema, out.responses])))
|
|
1506
|
+
set.add(name);
|
|
1507
|
+
neededDefs.set(svc, set);
|
|
1508
|
+
}
|
|
1125
1509
|
this.emitFile({
|
|
1126
1510
|
type: "asset",
|
|
1127
1511
|
fileName: `data/${key}.json`,
|
|
@@ -1132,7 +1516,9 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
|
|
|
1132
1516
|
this.emitFile({
|
|
1133
1517
|
type: "asset",
|
|
1134
1518
|
fileName: `data/${svc}/_definitions.json`,
|
|
1135
|
-
source:
|
|
1519
|
+
source: only ? JSON.stringify(
|
|
1520
|
+
pruneDefinitions(JSON.parse(await readFile6(abs, "utf8")), neededDefs.get(svc))
|
|
1521
|
+
) : await readFile6(abs)
|
|
1136
1522
|
});
|
|
1137
1523
|
},
|
|
1138
1524
|
config(_, env) {
|
|
@@ -1144,7 +1530,8 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
|
|
|
1144
1530
|
// manifests are their own chunk (preloaded with the app, so no extra round trip), and
|
|
1145
1531
|
// operation modules are grouped by tag instead of one chunk per operation
|
|
1146
1532
|
manualChunks(id) {
|
|
1147
|
-
if (id
|
|
1533
|
+
if (id.startsWith(RESOLVED_MANIFEST))
|
|
1534
|
+
return `manifest-${id.slice(RESOLVED_MANIFEST.length)}`;
|
|
1148
1535
|
return operationChunks.get(id);
|
|
1149
1536
|
}
|
|
1150
1537
|
}
|
|
@@ -1156,7 +1543,7 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
|
|
|
1156
1543
|
if (id === VIRTUAL) return RESOLVED;
|
|
1157
1544
|
if (id === VIRTUAL_TYPES) return RESOLVED_TYPES;
|
|
1158
1545
|
if (id === VIRTUAL_FAKER) return RESOLVED_FAKER;
|
|
1159
|
-
if (id
|
|
1546
|
+
if (id.startsWith(VIRTUAL_MANIFEST)) return `\0${id}`;
|
|
1160
1547
|
if (id === VIRTUAL_OPERATIONS) return RESOLVED_OPERATIONS;
|
|
1161
1548
|
if (id.startsWith(VIRTUAL_FAKER_LOCALE)) return `\0${id}`;
|
|
1162
1549
|
if (id.startsWith(VIRTUAL_DOC)) return `\0${id}`;
|
|
@@ -1164,10 +1551,11 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
|
|
|
1164
1551
|
},
|
|
1165
1552
|
async load(id) {
|
|
1166
1553
|
if (id === RESOLVED) return registryCode();
|
|
1167
|
-
if (id
|
|
1554
|
+
if (id.startsWith(RESOLVED_MANIFEST))
|
|
1555
|
+
return manifestCode.get(id.slice(RESOLVED_MANIFEST.length)) ?? "export default {}";
|
|
1168
1556
|
if (id === RESOLVED_OPERATIONS) return operationsCode;
|
|
1169
1557
|
if (id === RESOLVED_FAKER || id.startsWith(RESOLVED_FAKER_LOCALE)) {
|
|
1170
|
-
const r = await this.resolve("@faker-js/faker",
|
|
1558
|
+
const r = await this.resolve("@faker-js/faker", join5(root, "package.json"), {
|
|
1171
1559
|
skipSelf: true
|
|
1172
1560
|
});
|
|
1173
1561
|
if (!r) return "export {}";
|
|
@@ -1176,7 +1564,7 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
|
|
|
1176
1564
|
return `export { ${id.slice(RESOLVED_FAKER_LOCALE.length)} as default } from ${pkg}`;
|
|
1177
1565
|
let entry = r.id;
|
|
1178
1566
|
try {
|
|
1179
|
-
entry = createRequire(
|
|
1567
|
+
entry = createRequire(join5(root, "package.json")).resolve("@faker-js/faker");
|
|
1180
1568
|
} catch {
|
|
1181
1569
|
}
|
|
1182
1570
|
const names = fakerLocales(entry);
|
|
@@ -1203,7 +1591,7 @@ ${names.map((n) => ` ${JSON.stringify(n)}: () => import(${JSON.stringify(`${VIR
|
|
|
1203
1591
|
basename(outDir)
|
|
1204
1592
|
]);
|
|
1205
1593
|
for (const entry of readdirSync(root, { withFileTypes: true }))
|
|
1206
|
-
if (entry.isDirectory() && !SKIP.has(entry.name)) s.watcher.add(
|
|
1594
|
+
if (entry.isDirectory() && !SKIP.has(entry.name)) s.watcher.add(join5(root, entry.name));
|
|
1207
1595
|
const relevant = (file) => {
|
|
1208
1596
|
const rel = relative4(root, file);
|
|
1209
1597
|
if (rel.startsWith("..") || /node_modules/.test(rel)) return false;
|
|
@@ -1227,7 +1615,7 @@ ${names.map((n) => ` ${JSON.stringify(n)}: () => import(${JSON.stringify(`${VIR
|
|
|
1227
1615
|
if (file.endsWith(".md")) return refresh(file);
|
|
1228
1616
|
if (!file.endsWith(".seq.ts")) return;
|
|
1229
1617
|
const header = JSON.stringify(
|
|
1230
|
-
parseSequenceMeta(await
|
|
1618
|
+
parseSequenceMeta(await readFile6(file, "utf8").catch(() => ""))
|
|
1231
1619
|
);
|
|
1232
1620
|
if (seqHeaders.get(file) !== header) {
|
|
1233
1621
|
seqHeaders.set(file, header);
|
|
@@ -1252,7 +1640,7 @@ ${names.map((n) => ` ${JSON.stringify(n)}: () => import(${JSON.stringify(`${VIR
|
|
|
1252
1640
|
const abs = safeProjectPath(root, url.searchParams.get("path") ?? "");
|
|
1253
1641
|
if (!abs || !existsSync3(abs)) return json2(res, 404, { error: "not found" });
|
|
1254
1642
|
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
1255
|
-
return void res.end(await
|
|
1643
|
+
return void res.end(await readFile6(abs, "utf8"));
|
|
1256
1644
|
}
|
|
1257
1645
|
if (url.pathname === "/__specbook/types")
|
|
1258
1646
|
return json2(res, 200, await collectTypes(root, outDir));
|
|
@@ -1271,6 +1659,35 @@ ${names.map((n) => ` ${JSON.stringify(n)}: () => import(${JSON.stringify(`${VIR
|
|
|
1271
1659
|
);
|
|
1272
1660
|
return json2(res, 200, { env, vars: values.vars, secrets, warnings });
|
|
1273
1661
|
}
|
|
1662
|
+
if (url.pathname === "/__specbook/environment" && req.method === "POST") {
|
|
1663
|
+
const origin = req.headers.origin;
|
|
1664
|
+
if (origin && new URL(origin).host !== req.headers.host)
|
|
1665
|
+
return json2(res, 403, { error: "cross-origin request refused" });
|
|
1666
|
+
const { name, from } = JSON.parse(await readBody2(req));
|
|
1667
|
+
if (!isEnvName(name) || name === "mock" || !isEnvName(from))
|
|
1668
|
+
return json2(res, 400, { error: "bad environment name" });
|
|
1669
|
+
const conf = await loadProjectConfig();
|
|
1670
|
+
const known = environmentNames(
|
|
1671
|
+
{ services: conf?.services ?? {}, environments: conf?.environments },
|
|
1672
|
+
await listEnvironmentFiles(root)
|
|
1673
|
+
);
|
|
1674
|
+
if (known.includes(name)) return json2(res, 409, { error: `"${name}" already exists` });
|
|
1675
|
+
if (!known.includes(from))
|
|
1676
|
+
return json2(res, 400, { error: `unknown environment "${from}"` });
|
|
1677
|
+
const own = {
|
|
1678
|
+
...conf?.environments?.[from]?.vars,
|
|
1679
|
+
...(await readEnvironment(root, from)).vars
|
|
1680
|
+
};
|
|
1681
|
+
await ensureEnvironmentsDir(root);
|
|
1682
|
+
const file = varsFile(root, name);
|
|
1683
|
+
await writeFile2(file, `# variables for ${name}, copied from ${from}
|
|
1684
|
+
`);
|
|
1685
|
+
for (const [k, v] of Object.entries(own)) {
|
|
1686
|
+
if (v === null || typeof v === "object" || !isVarName(k)) continue;
|
|
1687
|
+
await writeEnvKey(file, k, String(v));
|
|
1688
|
+
}
|
|
1689
|
+
return json2(res, 200, { ok: true, name });
|
|
1690
|
+
}
|
|
1274
1691
|
if ((url.pathname === "/__specbook/secret" || url.pathname === "/__specbook/var") && req.method === "POST") {
|
|
1275
1692
|
const origin = req.headers.origin;
|
|
1276
1693
|
if (origin && new URL(origin).host !== req.headers.host)
|
|
@@ -1337,6 +1754,7 @@ export {
|
|
|
1337
1754
|
listEnvironmentFiles,
|
|
1338
1755
|
readAllVars,
|
|
1339
1756
|
readEnvironment,
|
|
1757
|
+
requestsDirFor,
|
|
1340
1758
|
safeProjectPath,
|
|
1341
1759
|
scanProject,
|
|
1342
1760
|
secretsFileFor,
|