@specbook/vite 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,9 +1,166 @@
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 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(".");
37
+ }
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 };
48
+ }
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 };
57
+ }
58
+ }
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;
87
+ }
88
+
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
+ }
7
164
 
8
165
  // ../core/src/oauth.ts
9
166
  var OAuthError = class extends Error {
@@ -318,6 +475,65 @@ function parseDotenv(text) {
318
475
  return out;
319
476
  }
320
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
+
321
537
  // src/discovery.ts
322
538
  var FETCH_TIMEOUT_MS = 5e3;
323
539
  function createDiscovery(opts = {}) {
@@ -527,6 +743,7 @@ async function scanExamples(root, outDir, operations) {
527
743
  const gen = `${root}/${outDir}`;
528
744
  for (const abs of await fg2("*/examples/*/*.example.json", { cwd: gen, absolute: true })) {
529
745
  const [svc, , id, file] = relative2(gen, abs).split(sep2);
746
+ if (!operations.has(`${svc}/${id}`)) continue;
530
747
  add(`${svc}/${id}`, {
531
748
  name: file.replace(NAME, ""),
532
749
  abs,
@@ -622,6 +839,15 @@ async function handleProxy2(req, res, deps) {
622
839
  const url = req.url ?? "/";
623
840
  if (!url.startsWith(`${MOUNT}/`)) return false;
624
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
+ };
625
851
  const secretsByEnv = /* @__PURE__ */ new Map();
626
852
  const method = req.method ?? "GET";
627
853
  const headers = new Headers();
@@ -636,12 +862,13 @@ async function handleProxy2(req, res, deps) {
636
862
  const response = await handleProxy(request, {
637
863
  mount: MOUNT,
638
864
  policy: {
639
- upstream: (env, service) => {
865
+ upstream: async (env, service) => {
866
+ const a = await addressesFor2(env);
640
867
  if (service.startsWith(AUTH_SERVICE_PREFIX))
641
- return config.tokenOrigins?.[decode(service.slice(AUTH_SERVICE_PREFIX.length))];
868
+ return a.tokenOrigins[decode(service.slice(AUTH_SERVICE_PREFIX.length))];
642
869
  if (service.startsWith(DISCOVERY_SERVICE_PREFIX))
643
- return config.discoveryOrigins?.[decode(service.slice(DISCOVERY_SERVICE_PREFIX.length))];
644
- return config.services[service]?.baseUrl[env];
870
+ return a.discoveryOrigins[decode(service.slice(DISCOVERY_SERVICE_PREFIX.length))];
871
+ return a.services[service];
645
872
  },
646
873
  // a developer's own machine: their local services are the point
647
874
  allowPrivateHosts: true
@@ -671,9 +898,47 @@ async function handleProxy2(req, res, deps) {
671
898
  return true;
672
899
  }
673
900
 
674
- // src/sequences.ts
901
+ // src/requests.ts
675
902
  import { readFile as readFile4 } from "fs/promises";
676
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";
677
942
  var KEYS = ["id", "title", "description", "group", "tags"];
678
943
  function readString(src, i) {
679
944
  const q = src[i];
@@ -771,7 +1036,7 @@ async function scanSequences(root, files) {
771
1036
  const unresolved = [];
772
1037
  await Promise.all(
773
1038
  files.map(async (file) => {
774
- const parsed = parseSequenceMeta(await readFile4(join3(root, file), "utf8").catch(() => ""));
1039
+ const parsed = parseSequenceMeta(await readFile5(join4(root, file), "utf8").catch(() => ""));
775
1040
  if (parsed) meta[parsed.id] = { ...parsed, file };
776
1041
  else unresolved.push(file);
777
1042
  })
@@ -788,14 +1053,14 @@ var VIRTUAL_FAKER = "virtual:specbook/faker";
788
1053
  var RESOLVED_FAKER = `\0${VIRTUAL_FAKER}`;
789
1054
  var VIRTUAL_FAKER_LOCALE = "virtual:specbook/faker-locale/";
790
1055
  var RESOLVED_FAKER_LOCALE = `\0${VIRTUAL_FAKER_LOCALE}`;
791
- var VIRTUAL_MANIFESTS = "virtual:specbook/manifests";
792
- var RESOLVED_MANIFESTS = `\0${VIRTUAL_MANIFESTS}`;
1056
+ var VIRTUAL_MANIFEST = "virtual:specbook/manifest/";
1057
+ var RESOLVED_MANIFEST = `\0${VIRTUAL_MANIFEST}`;
793
1058
  var VIRTUAL_OPERATIONS = "virtual:specbook/operations";
794
1059
  var RESOLVED_OPERATIONS = `\0${VIRTUAL_OPERATIONS}`;
795
1060
  var VIRTUAL_DOC = "virtual:specbook/doc/";
796
1061
  var RESOLVED_DOC = `\0${VIRTUAL_DOC}`;
797
1062
  function findConfig(root) {
798
- return [join4(root, ".specbook/specbook.config.ts"), join4(root, "specbook.config.ts")].find(
1063
+ return [join5(root, ".specbook/specbook.config.ts"), join5(root, "specbook.config.ts")].find(
799
1064
  existsSync3
800
1065
  );
801
1066
  }
@@ -816,7 +1081,7 @@ async function readBody2(req) {
816
1081
  }
817
1082
  function fakerLocales(entry) {
818
1083
  const names = /* @__PURE__ */ new Set(["en", "base"]);
819
- const dir = join4(dirname(entry), "locale");
1084
+ const dir = join5(dirname(entry), "locale");
820
1085
  if (existsSync3(dir))
821
1086
  for (const f of readdirSync(dir)) {
822
1087
  const m = /^([A-Za-z0-9_]+)\.js$/.exec(f);
@@ -839,13 +1104,13 @@ function slim(manifests) {
839
1104
  }
840
1105
  async function collectTypes(root, outDir) {
841
1106
  const libs = {};
842
- const req = createRequire(join4(root, "package.json"));
1107
+ const req = createRequire(join5(root, "package.json"));
843
1108
  try {
844
1109
  const entry = req.resolve("@specbook/core");
845
1110
  const srcDir = dirname(entry);
846
1111
  const files = await fg4("**/*.ts", { cwd: srcDir, ignore: ["**/*.test.ts"] });
847
1112
  for (const f of files) {
848
- libs[`file:///node_modules/@specbook/core/src/${f}`] = await readFile5(join4(srcDir, f), "utf8");
1113
+ libs[`file:///node_modules/@specbook/core/src/${f}`] = await readFile6(join5(srcDir, f), "utf8");
849
1114
  }
850
1115
  libs["file:///node_modules/@specbook/core/package.json"] = JSON.stringify({
851
1116
  name: "@specbook/core",
@@ -853,24 +1118,27 @@ async function collectTypes(root, outDir) {
853
1118
  });
854
1119
  } catch {
855
1120
  }
856
- const genDir = join4(root, outDir);
1121
+ const genDir = join5(root, outDir);
857
1122
  for (const f of await fg4("**/*.ts", { cwd: genDir })) {
858
- libs[`file:///${outDir}/${f}`] = await readFile5(join4(genDir, f), "utf8");
1123
+ libs[`file:///${outDir}/${f}`] = await readFile6(join5(genDir, f), "utf8");
859
1124
  }
860
1125
  const project = await scanProject(root);
861
1126
  const sources = {};
862
1127
  for (const f of project.sources.filter((s) => s.endsWith(".ts"))) {
863
- sources[f] = await readFile5(join4(root, f), "utf8");
1128
+ sources[f] = await readFile6(join5(root, f), "utf8");
864
1129
  }
865
1130
  return { libs, sources };
866
1131
  }
867
1132
  function specbook(options) {
868
1133
  const root = resolve2(options.projectRoot);
869
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();
870
1138
  let server;
871
1139
  let isBuild = false;
872
1140
  let docHtml = [];
873
- let manifestsCode = "export default {}";
1141
+ let manifestCode = /* @__PURE__ */ new Map();
874
1142
  let operationsCode = "export const operations = {}";
875
1143
  let bundlePlan = /* @__PURE__ */ new Map();
876
1144
  let definitionFiles = /* @__PURE__ */ new Map();
@@ -883,13 +1151,25 @@ function specbook(options) {
883
1151
  async function registryCode() {
884
1152
  const files = await scanProject(root);
885
1153
  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"));
1154
+ for (const m of await fg4("*/manifest.json", { cwd: join5(root, outDir), absolute: true })) {
1155
+ const parsed = JSON.parse(await readFile6(m, "utf8"));
888
1156
  manifests[parsed.service] = parsed;
889
1157
  }
890
- const examples = await fg4("*/examples/*/*.response.json", {
891
- cwd: join4(root, outDir),
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),
892
1169
  absolute: true
1170
+ })).filter((abs) => {
1171
+ const rel = relative4(join5(root, outDir), abs).split(sep4);
1172
+ return inScope(rel[0], rel[2]);
893
1173
  });
894
1174
  const imp = (abs) => {
895
1175
  if (isBuild) return `() => import(${JSON.stringify(abs)})`;
@@ -898,50 +1178,45 @@ function specbook(options) {
898
1178
  return `(v) => import(/* @vite-ignore */ ${JSON.stringify(`/@fs${abs}`)} + '?t=' + v)`;
899
1179
  };
900
1180
  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))}` : ""} }`;
1181
+ const entry = (file) => `{ file: ${JSON.stringify(file)}, load: ${imp(join5(root, file))}${isBuild ? `, source: ${raw(join5(root, file))}` : ""} }`;
902
1182
  const cfg = findConfig(root);
903
1183
  const requestsDir = requestsDirFor(root);
904
- existsSync3(join4(root, "apis")) && !existsSync3(join4(root, "requests")) ? "apis" : "requests";
1184
+ existsSync3(join5(root, "apis")) && !existsSync3(join5(root, "requests")) ? "apis" : "requests";
905
1185
  let fakerEntry;
906
1186
  try {
907
- fakerEntry = createRequire(join4(root, "package.json")).resolve("@faker-js/faker");
1187
+ fakerEntry = createRequire(join5(root, "package.json")).resolve("@faker-js/faker");
908
1188
  } catch {
909
1189
  }
910
1190
  const operationEntries = Object.values(manifests).flatMap((m) => {
911
1191
  const mm = m;
912
1192
  return mm.operations.map(
913
- (o) => ` ${JSON.stringify(`${mm.service}/${o.id}`)}: ${imp(join4(root, outDir, mm.service, "operations", `${o.id}.ts`))}`
1193
+ (o) => ` ${JSON.stringify(`${mm.service}/${o.id}`)}: ${imp(join5(root, outDir, mm.service, "operations", `${o.id}.ts`))}`
914
1194
  );
915
1195
  });
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
- });
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$/)));
924
1202
  const responseEntries = responseFiles.map((abs) => {
925
- const rel = relative4(join4(root, outDir), abs).split(sep4);
1203
+ const rel = relative4(join5(root, outDir), abs).split(sep4);
926
1204
  const op = rel[2].replace(/\.responses\.schema\.json$/, "");
927
1205
  return ` ${JSON.stringify(`${rel[0]}/${op}`)}: ${imp(abs)}`;
928
1206
  });
929
- const defFiles = await fg4("*/schemas/definitions.schema.json", {
930
- cwd: join4(root, outDir),
931
- absolute: true
932
- });
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);
933
1208
  const schemaEntries = schemaFiles.map((abs) => {
934
- const rel = relative4(join4(root, outDir), abs).split(sep4);
1209
+ const rel = relative4(join5(root, outDir), abs).split(sep4);
935
1210
  const op = rel[2].replace(/\.request\.schema\.json$/, "");
936
1211
  return ` ${JSON.stringify(`${rel[0]}/${op}`)}: ${imp(abs)}`;
937
1212
  });
938
- const docFiles = await fg4("*/docs/*.json", { cwd: join4(root, outDir), absolute: true });
1213
+ const docFiles = (await fg4("*/docs/*.json", { cwd: join5(root, outDir), absolute: true })).filter((abs) => inScope(...opOf(abs, /\.json$/)));
939
1214
  const operationDocEntries = docFiles.map((abs) => {
940
- const rel = relative4(join4(root, outDir), abs).split(sep4);
1215
+ const rel = relative4(join5(root, outDir), abs).split(sep4);
941
1216
  return ` ${JSON.stringify(`${rel[0]}/${rel[2].replace(/\.json$/, "")}`)}: ${imp(abs)}`;
942
1217
  });
943
1218
  const defEntries = defFiles.map((abs) => {
944
- const service = relative4(join4(root, outDir), abs).split(sep4)[0];
1219
+ const service = relative4(join5(root, outDir), abs).split(sep4)[0];
945
1220
  return ` ${JSON.stringify(service)}: ${imp(abs)}`;
946
1221
  });
947
1222
  const known = new Set(
@@ -951,11 +1226,15 @@ function specbook(options) {
951
1226
  })
952
1227
  );
953
1228
  const sequenceScan = await scanSequences(root, files.sequences);
1229
+ const requestScan = await scanRequests(root, files.requests, new Set(Object.keys(manifests)));
954
1230
  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)));
1231
+ const src = await readFile6(join5(root, f), "utf8").catch(() => "");
1232
+ seqHeaders.set(join5(root, f), JSON.stringify(parseSequenceMeta(src)));
957
1233
  }
958
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];
959
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)`;
960
1239
  const requestExampleEntries = Object.entries(scanned.examples).map(
961
1240
  ([key, list]) => ` ${JSON.stringify(key)}: [${list.map(
@@ -977,23 +1256,28 @@ function specbook(options) {
977
1256
  const assetEntries = Object.entries(scanned.assets).map(
978
1257
  ([key, abs]) => ` ${JSON.stringify(key)}: ${url(abs)}`
979
1258
  );
980
- const pages = await scanDocs(root);
1259
+ const pages = (await scanDocs(root)).filter(
1260
+ (p) => !only || !p.operation || only.has(p.operation)
1261
+ );
981
1262
  docHtml = pages.map((p) => p.html);
982
1263
  const docEntries = pages.map(
983
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) }`
984
1265
  );
985
1266
  const exampleEntries = examples.map((abs) => {
986
- const rel = relative4(join4(root, outDir), abs).split(sep4);
1267
+ const rel = relative4(join5(root, outDir), abs).split(sep4);
987
1268
  const status = rel[3].replace(/\.response\.json$/, "");
988
1269
  const key = `${rel[0]}/${rel[2]}.${status}`;
989
1270
  return ` ${JSON.stringify(key)}: ${imp(abs)}`;
990
1271
  });
991
1272
  if (isBuild) {
992
- manifestsCode = `export default ${JSON.stringify(slim(manifests))}`;
1273
+ const slimmed = slim(manifests);
1274
+ manifestCode = new Map(
1275
+ Object.entries(slimmed).map(([svc, m]) => [svc, `export default ${JSON.stringify(m)}`])
1276
+ );
993
1277
  operationsCode = `export const operations = {
994
1278
  ${operationEntries.join(",\n")}
995
1279
  }`;
996
- const gen = join4(root, outDir);
1280
+ const gen = join5(root, outDir);
997
1281
  bundlePlan = /* @__PURE__ */ new Map();
998
1282
  const slot = (key) => {
999
1283
  const have = bundlePlan.get(key);
@@ -1033,7 +1317,7 @@ ${operationEntries.join(",\n")}
1033
1317
  const n = counts.get(tag) ?? 0;
1034
1318
  counts.set(tag, n + 1);
1035
1319
  operationChunks.set(
1036
- join4(gen, m.service, "operations", `${o.id}.ts`),
1320
+ join5(gen, m.service, "operations", `${o.id}.ts`),
1037
1321
  `ops-${m.service}-${tag}-${Math.floor(n / 40)}`
1038
1322
  );
1039
1323
  }
@@ -1047,8 +1331,8 @@ ${operationEntries.join(",\n")}
1047
1331
  projectLoads.push(` ${JSON.stringify(`${key}/${e.name}`)}: ${imp(e.abs)}`);
1048
1332
  }
1049
1333
  const services = Object.keys(manifests);
1050
- return `import manifests from ${JSON.stringify(VIRTUAL_MANIFESTS)}
1051
- export const mode = "static"
1334
+ return `export const mode = "static"
1335
+ export const minimal = ${JSON.stringify(!!only)}
1052
1336
  export const project = ${JSON.stringify({ root, outDir, requestsDir })}
1053
1337
  // where the data files are: dist/data, whether this chunk is in dist/ or dist/assets/
1054
1338
  const dataBase = import.meta.url.replace(/(assets\\/)?[^/]*$/, '') + 'data/'
@@ -1062,8 +1346,15 @@ const bundle = (key) => {
1062
1346
  }
1063
1347
  const part = (pick) =>
1064
1348
  new Proxy({}, { get: (_, key) => (typeof key === 'string' && key.includes('/') ? () => bundle(key).then((b) => ({ default: pick(b) })) : undefined) })
1065
- export { manifests }
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)
1066
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)}
1067
1358
  export const sequences = [${files.sequences.map(entry).join(",\n")}]
1068
1359
  export const sequenceMeta = ${JSON.stringify(sequenceScan.meta)}
1069
1360
  export const unresolvedSequences = ${JSON.stringify(sequenceScan.unresolved)}
@@ -1119,9 +1410,13 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
1119
1410
  `;
1120
1411
  }
1121
1412
  return `export const mode = ${JSON.stringify(isBuild ? "static" : "dev")}
1413
+ export const minimal = ${JSON.stringify(!!only)}
1122
1414
  export const project = ${JSON.stringify({ root, outDir, requestsDir })}
1123
1415
  export const manifests = ${JSON.stringify(slim(manifests))}
1416
+ export const loadManifests = () => Promise.resolve()
1124
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)}
1125
1420
  export const sequences = [${files.sequences.map(entry).join(",\n")}]
1126
1421
  export const sequenceMeta = ${JSON.stringify(sequenceScan.meta)}
1127
1422
  export const unresolvedSequences = ${JSON.stringify(sequenceScan.unresolved)}
@@ -1158,33 +1453,24 @@ export const loadFaker = ${fakerEntry ? `() => import('virtual:specbook/faker')`
1158
1453
  export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: { project: "specbook", services: {} } })'}
1159
1454
  `;
1160
1455
  }
1161
- async function getProxyConfig() {
1456
+ async function loadProjectConfig() {
1162
1457
  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
- };
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 };
1188
1474
  }
1189
1475
  return {
1190
1476
  name: "specbook",
@@ -1192,7 +1478,7 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
1192
1478
  isBuild = c.command === "build";
1193
1479
  },
1194
1480
  async generateBundle() {
1195
- const read = async (abs) => JSON.parse(await readFile5(abs, "utf8"));
1481
+ const read = async (abs) => JSON.parse(await readFile6(abs, "utf8"));
1196
1482
  for (const [key, p] of bundlePlan) {
1197
1483
  const out = {};
1198
1484
  if (p.schema) out.schema = await read(p.schema);
@@ -1213,6 +1499,13 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
1213
1499
  await Promise.all(statuses.map(async (n) => [n, await read(p.mock[n])]))
1214
1500
  );
1215
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
+ }
1216
1509
  this.emitFile({
1217
1510
  type: "asset",
1218
1511
  fileName: `data/${key}.json`,
@@ -1223,7 +1516,9 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
1223
1516
  this.emitFile({
1224
1517
  type: "asset",
1225
1518
  fileName: `data/${svc}/_definitions.json`,
1226
- source: await readFile5(abs)
1519
+ source: only ? JSON.stringify(
1520
+ pruneDefinitions(JSON.parse(await readFile6(abs, "utf8")), neededDefs.get(svc))
1521
+ ) : await readFile6(abs)
1227
1522
  });
1228
1523
  },
1229
1524
  config(_, env) {
@@ -1235,7 +1530,8 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
1235
1530
  // manifests are their own chunk (preloaded with the app, so no extra round trip), and
1236
1531
  // operation modules are grouped by tag instead of one chunk per operation
1237
1532
  manualChunks(id) {
1238
- if (id === RESOLVED_MANIFESTS) return "manifests";
1533
+ if (id.startsWith(RESOLVED_MANIFEST))
1534
+ return `manifest-${id.slice(RESOLVED_MANIFEST.length)}`;
1239
1535
  return operationChunks.get(id);
1240
1536
  }
1241
1537
  }
@@ -1247,7 +1543,7 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
1247
1543
  if (id === VIRTUAL) return RESOLVED;
1248
1544
  if (id === VIRTUAL_TYPES) return RESOLVED_TYPES;
1249
1545
  if (id === VIRTUAL_FAKER) return RESOLVED_FAKER;
1250
- if (id === VIRTUAL_MANIFESTS) return RESOLVED_MANIFESTS;
1546
+ if (id.startsWith(VIRTUAL_MANIFEST)) return `\0${id}`;
1251
1547
  if (id === VIRTUAL_OPERATIONS) return RESOLVED_OPERATIONS;
1252
1548
  if (id.startsWith(VIRTUAL_FAKER_LOCALE)) return `\0${id}`;
1253
1549
  if (id.startsWith(VIRTUAL_DOC)) return `\0${id}`;
@@ -1255,10 +1551,11 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
1255
1551
  },
1256
1552
  async load(id) {
1257
1553
  if (id === RESOLVED) return registryCode();
1258
- if (id === RESOLVED_MANIFESTS) return manifestsCode;
1554
+ if (id.startsWith(RESOLVED_MANIFEST))
1555
+ return manifestCode.get(id.slice(RESOLVED_MANIFEST.length)) ?? "export default {}";
1259
1556
  if (id === RESOLVED_OPERATIONS) return operationsCode;
1260
1557
  if (id === RESOLVED_FAKER || id.startsWith(RESOLVED_FAKER_LOCALE)) {
1261
- const r = await this.resolve("@faker-js/faker", join4(root, "package.json"), {
1558
+ const r = await this.resolve("@faker-js/faker", join5(root, "package.json"), {
1262
1559
  skipSelf: true
1263
1560
  });
1264
1561
  if (!r) return "export {}";
@@ -1267,7 +1564,7 @@ export const loadConfig = ${cfg ? imp(cfg) : '() => Promise.resolve({ default: {
1267
1564
  return `export { ${id.slice(RESOLVED_FAKER_LOCALE.length)} as default } from ${pkg}`;
1268
1565
  let entry = r.id;
1269
1566
  try {
1270
- entry = createRequire(join4(root, "package.json")).resolve("@faker-js/faker");
1567
+ entry = createRequire(join5(root, "package.json")).resolve("@faker-js/faker");
1271
1568
  } catch {
1272
1569
  }
1273
1570
  const names = fakerLocales(entry);
@@ -1294,7 +1591,7 @@ ${names.map((n) => ` ${JSON.stringify(n)}: () => import(${JSON.stringify(`${VIR
1294
1591
  basename(outDir)
1295
1592
  ]);
1296
1593
  for (const entry of readdirSync(root, { withFileTypes: true }))
1297
- if (entry.isDirectory() && !SKIP.has(entry.name)) s.watcher.add(join4(root, entry.name));
1594
+ if (entry.isDirectory() && !SKIP.has(entry.name)) s.watcher.add(join5(root, entry.name));
1298
1595
  const relevant = (file) => {
1299
1596
  const rel = relative4(root, file);
1300
1597
  if (rel.startsWith("..") || /node_modules/.test(rel)) return false;
@@ -1318,7 +1615,7 @@ ${names.map((n) => ` ${JSON.stringify(n)}: () => import(${JSON.stringify(`${VIR
1318
1615
  if (file.endsWith(".md")) return refresh(file);
1319
1616
  if (!file.endsWith(".seq.ts")) return;
1320
1617
  const header = JSON.stringify(
1321
- parseSequenceMeta(await readFile5(file, "utf8").catch(() => ""))
1618
+ parseSequenceMeta(await readFile6(file, "utf8").catch(() => ""))
1322
1619
  );
1323
1620
  if (seqHeaders.get(file) !== header) {
1324
1621
  seqHeaders.set(file, header);
@@ -1343,7 +1640,7 @@ ${names.map((n) => ` ${JSON.stringify(n)}: () => import(${JSON.stringify(`${VIR
1343
1640
  const abs = safeProjectPath(root, url.searchParams.get("path") ?? "");
1344
1641
  if (!abs || !existsSync3(abs)) return json2(res, 404, { error: "not found" });
1345
1642
  res.setHeader("content-type", "text/plain; charset=utf-8");
1346
- return void res.end(await readFile5(abs, "utf8"));
1643
+ return void res.end(await readFile6(abs, "utf8"));
1347
1644
  }
1348
1645
  if (url.pathname === "/__specbook/types")
1349
1646
  return json2(res, 200, await collectTypes(root, outDir));
@@ -1362,6 +1659,35 @@ ${names.map((n) => ` ${JSON.stringify(n)}: () => import(${JSON.stringify(`${VIR
1362
1659
  );
1363
1660
  return json2(res, 200, { env, vars: values.vars, secrets, warnings });
1364
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
+ }
1365
1691
  if ((url.pathname === "/__specbook/secret" || url.pathname === "/__specbook/var") && req.method === "POST") {
1366
1692
  const origin = req.headers.origin;
1367
1693
  if (origin && new URL(origin).host !== req.headers.host)
@@ -1428,6 +1754,7 @@ export {
1428
1754
  listEnvironmentFiles,
1429
1755
  readAllVars,
1430
1756
  readEnvironment,
1757
+ requestsDirFor,
1431
1758
  safeProjectPath,
1432
1759
  scanProject,
1433
1760
  secretsFileFor,