@specbook/vite 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,9 +1,168 @@
1
1
  // src/index.ts
2
2
  import { existsSync as existsSync3, readdirSync } from "fs";
3
- import { mkdir as mkdir2, readFile as readFile5, writeFile as writeFile2 } from "fs/promises";
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 join4, relative as relative4, resolve as resolve2, sep as sep4 } from "path";
6
- import fg4 from "fast-glob";
5
+ import { basename, dirname, join as join5, relative as relative4, resolve as resolve2, sep as sep4 } from "path";
6
+
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];
19
+ }
20
+ });
21
+
22
+ // ../core/src/template.ts
23
+ var NAME = String.raw`[\w.$\[\]-]+`;
24
+ var WHOLE = new RegExp(String.raw`^\s*\{\{\s*(${NAME})\s*\}\}\s*$`);
25
+ var EMBED = new RegExp(String.raw`\{\{\s*(${NAME})\s*\}\}`, "g");
26
+ var DEFERRED_NAMESPACES = ["secrets", "session"];
27
+ var TemplateError = class extends Error {
28
+ constructor(variable) {
29
+ super(`Missing variable "{{${variable}}}"`);
30
+ this.variable = variable;
31
+ this.name = "TemplateError";
32
+ }
33
+ variable;
34
+ };
35
+ function isDeferred(name) {
36
+ const ns = name.split(".")[0];
37
+ return DEFERRED_NAMESPACES.includes(ns) && name.includes(".");
38
+ }
39
+ var isFakerName = (name) => name.startsWith("faker.");
40
+ function fromFaker(path) {
41
+ let parent = faker;
42
+ let cur = faker;
43
+ for (const part of path.split(".").slice(1)) {
44
+ parent = cur;
45
+ cur = cur?.[part];
46
+ if (cur === void 0) return { found: false };
47
+ }
48
+ return { found: true, value: typeof cur === "function" ? cur.call(parent) : cur };
49
+ }
50
+ var steps = (path) => path.replace(/\[(\d+)\]/g, ".$1").split(".");
51
+ function lookup(scope, path) {
52
+ if (isFakerName(path)) return fromFaker(path);
53
+ let cur = scope;
54
+ for (const part of steps(path)) {
55
+ if (cur !== null && typeof cur === "object" && Object.hasOwn(cur, part)) {
56
+ cur = cur[part];
57
+ } else {
58
+ return { found: false };
59
+ }
60
+ }
61
+ return { found: true, value: cur };
62
+ }
63
+ function renderString(s, scope) {
64
+ const whole = WHOLE.exec(s);
65
+ if (whole) {
66
+ const name = whole[1];
67
+ if (isDeferred(name)) return s;
68
+ const r = lookup(scope, name);
69
+ if (!r.found) throw new TemplateError(name);
70
+ return r.value;
71
+ }
72
+ return s.replace(EMBED, (m, name) => {
73
+ if (isDeferred(name)) return m;
74
+ const r = lookup(scope, name);
75
+ if (!r.found) throw new TemplateError(name);
76
+ const v = r.value;
77
+ return typeof v === "object" && v !== null ? JSON.stringify(v) : String(v);
78
+ });
79
+ }
80
+ function collectPlaceholders(value, into = /* @__PURE__ */ new Set()) {
81
+ if (typeof value === "string") {
82
+ for (const m of value.matchAll(EMBED)) into.add(m[1]);
83
+ } else if (Array.isArray(value)) {
84
+ for (const v of value) collectPlaceholders(v, into);
85
+ } else if (value !== null && typeof value === "object") {
86
+ for (const v of Object.values(value)) collectPlaceholders(v, into);
87
+ }
88
+ return into;
89
+ }
90
+
91
+ // ../core/src/environment.ts
92
+ var AddressError = class extends Error {
93
+ constructor(message, variable) {
94
+ super(message);
95
+ this.variable = variable;
96
+ this.name = "AddressError";
97
+ }
98
+ variable;
99
+ };
100
+ var OAUTH_ADDRESS_FIELDS = [
101
+ "authorizationUrl",
102
+ "tokenUrl",
103
+ "openIdConnectUrl",
104
+ "clientId"
105
+ ];
106
+ function environmentScope(cfg, env, fileVars = {}, extra = {}) {
107
+ return {
108
+ ...cfg.vars,
109
+ ...cfg.environments?.[env]?.vars,
110
+ ...fileVars,
111
+ ...extra
112
+ };
113
+ }
114
+ function renderAddress(template, scope, what) {
115
+ for (const name of collectPlaceholders(template)) {
116
+ if (isDeferred(name) || isFakerName(name))
117
+ throw new AddressError(
118
+ `${what} cannot use {{${name}}}: only environment variables are available`
119
+ );
120
+ }
121
+ let value;
122
+ try {
123
+ value = renderString(template, scope);
124
+ } catch (e) {
125
+ if (e instanceof TemplateError) throw new AddressError(`${what}: set ${e.variable}`, e.variable);
126
+ throw e;
127
+ }
128
+ if (value !== null && typeof value === "object")
129
+ throw new AddressError(`${what} is not a text value`);
130
+ const out = value === void 0 || value === null ? "" : String(value).trim();
131
+ if (/\{\{/.test(out)) throw new AddressError(`${what} still contains a placeholder: ${out}`);
132
+ return out;
133
+ }
134
+ function baseUrlFor(cfg, service, env, scope) {
135
+ const base = cfg.services[service]?.baseUrl;
136
+ const template = typeof base === "string" ? base : base?.[env];
137
+ if (!template) return void 0;
138
+ const what = `No base URL for ${service} in ${env}`;
139
+ const url = /\{\{/.test(template) ? renderAddress(template, scope, what) : template.trim();
140
+ return url ? url.replace(/\/+$/, "") : void 0;
141
+ }
142
+ function oauthFor(oauth, scope, profile = "sign-in") {
143
+ const out = { ...oauth };
144
+ for (const field of OAUTH_ADDRESS_FIELDS) {
145
+ const v = out[field];
146
+ if (typeof v !== "string" || !/\{\{/.test(v)) continue;
147
+ const rendered = renderAddress(v, scope, `${profile} ${field}`);
148
+ if (rendered) out[field] = rendered;
149
+ else delete out[field];
150
+ }
151
+ return out;
152
+ }
153
+ function environmentNames(cfg, fileEnvs = [], extra = []) {
154
+ const names = /* @__PURE__ */ new Set(["mock"]);
155
+ const services = Object.values(cfg.services ?? {});
156
+ if (services.some((s) => typeof s.baseUrl === "string")) names.add("live");
157
+ for (const s of services) {
158
+ if (s.baseUrl && typeof s.baseUrl === "object")
159
+ for (const k of Object.keys(s.baseUrl)) names.add(k);
160
+ }
161
+ for (const k of Object.keys(cfg.environments ?? {})) names.add(k);
162
+ for (const k of fileEnvs) names.add(k);
163
+ for (const k of extra) names.add(k);
164
+ return [...names];
165
+ }
7
166
 
8
167
  // ../core/src/oauth.ts
9
168
  var OAuthError = class extends Error {
@@ -318,6 +477,65 @@ function parseDotenv(text) {
318
477
  return out;
319
478
  }
320
479
 
480
+ // src/index.ts
481
+ import fg4 from "fast-glob";
482
+
483
+ // src/addresses.ts
484
+ var NO_ADDRESSES = {
485
+ services: {},
486
+ tokenOrigins: {},
487
+ discoveryOrigins: {}
488
+ };
489
+ async function addressesFor(cfg, env, files, tokenOriginOf) {
490
+ const services = cfg.services ?? {};
491
+ const known = environmentNames({ services, environments: cfg.environments }, files.environments);
492
+ if (!known.includes(env)) return NO_ADDRESSES;
493
+ const scope = environmentScope(cfg, env, await files.vars());
494
+ const out = { services: {}, tokenOrigins: {}, discoveryOrigins: {} };
495
+ for (const [name, svc] of Object.entries(services)) {
496
+ if (env === "mock" && typeof svc.baseUrl === "string") continue;
497
+ try {
498
+ out.services[name] = baseUrlFor({ services }, name, env, scope);
499
+ } catch {
500
+ }
501
+ }
502
+ for (const [name, profile] of Object.entries(cfg.auth ?? {})) {
503
+ try {
504
+ if (!profile.oauth) continue;
505
+ const oauth = oauthFor(profile.oauth, scope, name);
506
+ if ("tokenUrl" in oauth && oauth.tokenUrl)
507
+ out.tokenOrigins[name] = new URL(oauth.tokenUrl).origin;
508
+ else if ("openIdConnectUrl" in oauth && oauth.openIdConnectUrl) {
509
+ out.discoveryOrigins[name] = new URL(oauth.openIdConnectUrl).origin;
510
+ const token = await tokenOriginOf(oauth.openIdConnectUrl);
511
+ if (token) out.tokenOrigins[name] = token;
512
+ }
513
+ } catch {
514
+ }
515
+ }
516
+ return out;
517
+ }
518
+
519
+ // src/definitions.ts
520
+ function definitionRefs(text) {
521
+ const names = [];
522
+ for (const m of text.matchAll(/"\$ref":"(?:specbook:\/\/[^"#]*)?#\/\$defs\/([^"]+)"/g))
523
+ names.push(m[1].replace(/~1/g, "/").replace(/~0/g, "~"));
524
+ return names;
525
+ }
526
+ function pruneDefinitions(defs, roots) {
527
+ const all = defs.$defs ?? {};
528
+ const keep = /* @__PURE__ */ new Set();
529
+ const queue = [...roots ?? []];
530
+ while (queue.length) {
531
+ const name = queue.pop();
532
+ if (keep.has(name) || !(name in all)) continue;
533
+ keep.add(name);
534
+ queue.push(...definitionRefs(JSON.stringify(all[name])));
535
+ }
536
+ return { ...defs, $defs: Object.fromEntries(Object.entries(all).filter(([k]) => keep.has(k))) };
537
+ }
538
+
321
539
  // src/discovery.ts
322
540
  var FETCH_TIMEOUT_MS = 5e3;
323
541
  function createDiscovery(opts = {}) {
@@ -511,7 +729,7 @@ async function writeEnvKey(file, key, value) {
511
729
  import { relative as relative2, sep as sep2 } from "path";
512
730
  import fg2 from "fast-glob";
513
731
  var IGNORE = ["**/node_modules/**", "**/.generated/**", "**/dist/**", "**/.git/**"];
514
- var NAME = /\.example\.json$/;
732
+ var NAME2 = /\.example\.json$/;
515
733
  async function scanExamples(root, outDir, operations) {
516
734
  const examples = {};
517
735
  const assets = {};
@@ -527,8 +745,9 @@ async function scanExamples(root, outDir, operations) {
527
745
  const gen = `${root}/${outDir}`;
528
746
  for (const abs of await fg2("*/examples/*/*.example.json", { cwd: gen, absolute: true })) {
529
747
  const [svc, , id, file] = relative2(gen, abs).split(sep2);
748
+ if (!operations.has(`${svc}/${id}`)) continue;
530
749
  add(`${svc}/${id}`, {
531
- name: file.replace(NAME, ""),
750
+ name: file.replace(NAME2, ""),
532
751
  abs,
533
752
  source: "generated",
534
753
  dir: `${svc}/${id}@generated`
@@ -542,7 +761,7 @@ async function scanExamples(root, outDir, operations) {
542
761
  const key = `${svc}/${id}`;
543
762
  if (!svc || !id || !operations.has(key)) continue;
544
763
  add(key, {
545
- name: file.replace(NAME, ""),
764
+ name: file.replace(NAME2, ""),
546
765
  abs,
547
766
  source: "project",
548
767
  dir: `${svc}/${id}@project`
@@ -622,6 +841,15 @@ async function handleProxy2(req, res, deps) {
622
841
  const url = req.url ?? "/";
623
842
  if (!url.startsWith(`${MOUNT}/`)) return false;
624
843
  const config = await deps.getConfig();
844
+ const addressesByEnv = /* @__PURE__ */ new Map();
845
+ const addressesFor2 = (env) => {
846
+ let a = addressesByEnv.get(env);
847
+ if (!a) {
848
+ a = config.resolve(env);
849
+ addressesByEnv.set(env, a);
850
+ }
851
+ return a;
852
+ };
625
853
  const secretsByEnv = /* @__PURE__ */ new Map();
626
854
  const method = req.method ?? "GET";
627
855
  const headers = new Headers();
@@ -636,12 +864,13 @@ async function handleProxy2(req, res, deps) {
636
864
  const response = await handleProxy(request, {
637
865
  mount: MOUNT,
638
866
  policy: {
639
- upstream: (env, service) => {
867
+ upstream: async (env, service) => {
868
+ const a = await addressesFor2(env);
640
869
  if (service.startsWith(AUTH_SERVICE_PREFIX))
641
- return config.tokenOrigins?.[decode(service.slice(AUTH_SERVICE_PREFIX.length))];
870
+ return a.tokenOrigins[decode(service.slice(AUTH_SERVICE_PREFIX.length))];
642
871
  if (service.startsWith(DISCOVERY_SERVICE_PREFIX))
643
- return config.discoveryOrigins?.[decode(service.slice(DISCOVERY_SERVICE_PREFIX.length))];
644
- return config.services[service]?.baseUrl[env];
872
+ return a.discoveryOrigins[decode(service.slice(DISCOVERY_SERVICE_PREFIX.length))];
873
+ return a.services[service];
645
874
  },
646
875
  // a developer's own machine: their local services are the point
647
876
  allowPrivateHosts: true
@@ -671,9 +900,47 @@ async function handleProxy2(req, res, deps) {
671
900
  return true;
672
901
  }
673
902
 
674
- // src/sequences.ts
903
+ // src/requests.ts
675
904
  import { readFile as readFile4 } from "fs/promises";
676
905
  import { join as join3 } from "path";
906
+ function parseRequestTarget(src, services) {
907
+ const local = /* @__PURE__ */ new Map();
908
+ for (const m of src.matchAll(/import\s*(?:type\s*)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g)) {
909
+ const service = m[2].split("/").pop();
910
+ if (!services.has(service) || /^import\s*type/.test(m[0])) continue;
911
+ for (const part of m[1].split(",")) {
912
+ const spec = part.trim();
913
+ if (!spec || spec.startsWith("type ")) continue;
914
+ const [imported, as] = spec.split(/\s+as\s+/);
915
+ local.set(as ?? imported, `${service}/${imported}`);
916
+ }
917
+ }
918
+ const used = /* @__PURE__ */ new Set();
919
+ for (const m of src.matchAll(/\b([A-Za-z_$][\w$]*)\s*\.\s*request\s*\(/g)) {
920
+ const key = local.get(m[1]);
921
+ if (key) used.add(key);
922
+ }
923
+ return used.size === 1 ? [...used][0] : void 0;
924
+ }
925
+ async function scanRequests(root, files, services) {
926
+ const meta = {};
927
+ const unresolved = [];
928
+ await Promise.all(
929
+ files.map(async (file) => {
930
+ const key = parseRequestTarget(
931
+ await readFile4(join3(root, file), "utf8").catch(() => ""),
932
+ services
933
+ );
934
+ if (key) meta[file] = key;
935
+ else unresolved.push(file);
936
+ })
937
+ );
938
+ return { meta, unresolved: unresolved.sort() };
939
+ }
940
+
941
+ // src/sequences.ts
942
+ import { readFile as readFile5 } from "fs/promises";
943
+ import { join as join4 } from "path";
677
944
  var KEYS = ["id", "title", "description", "group", "tags"];
678
945
  function readString(src, i) {
679
946
  const q = src[i];
@@ -771,7 +1038,7 @@ async function scanSequences(root, files) {
771
1038
  const unresolved = [];
772
1039
  await Promise.all(
773
1040
  files.map(async (file) => {
774
- const parsed = parseSequenceMeta(await readFile4(join3(root, file), "utf8").catch(() => ""));
1041
+ const parsed = parseSequenceMeta(await readFile5(join4(root, file), "utf8").catch(() => ""));
775
1042
  if (parsed) meta[parsed.id] = { ...parsed, file };
776
1043
  else unresolved.push(file);
777
1044
  })
@@ -788,14 +1055,14 @@ var VIRTUAL_FAKER = "virtual:specbook/faker";
788
1055
  var RESOLVED_FAKER = `\0${VIRTUAL_FAKER}`;
789
1056
  var VIRTUAL_FAKER_LOCALE = "virtual:specbook/faker-locale/";
790
1057
  var RESOLVED_FAKER_LOCALE = `\0${VIRTUAL_FAKER_LOCALE}`;
791
- var VIRTUAL_MANIFESTS = "virtual:specbook/manifests";
792
- var RESOLVED_MANIFESTS = `\0${VIRTUAL_MANIFESTS}`;
1058
+ var VIRTUAL_MANIFEST = "virtual:specbook/manifest/";
1059
+ var RESOLVED_MANIFEST = `\0${VIRTUAL_MANIFEST}`;
793
1060
  var VIRTUAL_OPERATIONS = "virtual:specbook/operations";
794
1061
  var RESOLVED_OPERATIONS = `\0${VIRTUAL_OPERATIONS}`;
795
1062
  var VIRTUAL_DOC = "virtual:specbook/doc/";
796
1063
  var RESOLVED_DOC = `\0${VIRTUAL_DOC}`;
797
1064
  function findConfig(root) {
798
- return [join4(root, ".specbook/specbook.config.ts"), join4(root, "specbook.config.ts")].find(
1065
+ return [join5(root, ".specbook/specbook.config.ts"), join5(root, "specbook.config.ts")].find(
799
1066
  existsSync3
800
1067
  );
801
1068
  }
@@ -816,7 +1083,7 @@ async function readBody2(req) {
816
1083
  }
817
1084
  function fakerLocales(entry) {
818
1085
  const names = /* @__PURE__ */ new Set(["en", "base"]);
819
- const dir = join4(dirname(entry), "locale");
1086
+ const dir = join5(dirname(entry), "locale");
820
1087
  if (existsSync3(dir))
821
1088
  for (const f of readdirSync(dir)) {
822
1089
  const m = /^([A-Za-z0-9_]+)\.js$/.exec(f);
@@ -839,13 +1106,13 @@ function slim(manifests) {
839
1106
  }
840
1107
  async function collectTypes(root, outDir) {
841
1108
  const libs = {};
842
- const req = createRequire(join4(root, "package.json"));
1109
+ const req = createRequire(join5(root, "package.json"));
843
1110
  try {
844
1111
  const entry = req.resolve("@specbook/core");
845
1112
  const srcDir = dirname(entry);
846
1113
  const files = await fg4("**/*.ts", { cwd: srcDir, ignore: ["**/*.test.ts"] });
847
1114
  for (const f of files) {
848
- libs[`file:///node_modules/@specbook/core/src/${f}`] = await readFile5(join4(srcDir, f), "utf8");
1115
+ libs[`file:///node_modules/@specbook/core/src/${f}`] = await readFile6(join5(srcDir, f), "utf8");
849
1116
  }
850
1117
  libs["file:///node_modules/@specbook/core/package.json"] = JSON.stringify({
851
1118
  name: "@specbook/core",
@@ -853,24 +1120,27 @@ async function collectTypes(root, outDir) {
853
1120
  });
854
1121
  } catch {
855
1122
  }
856
- const genDir = join4(root, outDir);
1123
+ const genDir = join5(root, outDir);
857
1124
  for (const f of await fg4("**/*.ts", { cwd: genDir })) {
858
- libs[`file:///${outDir}/${f}`] = await readFile5(join4(genDir, f), "utf8");
1125
+ libs[`file:///${outDir}/${f}`] = await readFile6(join5(genDir, f), "utf8");
859
1126
  }
860
1127
  const project = await scanProject(root);
861
1128
  const sources = {};
862
1129
  for (const f of project.sources.filter((s) => s.endsWith(".ts"))) {
863
- sources[f] = await readFile5(join4(root, f), "utf8");
1130
+ sources[f] = await readFile6(join5(root, f), "utf8");
864
1131
  }
865
1132
  return { libs, sources };
866
1133
  }
867
1134
  function specbook(options) {
868
1135
  const root = resolve2(options.projectRoot);
869
1136
  const outDir = options.outDir ?? ".generated";
1137
+ const only = options.operations;
1138
+ const keep = (service, op) => !only || only.has(`${service}/${op}`);
1139
+ const neededDefs = /* @__PURE__ */ new Map();
870
1140
  let server;
871
1141
  let isBuild = false;
872
1142
  let docHtml = [];
873
- let manifestsCode = "export default {}";
1143
+ let manifestCode = /* @__PURE__ */ new Map();
874
1144
  let operationsCode = "export const operations = {}";
875
1145
  let bundlePlan = /* @__PURE__ */ new Map();
876
1146
  let definitionFiles = /* @__PURE__ */ new Map();
@@ -883,13 +1153,25 @@ function specbook(options) {
883
1153
  async function registryCode() {
884
1154
  const files = await scanProject(root);
885
1155
  const manifests = {};
886
- for (const m of await fg4("*/manifest.json", { cwd: join4(root, outDir), absolute: true })) {
887
- const parsed = JSON.parse(await readFile5(m, "utf8"));
1156
+ for (const m of await fg4("*/manifest.json", { cwd: join5(root, outDir), absolute: true })) {
1157
+ const parsed = JSON.parse(await readFile6(m, "utf8"));
888
1158
  manifests[parsed.service] = parsed;
889
1159
  }
890
- const examples = await fg4("*/examples/*/*.response.json", {
891
- cwd: join4(root, outDir),
1160
+ if (only) {
1161
+ for (const [svc, m] of Object.entries(manifests)) {
1162
+ const mm = m;
1163
+ mm.operations = mm.operations.filter((o) => keep(svc, o.id));
1164
+ if (mm.operations.length === 0) delete manifests[svc];
1165
+ else mm.tags = [...new Set(mm.operations.flatMap((o) => o.tags ?? []))];
1166
+ }
1167
+ }
1168
+ const inScope = (svc, op) => svc in manifests && keep(svc, op);
1169
+ const examples = (await fg4("*/examples/*/*.response.json", {
1170
+ cwd: join5(root, outDir),
892
1171
  absolute: true
1172
+ })).filter((abs) => {
1173
+ const rel = relative4(join5(root, outDir), abs).split(sep4);
1174
+ return inScope(rel[0], rel[2]);
893
1175
  });
894
1176
  const imp = (abs) => {
895
1177
  if (isBuild) return `() => import(${JSON.stringify(abs)})`;
@@ -898,50 +1180,45 @@ function specbook(options) {
898
1180
  return `(v) => import(/* @vite-ignore */ ${JSON.stringify(`/@fs${abs}`)} + '?t=' + v)`;
899
1181
  };
900
1182
  const raw = (abs) => `() => import(${JSON.stringify(`${abs}?raw`)}).then((m) => m.default)`;
901
- const entry = (file) => `{ file: ${JSON.stringify(file)}, load: ${imp(join4(root, file))}${isBuild ? `, source: ${raw(join4(root, file))}` : ""} }`;
1183
+ const entry = (file) => `{ file: ${JSON.stringify(file)}, load: ${imp(join5(root, file))}${isBuild ? `, source: ${raw(join5(root, file))}` : ""} }`;
902
1184
  const cfg = findConfig(root);
903
1185
  const requestsDir = requestsDirFor(root);
904
- existsSync3(join4(root, "apis")) && !existsSync3(join4(root, "requests")) ? "apis" : "requests";
1186
+ existsSync3(join5(root, "apis")) && !existsSync3(join5(root, "requests")) ? "apis" : "requests";
905
1187
  let fakerEntry;
906
1188
  try {
907
- fakerEntry = createRequire(join4(root, "package.json")).resolve("@faker-js/faker");
1189
+ fakerEntry = createRequire(join5(root, "package.json")).resolve("@faker-js/faker");
908
1190
  } catch {
909
1191
  }
910
1192
  const operationEntries = Object.values(manifests).flatMap((m) => {
911
1193
  const mm = m;
912
1194
  return mm.operations.map(
913
- (o) => ` ${JSON.stringify(`${mm.service}/${o.id}`)}: ${imp(join4(root, outDir, mm.service, "operations", `${o.id}.ts`))}`
1195
+ (o) => ` ${JSON.stringify(`${mm.service}/${o.id}`)}: ${imp(join5(root, outDir, mm.service, "operations", `${o.id}.ts`))}`
914
1196
  );
915
1197
  });
916
- const schemaFiles = await fg4("*/schemas/*.request.schema.json", {
917
- cwd: join4(root, outDir),
918
- absolute: true
919
- });
920
- const responseFiles = await fg4("*/schemas/*.responses.schema.json", {
921
- cwd: join4(root, outDir),
922
- absolute: true
923
- });
1198
+ const opOf = (abs, suffix) => {
1199
+ const rel = relative4(join5(root, outDir), abs).split(sep4);
1200
+ return [rel[0], rel[2].replace(suffix, "")];
1201
+ };
1202
+ const schemaFiles = (await fg4("*/schemas/*.request.schema.json", { cwd: join5(root, outDir), absolute: true })).filter((abs) => inScope(...opOf(abs, /\.request\.schema\.json$/)));
1203
+ const responseFiles = (await fg4("*/schemas/*.responses.schema.json", { cwd: join5(root, outDir), absolute: true })).filter((abs) => inScope(...opOf(abs, /\.responses\.schema\.json$/)));
924
1204
  const responseEntries = responseFiles.map((abs) => {
925
- const rel = relative4(join4(root, outDir), abs).split(sep4);
1205
+ const rel = relative4(join5(root, outDir), abs).split(sep4);
926
1206
  const op = rel[2].replace(/\.responses\.schema\.json$/, "");
927
1207
  return ` ${JSON.stringify(`${rel[0]}/${op}`)}: ${imp(abs)}`;
928
1208
  });
929
- const defFiles = await fg4("*/schemas/definitions.schema.json", {
930
- cwd: join4(root, outDir),
931
- absolute: true
932
- });
1209
+ 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);
933
1210
  const schemaEntries = schemaFiles.map((abs) => {
934
- const rel = relative4(join4(root, outDir), abs).split(sep4);
1211
+ const rel = relative4(join5(root, outDir), abs).split(sep4);
935
1212
  const op = rel[2].replace(/\.request\.schema\.json$/, "");
936
1213
  return ` ${JSON.stringify(`${rel[0]}/${op}`)}: ${imp(abs)}`;
937
1214
  });
938
- const docFiles = await fg4("*/docs/*.json", { cwd: join4(root, outDir), absolute: true });
1215
+ const docFiles = (await fg4("*/docs/*.json", { cwd: join5(root, outDir), absolute: true })).filter((abs) => inScope(...opOf(abs, /\.json$/)));
939
1216
  const operationDocEntries = docFiles.map((abs) => {
940
- const rel = relative4(join4(root, outDir), abs).split(sep4);
1217
+ const rel = relative4(join5(root, outDir), abs).split(sep4);
941
1218
  return ` ${JSON.stringify(`${rel[0]}/${rel[2].replace(/\.json$/, "")}`)}: ${imp(abs)}`;
942
1219
  });
943
1220
  const defEntries = defFiles.map((abs) => {
944
- const service = relative4(join4(root, outDir), abs).split(sep4)[0];
1221
+ const service = relative4(join5(root, outDir), abs).split(sep4)[0];
945
1222
  return ` ${JSON.stringify(service)}: ${imp(abs)}`;
946
1223
  });
947
1224
  const known = new Set(
@@ -951,11 +1228,15 @@ function specbook(options) {
951
1228
  })
952
1229
  );
953
1230
  const sequenceScan = await scanSequences(root, files.sequences);
1231
+ const requestScan = await scanRequests(root, files.requests, new Set(Object.keys(manifests)));
954
1232
  for (const f of files.sequences) {
955
- const src = await readFile5(join4(root, f), "utf8").catch(() => "");
956
- seqHeaders.set(join4(root, f), JSON.stringify(parseSequenceMeta(src)));
1233
+ const src = await readFile6(join5(root, f), "utf8").catch(() => "");
1234
+ seqHeaders.set(join5(root, f), JSON.stringify(parseSequenceMeta(src)));
957
1235
  }
958
1236
  const scanned = await scanExamples(root, outDir, known);
1237
+ const keepExampleKey = (key) => !only || only.has(key);
1238
+ for (const key of Object.keys(scanned.examples))
1239
+ if (!keepExampleKey(key)) delete scanned.examples[key];
959
1240
  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)`;
960
1241
  const requestExampleEntries = Object.entries(scanned.examples).map(
961
1242
  ([key, list]) => ` ${JSON.stringify(key)}: [${list.map(
@@ -977,23 +1258,28 @@ function specbook(options) {
977
1258
  const assetEntries = Object.entries(scanned.assets).map(
978
1259
  ([key, abs]) => ` ${JSON.stringify(key)}: ${url(abs)}`
979
1260
  );
980
- const pages = await scanDocs(root);
1261
+ const pages = (await scanDocs(root)).filter(
1262
+ (p) => !only || !p.operation || only.has(p.operation)
1263
+ );
981
1264
  docHtml = pages.map((p) => p.html);
982
1265
  const docEntries = pages.map(
983
1266
  (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) }`
984
1267
  );
985
1268
  const exampleEntries = examples.map((abs) => {
986
- const rel = relative4(join4(root, outDir), abs).split(sep4);
1269
+ const rel = relative4(join5(root, outDir), abs).split(sep4);
987
1270
  const status = rel[3].replace(/\.response\.json$/, "");
988
1271
  const key = `${rel[0]}/${rel[2]}.${status}`;
989
1272
  return ` ${JSON.stringify(key)}: ${imp(abs)}`;
990
1273
  });
991
1274
  if (isBuild) {
992
- manifestsCode = `export default ${JSON.stringify(slim(manifests))}`;
1275
+ const slimmed = slim(manifests);
1276
+ manifestCode = new Map(
1277
+ Object.entries(slimmed).map(([svc, m]) => [svc, `export default ${JSON.stringify(m)}`])
1278
+ );
993
1279
  operationsCode = `export const operations = {
994
1280
  ${operationEntries.join(",\n")}
995
1281
  }`;
996
- const gen = join4(root, outDir);
1282
+ const gen = join5(root, outDir);
997
1283
  bundlePlan = /* @__PURE__ */ new Map();
998
1284
  const slot = (key) => {
999
1285
  const have = bundlePlan.get(key);
@@ -1033,7 +1319,7 @@ ${operationEntries.join(",\n")}
1033
1319
  const n = counts.get(tag) ?? 0;
1034
1320
  counts.set(tag, n + 1);
1035
1321
  operationChunks.set(
1036
- join4(gen, m.service, "operations", `${o.id}.ts`),
1322
+ join5(gen, m.service, "operations", `${o.id}.ts`),
1037
1323
  `ops-${m.service}-${tag}-${Math.floor(n / 40)}`
1038
1324
  );
1039
1325
  }
@@ -1047,8 +1333,8 @@ ${operationEntries.join(",\n")}
1047
1333
  projectLoads.push(` ${JSON.stringify(`${key}/${e.name}`)}: ${imp(e.abs)}`);
1048
1334
  }
1049
1335
  const services = Object.keys(manifests);
1050
- return `import manifests from ${JSON.stringify(VIRTUAL_MANIFESTS)}
1051
- export const mode = "static"
1336
+ return `export const mode = "static"
1337
+ export const minimal = ${JSON.stringify(!!only)}
1052
1338
  export const project = ${JSON.stringify({ root, outDir, requestsDir })}
1053
1339
  // where the data files are: dist/data, whether this chunk is in dist/ or dist/assets/
1054
1340
  const dataBase = import.meta.url.replace(/(assets\\/)?[^/]*$/, '') + 'data/'
@@ -1062,8 +1348,15 @@ const bundle = (key) => {
1062
1348
  }
1063
1349
  const part = (pick) =>
1064
1350
  new Proxy({}, { get: (_, key) => (typeof key === 'string' && key.includes('/') ? () => bundle(key).then((b) => ({ default: pick(b) })) : undefined) })
1065
- export { manifests }
1351
+ export const manifests = {}
1352
+ const manifestLoaders = {${services.map((svc) => `
1353
+ ${JSON.stringify(svc)}: () => import(${JSON.stringify(`${VIRTUAL_MANIFEST}${svc}`)})`).join(",")}
1354
+ }
1355
+ export const loadManifests = (names = Object.keys(manifestLoaders)) =>
1356
+ Promise.all(names.map((svc) => manifests[svc] ? undefined : manifestLoaders[svc]?.().then((m) => { manifests[svc] = m.default }))).then(() => undefined)
1066
1357
  export const requests = [${files.requests.map(entry).join(",\n")}]
1358
+ export const requestMeta = ${JSON.stringify(requestScan.meta)}
1359
+ export const unresolvedRequests = ${JSON.stringify(requestScan.unresolved)}
1067
1360
  export const sequences = [${files.sequences.map(entry).join(",\n")}]
1068
1361
  export const sequenceMeta = ${JSON.stringify(sequenceScan.meta)}
1069
1362
  export const unresolvedSequences = ${JSON.stringify(sequenceScan.unresolved)}
@@ -1119,9 +1412,13 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
1119
1412
  `;
1120
1413
  }
1121
1414
  return `export const mode = ${JSON.stringify(isBuild ? "static" : "dev")}
1415
+ export const minimal = ${JSON.stringify(!!only)}
1122
1416
  export const project = ${JSON.stringify({ root, outDir, requestsDir })}
1123
1417
  export const manifests = ${JSON.stringify(slim(manifests))}
1418
+ export const loadManifests = () => Promise.resolve()
1124
1419
  export const requests = [${files.requests.map(entry).join(",\n")}]
1420
+ export const requestMeta = ${JSON.stringify(requestScan.meta)}
1421
+ export const unresolvedRequests = ${JSON.stringify(requestScan.unresolved)}
1125
1422
  export const sequences = [${files.sequences.map(entry).join(",\n")}]
1126
1423
  export const sequenceMeta = ${JSON.stringify(sequenceScan.meta)}
1127
1424
  export const unresolvedSequences = ${JSON.stringify(sequenceScan.unresolved)}
@@ -1158,33 +1455,24 @@ export const loadFaker = ${fakerEntry ? `() => import('virtual:specbook/faker')`
1158
1455
  export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: { project: "specbook", services: {} } })'}
1159
1456
  `;
1160
1457
  }
1161
- async function getProxyConfig() {
1458
+ async function loadProjectConfig() {
1162
1459
  const cfgFile = findConfig(root);
1163
- if (!server || !cfgFile) return { services: {}, secretNames: {} };
1164
- const mod = await server.ssrLoadModule(cfgFile);
1165
- const cfg = mod.default ?? {};
1166
- const tokenOrigins = {};
1167
- const discoveryOrigins = {};
1168
- for (const [name, profile] of Object.entries(cfg.auth ?? {})) {
1169
- try {
1170
- const { tokenUrl, openIdConnectUrl } = profile.oauth ?? {};
1171
- if (tokenUrl) tokenOrigins[name] = new URL(tokenUrl).origin;
1172
- else if (openIdConnectUrl) {
1173
- discoveryOrigins[name] = new URL(openIdConnectUrl).origin;
1174
- const token = await tokenOriginOf(openIdConnectUrl);
1175
- if (token) tokenOrigins[name] = token;
1176
- }
1177
- } catch {
1178
- }
1179
- }
1180
- return {
1181
- services: Object.fromEntries(
1182
- Object.entries(cfg.services ?? {}).map(([k, v]) => [k, { baseUrl: v.baseUrl ?? {} }])
1183
- ),
1184
- secretNames: cfg.secrets ?? {},
1185
- tokenOrigins,
1186
- discoveryOrigins
1187
- };
1460
+ if (!server || !cfgFile) return void 0;
1461
+ return (await server.ssrLoadModule(cfgFile)).default ?? {};
1462
+ }
1463
+ async function getProxyConfig() {
1464
+ const cfg = await loadProjectConfig();
1465
+ if (!cfg) return { secretNames: {}, resolve: async () => NO_ADDRESSES };
1466
+ const resolve3 = async (env) => isEnvName(env) ? addressesFor(
1467
+ cfg,
1468
+ env,
1469
+ {
1470
+ environments: await listEnvironmentFiles(root),
1471
+ vars: async () => (await readEnvironment(root, env)).vars
1472
+ },
1473
+ tokenOriginOf
1474
+ ) : NO_ADDRESSES;
1475
+ return { secretNames: cfg.secrets ?? {}, resolve: resolve3 };
1188
1476
  }
1189
1477
  return {
1190
1478
  name: "specbook",
@@ -1192,7 +1480,7 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
1192
1480
  isBuild = c.command === "build";
1193
1481
  },
1194
1482
  async generateBundle() {
1195
- const read = async (abs) => JSON.parse(await readFile5(abs, "utf8"));
1483
+ const read = async (abs) => JSON.parse(await readFile6(abs, "utf8"));
1196
1484
  for (const [key, p] of bundlePlan) {
1197
1485
  const out = {};
1198
1486
  if (p.schema) out.schema = await read(p.schema);
@@ -1213,6 +1501,13 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
1213
1501
  await Promise.all(statuses.map(async (n) => [n, await read(p.mock[n])]))
1214
1502
  );
1215
1503
  }
1504
+ if (only) {
1505
+ const svc = key.slice(0, key.indexOf("/"));
1506
+ const set = neededDefs.get(svc) ?? /* @__PURE__ */ new Set();
1507
+ for (const name of definitionRefs(JSON.stringify([out.schema, out.responses])))
1508
+ set.add(name);
1509
+ neededDefs.set(svc, set);
1510
+ }
1216
1511
  this.emitFile({
1217
1512
  type: "asset",
1218
1513
  fileName: `data/${key}.json`,
@@ -1223,7 +1518,9 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
1223
1518
  this.emitFile({
1224
1519
  type: "asset",
1225
1520
  fileName: `data/${svc}/_definitions.json`,
1226
- source: await readFile5(abs)
1521
+ source: only ? JSON.stringify(
1522
+ pruneDefinitions(JSON.parse(await readFile6(abs, "utf8")), neededDefs.get(svc))
1523
+ ) : await readFile6(abs)
1227
1524
  });
1228
1525
  },
1229
1526
  config(_, env) {
@@ -1235,7 +1532,8 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
1235
1532
  // manifests are their own chunk (preloaded with the app, so no extra round trip), and
1236
1533
  // operation modules are grouped by tag instead of one chunk per operation
1237
1534
  manualChunks(id) {
1238
- if (id === RESOLVED_MANIFESTS) return "manifests";
1535
+ if (id.startsWith(RESOLVED_MANIFEST))
1536
+ return `manifest-${id.slice(RESOLVED_MANIFEST.length)}`;
1239
1537
  return operationChunks.get(id);
1240
1538
  }
1241
1539
  }
@@ -1247,7 +1545,7 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
1247
1545
  if (id === VIRTUAL) return RESOLVED;
1248
1546
  if (id === VIRTUAL_TYPES) return RESOLVED_TYPES;
1249
1547
  if (id === VIRTUAL_FAKER) return RESOLVED_FAKER;
1250
- if (id === VIRTUAL_MANIFESTS) return RESOLVED_MANIFESTS;
1548
+ if (id.startsWith(VIRTUAL_MANIFEST)) return `\0${id}`;
1251
1549
  if (id === VIRTUAL_OPERATIONS) return RESOLVED_OPERATIONS;
1252
1550
  if (id.startsWith(VIRTUAL_FAKER_LOCALE)) return `\0${id}`;
1253
1551
  if (id.startsWith(VIRTUAL_DOC)) return `\0${id}`;
@@ -1255,10 +1553,11 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
1255
1553
  },
1256
1554
  async load(id) {
1257
1555
  if (id === RESOLVED) return registryCode();
1258
- if (id === RESOLVED_MANIFESTS) return manifestsCode;
1556
+ if (id.startsWith(RESOLVED_MANIFEST))
1557
+ return manifestCode.get(id.slice(RESOLVED_MANIFEST.length)) ?? "export default {}";
1259
1558
  if (id === RESOLVED_OPERATIONS) return operationsCode;
1260
1559
  if (id === RESOLVED_FAKER || id.startsWith(RESOLVED_FAKER_LOCALE)) {
1261
- const r = await this.resolve("@faker-js/faker", join4(root, "package.json"), {
1560
+ const r = await this.resolve("@faker-js/faker", join5(root, "package.json"), {
1262
1561
  skipSelf: true
1263
1562
  });
1264
1563
  if (!r) return "export {}";
@@ -1267,7 +1566,7 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
1267
1566
  return `export { ${id.slice(RESOLVED_FAKER_LOCALE.length)} as default } from ${pkg}`;
1268
1567
  let entry = r.id;
1269
1568
  try {
1270
- entry = createRequire(join4(root, "package.json")).resolve("@faker-js/faker");
1569
+ entry = createRequire(join5(root, "package.json")).resolve("@faker-js/faker");
1271
1570
  } catch {
1272
1571
  }
1273
1572
  const names = fakerLocales(entry);
@@ -1294,7 +1593,7 @@ ${names.map((n) => ` ${JSON.stringify(n)}: () => import(${JSON.stringify(`${VIR
1294
1593
  basename(outDir)
1295
1594
  ]);
1296
1595
  for (const entry of readdirSync(root, { withFileTypes: true }))
1297
- if (entry.isDirectory() && !SKIP.has(entry.name)) s.watcher.add(join4(root, entry.name));
1596
+ if (entry.isDirectory() && !SKIP.has(entry.name)) s.watcher.add(join5(root, entry.name));
1298
1597
  const relevant = (file) => {
1299
1598
  const rel = relative4(root, file);
1300
1599
  if (rel.startsWith("..") || /node_modules/.test(rel)) return false;
@@ -1318,7 +1617,7 @@ ${names.map((n) => ` ${JSON.stringify(n)}: () => import(${JSON.stringify(`${VIR
1318
1617
  if (file.endsWith(".md")) return refresh(file);
1319
1618
  if (!file.endsWith(".seq.ts")) return;
1320
1619
  const header = JSON.stringify(
1321
- parseSequenceMeta(await readFile5(file, "utf8").catch(() => ""))
1620
+ parseSequenceMeta(await readFile6(file, "utf8").catch(() => ""))
1322
1621
  );
1323
1622
  if (seqHeaders.get(file) !== header) {
1324
1623
  seqHeaders.set(file, header);
@@ -1343,7 +1642,7 @@ ${names.map((n) => ` ${JSON.stringify(n)}: () => import(${JSON.stringify(`${VIR
1343
1642
  const abs = safeProjectPath(root, url.searchParams.get("path") ?? "");
1344
1643
  if (!abs || !existsSync3(abs)) return json2(res, 404, { error: "not found" });
1345
1644
  res.setHeader("content-type", "text/plain; charset=utf-8");
1346
- return void res.end(await readFile5(abs, "utf8"));
1645
+ return void res.end(await readFile6(abs, "utf8"));
1347
1646
  }
1348
1647
  if (url.pathname === "/__specbook/types")
1349
1648
  return json2(res, 200, await collectTypes(root, outDir));
@@ -1362,6 +1661,35 @@ ${names.map((n) => ` ${JSON.stringify(n)}: () => import(${JSON.stringify(`${VIR
1362
1661
  );
1363
1662
  return json2(res, 200, { env, vars: values.vars, secrets, warnings });
1364
1663
  }
1664
+ if (url.pathname === "/__specbook/environment" && req.method === "POST") {
1665
+ const origin = req.headers.origin;
1666
+ if (origin && new URL(origin).host !== req.headers.host)
1667
+ return json2(res, 403, { error: "cross-origin request refused" });
1668
+ const { name, from } = JSON.parse(await readBody2(req));
1669
+ if (!isEnvName(name) || name === "mock" || !isEnvName(from))
1670
+ return json2(res, 400, { error: "bad environment name" });
1671
+ const conf = await loadProjectConfig();
1672
+ const known = environmentNames(
1673
+ { services: conf?.services ?? {}, environments: conf?.environments },
1674
+ await listEnvironmentFiles(root)
1675
+ );
1676
+ if (known.includes(name)) return json2(res, 409, { error: `"${name}" already exists` });
1677
+ if (!known.includes(from))
1678
+ return json2(res, 400, { error: `unknown environment "${from}"` });
1679
+ const own = {
1680
+ ...conf?.environments?.[from]?.vars,
1681
+ ...(await readEnvironment(root, from)).vars
1682
+ };
1683
+ await ensureEnvironmentsDir(root);
1684
+ const file = varsFile(root, name);
1685
+ await writeFile2(file, `# variables for ${name}, copied from ${from}
1686
+ `);
1687
+ for (const [k, v] of Object.entries(own)) {
1688
+ if (v === null || typeof v === "object" || !isVarName(k)) continue;
1689
+ await writeEnvKey(file, k, String(v));
1690
+ }
1691
+ return json2(res, 200, { ok: true, name });
1692
+ }
1365
1693
  if ((url.pathname === "/__specbook/secret" || url.pathname === "/__specbook/var") && req.method === "POST") {
1366
1694
  const origin = req.headers.origin;
1367
1695
  if (origin && new URL(origin).host !== req.headers.host)
@@ -1428,6 +1756,7 @@ export {
1428
1756
  listEnvironmentFiles,
1429
1757
  readAllVars,
1430
1758
  readEnvironment,
1759
+ requestsDirFor,
1431
1760
  safeProjectPath,
1432
1761
  scanProject,
1433
1762
  secretsFileFor,