@weasel-js/forge 1.11.0 → 1.12.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.
Files changed (53) hide show
  1. package/README.md +92 -1
  2. package/dist/{FrameController-BRhShqut.d.ts → FrameController-CdnaIo4C.d.ts} +18 -10
  3. package/dist/{chunk-7BQPHMA2.js → chunk-3BAM6M26.js} +2 -2
  4. package/dist/chunk-3BAM6M26.js.map +1 -0
  5. package/dist/{chunk-ZAWXMUGZ.js → chunk-5WS67HMU.js} +8 -3
  6. package/dist/chunk-5WS67HMU.js.map +1 -0
  7. package/dist/{chunk-R5F44ECM.js → chunk-ATI7FYJ7.js} +18 -14
  8. package/dist/chunk-ATI7FYJ7.js.map +1 -0
  9. package/dist/{chunk-2OJAV522.js → chunk-GH5RE5X5.js} +2 -2
  10. package/dist/chunk-GH5RE5X5.js.map +1 -0
  11. package/dist/{chunk-SQ4KCIEH.js → chunk-KEQBGDUZ.js} +3 -3
  12. package/dist/{chunk-SQ4KCIEH.js.map → chunk-KEQBGDUZ.js.map} +1 -1
  13. package/dist/{chunk-DPTECNYC.js → chunk-LTTEILXY.js} +3 -3
  14. package/dist/{chunk-DPTECNYC.js.map → chunk-LTTEILXY.js.map} +1 -1
  15. package/dist/chunk-ORMMZ5PV.js +59 -0
  16. package/dist/chunk-ORMMZ5PV.js.map +1 -0
  17. package/dist/{chunk-NVNTQ4AT.js → chunk-WMGEO5M6.js} +133 -9
  18. package/dist/chunk-WMGEO5M6.js.map +1 -0
  19. package/dist/chunk-YDLRSEUN.js +501 -0
  20. package/dist/chunk-YDLRSEUN.js.map +1 -0
  21. package/dist/{config-BwScQukB.d.ts → config-Mg05Ja9r.d.ts} +20 -3
  22. package/dist/csf/preview-api.js +2 -2
  23. package/dist/frame/index.css +12 -0
  24. package/dist/frame/index.css.map +1 -1
  25. package/dist/frame/index.d.ts +62 -29
  26. package/dist/frame/index.js +142 -15
  27. package/dist/frame/index.js.map +1 -1
  28. package/dist/index.d.ts +4 -4
  29. package/dist/index.js +3 -3
  30. package/dist/index.js.map +1 -1
  31. package/dist/mountFrame-Ds3QLnBF.d.ts +35 -0
  32. package/dist/shell/index.css +131 -6
  33. package/dist/shell/index.css.map +1 -1
  34. package/dist/shell/index.d.ts +44 -16
  35. package/dist/shell/index.js +1663 -280
  36. package/dist/shell/index.js.map +1 -1
  37. package/dist/test/index.css +58 -0
  38. package/dist/test/index.css.map +1 -0
  39. package/dist/test/index.d.ts +2 -4
  40. package/dist/test/index.js +11 -13
  41. package/dist/test/index.js.map +1 -1
  42. package/dist/vite/index.js +515 -111
  43. package/dist/vite/index.js.map +1 -1
  44. package/package.json +13 -8
  45. package/dist/chunk-2OJAV522.js.map +0 -1
  46. package/dist/chunk-5DXUNLV2.js +0 -6
  47. package/dist/chunk-5DXUNLV2.js.map +0 -1
  48. package/dist/chunk-7BQPHMA2.js.map +0 -1
  49. package/dist/chunk-J3GYSNCK.js +0 -285
  50. package/dist/chunk-J3GYSNCK.js.map +0 -1
  51. package/dist/chunk-NVNTQ4AT.js.map +0 -1
  52. package/dist/chunk-R5F44ECM.js.map +0 -1
  53. package/dist/chunk-ZAWXMUGZ.js.map +0 -1
@@ -1,11 +1,13 @@
1
- import { DEFAULT_TITLE } from '../chunk-5DXUNLV2.js';
1
+ import { CAPTURE_PAGES, DEFAULT_TITLE, CAPTURE_PATH, parsePayload, messageOf } from '../chunk-ORMMZ5PV.js';
2
2
  import { isGallery } from '../chunk-WEQSLPKT.js';
3
- import { PROP_ARG_TYPES_EXPORT, storyNameFromExport, storyId } from '../chunk-ZAWXMUGZ.js';
3
+ import { PROP_ARG_TYPES_EXPORT, storyNameFromExport, storyId } from '../chunk-5WS67HMU.js';
4
4
  import { readFileSync, mkdtempSync, existsSync, mkdirSync, writeFileSync, globSync, statSync, watch, rmSync } from 'fs';
5
5
  import { resolve, sep, relative, join, dirname, basename, matchesGlob, posix } from 'path';
6
6
  import { parse } from '@babel/parser';
7
7
  import { execFile } from 'child_process';
8
8
  import { fileURLToPath } from 'url';
9
+ import { createHash } from 'crypto';
10
+ import { stableStringify } from '@weasel-js/core/json';
9
11
  import { tmpdir } from 'os';
10
12
 
11
13
  // src/vite/html.ts
@@ -81,7 +83,7 @@ function createDepGraph({ root, read }) {
81
83
  reset() {
82
84
  resolutions.clear();
83
85
  },
84
- async build(entries, resolve5) {
86
+ async build(entries, resolve6) {
85
87
  const resolveFrom = (spec, importer) => {
86
88
  let byImporter = resolutions.get(importer);
87
89
  if (!byImporter) {
@@ -90,7 +92,7 @@ function createDepGraph({ root, read }) {
90
92
  }
91
93
  let pending = byImporter.get(spec);
92
94
  if (!pending) {
93
- pending = resolve5(spec, importer).then(
95
+ pending = resolve6(spec, importer).then(
94
96
  (id) => {
95
97
  const file = id?.split("?")[0];
96
98
  return file && !file.startsWith("\0") && SCRIPT.test(file) && !file.split(/[\\/]/).includes("node_modules") ? file : null;
@@ -344,7 +346,13 @@ function fileDates(log, paths) {
344
346
  const name = files.at(-1);
345
347
  const path = name === void 0 ? void 0 : names.get(name);
346
348
  if (!status || name === void 0 || path === void 0) continue;
347
- out.set(path, { created: date, modified: out.get(path)?.modified ?? date, commit });
349
+ const newer = out.get(path);
350
+ out.set(path, {
351
+ created: date,
352
+ modified: newer?.modified ?? date,
353
+ createdBy: commit,
354
+ modifiedBy: newer?.modifiedBy ?? commit
355
+ });
348
356
  const renamed = status.startsWith("R") ? files[0] : void 0;
349
357
  if (status === "A" || renamed !== void 0) names.delete(name);
350
358
  if (renamed !== void 0) names.set(renamed, path);
@@ -384,11 +392,16 @@ function createHistoryReader(root, git = runGit) {
384
392
  const history = {};
385
393
  for (const [name, paths] of byPackage) {
386
394
  const refs = ["v[0-9]*", "[0-9]*", ...name === null ? [] : [`${name}@[0-9]*`]].map((ref) => `--refs=${ref}`);
387
- const commits = paths.map((path) => dates.get(path).commit);
395
+ const commits = paths.flatMap((path) => [dates.get(path).createdBy, dates.get(path).modifiedBy]);
388
396
  const named = (await git(["name-rev", "--tags", "--name-only", ...refs, ...commits], top)).split("\n");
389
397
  paths.forEach((path, i) => {
390
398
  const { created, modified } = dates.get(path);
391
- history[inRepo.get(path)] = { created, modified, introduced: versionOf(named[i] ?? "") };
399
+ history[inRepo.get(path)] = {
400
+ created,
401
+ modified,
402
+ introduced: versionOf(named[2 * i] ?? ""),
403
+ modifiedIn: versionOf(named[2 * i + 1] ?? "")
404
+ };
392
405
  });
393
406
  }
394
407
  return history;
@@ -842,13 +855,13 @@ function createTracer(read) {
842
855
  }
843
856
  return modules.get(file) ?? null;
844
857
  };
845
- return (resolve5) => {
858
+ return (resolve6) => {
846
859
  const trace = {
847
860
  reads: /* @__PURE__ */ new Set(),
848
861
  unresolved: false,
849
862
  info,
850
863
  async target(spec, importer) {
851
- const id = (await resolve5(spec, importer).catch(() => null))?.split("?")[0];
864
+ const id = (await resolve6(spec, importer).catch(() => null))?.split("?")[0];
852
865
  if (!id) trace.unresolved = true;
853
866
  return id && !id.startsWith("\0") ? id : null;
854
867
  },
@@ -890,8 +903,8 @@ function sameResolution(a, b) {
890
903
  }
891
904
  function createWrapperResolver(read) {
892
905
  const tracer = createTracer(read);
893
- return async (file, callees, resolve5) => {
894
- const trace = tracer(resolve5);
906
+ return async (file, callees, resolve6) => {
907
+ const trace = tracer(resolve6);
895
908
  const wrappers = /* @__PURE__ */ new Set();
896
909
  const direct = [...trace.info(file)?.imports.values() ?? []].some((imp) => imp.spec === "@weasel-js/forge");
897
910
  if (callees.length === 0 && !direct) return { wrappers, foreign: NO_FOREIGN, reads: trace.reads, unresolved: false };
@@ -918,8 +931,8 @@ function indexStory(code, file, source, resolved) {
918
931
  const foreign = resolved ? foreignFrom(resolved.foreign) : void 0;
919
932
  return indexFile(code, file, autoTitle(file, source.root, source.stories), resolved?.wrappers, foreign);
920
933
  }
921
- async function indexStoryResolved(code, file, source, resolve5, decide = createWrapperResolver((path) => readFileSync(path, "utf8"))) {
922
- const resolution = await decide(file, foreignCallees(code, file), resolve5);
934
+ async function indexStoryResolved(code, file, source, resolve6, decide = createWrapperResolver((path) => readFileSync(path, "utf8"))) {
935
+ const resolution = await decide(file, foreignCallees(code, file), resolve6);
923
936
  return { entries: indexStory(code, file, source, resolution), resolution };
924
937
  }
925
938
  function ownEntries(realms) {
@@ -934,56 +947,6 @@ function ownEntries(realms) {
934
947
  }
935
948
  return [];
936
949
  }
937
-
938
- // src/vite/entries.ts
939
- function shellEntry(base, stories) {
940
- return `import { mountWorkshop } from '@weasel-js/forge/shell';
941
- import '@weasel-js/labkit/styles.css';
942
- import '@weasel-js/forge/shell.css';
943
- import index from 'virtual:forge/index.js';
944
- import importers from 'virtual:forge/importers.js';
945
- import config from 'virtual:forge/shell-config.js';
946
- import setup from 'virtual:forge/frame-config.js';
947
-
948
- const workshop = mountWorkshop({
949
- index,
950
- importers,
951
- setup,
952
- config,
953
- frameUrl: ${JSON.stringify(`${base}frame.html`)},
954
- stories: ${JSON.stringify(stories)},
955
- });
956
- import.meta.hot?.on('forge:index', (next) => workshop.setIndex(next));
957
- // Read from source on first request, so the graph costs nothing until the page is up.
958
- import('virtual:forge/deps.js').then((m) => workshop.setDependencies(m.default));
959
- import.meta.hot?.on('forge:deps', (next) => workshop.setDependencies(next));
960
- // An edit to a story file, or to a module one imports, reaches here as an update of the importers module, whose
961
- // fresh import() URLs carry the new timestamps; the files it reaches arrive just before, so the reload waits for
962
- // the importers that can fetch them.
963
- const stale = new Set();
964
- import.meta.hot?.on('forge:story', ({ file }) => stale.add(file));
965
- import.meta.hot?.accept('virtual:forge/importers.js', (next) => {
966
- if (!next) return;
967
- workshop.setImporters(next.default);
968
- for (const file of stale) workshop.reloadStory(file);
969
- stale.clear();
970
- });
971
- `;
972
- }
973
- function frameEntry(styles) {
974
- return `import { mountFrame } from '@weasel-js/forge/frame';
975
- import '@weasel-js/forge/frame.css';
976
- ${styles.map((path) => `import ${JSON.stringify(path)};
977
- `).join("")}import index from 'virtual:forge/index.js';
978
- import importers from 'virtual:forge/importers.js';
979
- import setup from 'virtual:forge/frame-config.js';
980
-
981
- mountFrame({ index, importers, setup });
982
- // The importers module is shared with the workshop page, which accepts a story edit in place; a frame document
983
- // showing the old module reloads instead, and without this the edit would dead-end here and reload every page.
984
- import.meta.hot?.accept('virtual:forge/importers.js', () => location.reload());
985
- `;
986
- }
987
950
  var PLATFORM = /[\\/]node_modules[\\/](@types[\\/]react(-dom)?|typescript)[\\/]/;
988
951
  async function createPropReader(root, stories, { skip } = {}) {
989
952
  const skipped = (file) => PLATFORM.test(file) || skip?.(file) === true;
@@ -1148,10 +1111,339 @@ export const ${PROP_ARG_TYPES_EXPORT} = ${JSON.stringify(table.argTypes)};
1148
1111
  }
1149
1112
  };
1150
1113
  }
1114
+ var IDLE_CLOSE_MS = 12e4;
1115
+ var SHOT_DEADLINE_MS = 2e4;
1116
+ var QUEUE_CAP = 8;
1117
+ var QUEUE_FULL = "capture queue is full";
1118
+ var INSTALL_PLAYWRIGHT = "install playwright";
1119
+ var INSTALL_CHROMIUM = "install Chromium for Playwright: npx playwright install chromium";
1120
+ var loadPlaywright = () => import('playwright');
1121
+ async function captureAvailability(load = loadPlaywright) {
1122
+ let playwright;
1123
+ try {
1124
+ playwright = await load();
1125
+ } catch {
1126
+ return { unavailable: INSTALL_PLAYWRIGHT };
1127
+ }
1128
+ let executable;
1129
+ try {
1130
+ executable = playwright.chromium.executablePath();
1131
+ } catch (err) {
1132
+ return { unavailable: messageOf(err) };
1133
+ }
1134
+ return existsSync(executable) ? { available: true } : { unavailable: INSTALL_CHROMIUM };
1135
+ }
1136
+ var WIRE_HEADERS = /* @__PURE__ */ new Set(["content-encoding", "content-length", "transfer-encoding"]);
1137
+ async function passThrough(route, fetchImpl) {
1138
+ const request = route.request();
1139
+ const method = request.method();
1140
+ if (method !== "GET" && method !== "HEAD") return route.abort();
1141
+ try {
1142
+ const response = await fetchImpl(request.url(), { method, headers: request.headers(), redirect: "manual" });
1143
+ await route.fulfill({
1144
+ status: response.status,
1145
+ headers: Object.fromEntries([...response.headers].filter(([name]) => !WIRE_HEADERS.has(name))),
1146
+ body: Buffer.from(await response.arrayBuffer())
1147
+ });
1148
+ } catch {
1149
+ await route.abort();
1150
+ }
1151
+ }
1152
+ async function guard(page, url, allow, fetchImpl) {
1153
+ const own = new URL(url);
1154
+ const secure = own.protocol === "https:";
1155
+ const reach = new Set(allow);
1156
+ await page.route("**/*", (route) => {
1157
+ const origin = new URL(route.request().url()).origin;
1158
+ if (origin === own.origin) return route.continue();
1159
+ if (!reach.has(origin)) return route.abort();
1160
+ return secure ? passThrough(route, fetchImpl) : route.continue();
1161
+ });
1162
+ const ownSocket = `${secure ? "wss:" : "ws:"}//${own.host}`;
1163
+ await page.routeWebSocket(
1164
+ (u) => `${u.protocol}//${u.host}` !== ownSocket,
1165
+ (ws) => ws.close()
1166
+ );
1167
+ }
1168
+ async function launchCaptureBrowser(load = loadPlaywright, fetchImpl = fetch) {
1169
+ let playwright;
1170
+ try {
1171
+ playwright = await load();
1172
+ } catch {
1173
+ return { unavailable: INSTALL_PLAYWRIGHT };
1174
+ }
1175
+ let chromium;
1176
+ try {
1177
+ chromium = Promise.resolve(await playwright.chromium.launch({ headless: true }));
1178
+ } catch (err) {
1179
+ return { unavailable: messageOf(err) };
1180
+ }
1181
+ const contexts = /* @__PURE__ */ new Map();
1182
+ let idle;
1183
+ let outstanding = 0;
1184
+ let tail = Promise.resolve();
1185
+ const shut = async () => {
1186
+ clearTimeout(idle);
1187
+ const closing = chromium;
1188
+ chromium = null;
1189
+ contexts.clear();
1190
+ await closing?.then((b) => b.close(), () => {
1191
+ });
1192
+ };
1193
+ const arm = () => {
1194
+ clearTimeout(idle);
1195
+ idle = setTimeout(() => void shut(), IDLE_CLOSE_MS);
1196
+ idle.unref?.();
1197
+ };
1198
+ const browser = () => {
1199
+ if (!chromium) {
1200
+ const launching = playwright.chromium.launch({ headless: true });
1201
+ chromium = launching;
1202
+ launching.catch(() => {
1203
+ if (chromium === launching) chromium = null;
1204
+ });
1205
+ }
1206
+ return chromium;
1207
+ };
1208
+ const contextFor = (dpr, secure) => {
1209
+ const key = `${dpr}:${secure}`;
1210
+ let context = contexts.get(key);
1211
+ if (!context) {
1212
+ const making = browser().then(
1213
+ (b) => b.newContext({ deviceScaleFactor: dpr, ignoreHTTPSErrors: secure, serviceWorkers: "block" })
1214
+ );
1215
+ context = making;
1216
+ contexts.set(key, making);
1217
+ making.catch(() => {
1218
+ if (contexts.get(key) === making) contexts.delete(key);
1219
+ });
1220
+ }
1221
+ return context;
1222
+ };
1223
+ const render = async (page, payload, url, allow) => {
1224
+ await guard(page, url, allow, fetchImpl);
1225
+ await page.setViewportSize({ width: Math.ceil(payload.box.width), height: Math.ceil(payload.box.height) });
1226
+ await page.emulateMedia({ colorScheme: payload.colorScheme });
1227
+ await page.goto(url);
1228
+ await page.waitForFunction(() => window.__forgeCapture !== void 0);
1229
+ await page.evaluate((p) => window.__forgeCapture.render(p), payload);
1230
+ return page.locator("[data-fg-capture]").screenshot({ type: "png" });
1231
+ };
1232
+ const shoot = async (url, payload, allow) => {
1233
+ let page = null;
1234
+ let late = false;
1235
+ let timer;
1236
+ const deadline = new Promise((_, reject) => {
1237
+ timer = setTimeout(() => reject(new Error(`capture took longer than ${SHOT_DEADLINE_MS / 1e3} s`)), SHOT_DEADLINE_MS);
1238
+ });
1239
+ const work = (async () => {
1240
+ const opened = await (await contextFor(payload.dpr, url.startsWith("https:"))).newPage();
1241
+ page = opened;
1242
+ if (late) throw new Error("capture timed out");
1243
+ return render(opened, payload, url, allow);
1244
+ })();
1245
+ try {
1246
+ return await Promise.race([work, deadline]);
1247
+ } finally {
1248
+ late = true;
1249
+ clearTimeout(timer);
1250
+ await page?.close().catch(() => {
1251
+ });
1252
+ void work.catch(() => page?.close().catch(() => {
1253
+ }));
1254
+ }
1255
+ };
1256
+ arm();
1257
+ return {
1258
+ capture(url, payload, allow = []) {
1259
+ if (outstanding >= QUEUE_CAP) return Promise.reject(new Error(QUEUE_FULL));
1260
+ outstanding += 1;
1261
+ clearTimeout(idle);
1262
+ const run = tail.then(() => shoot(url, payload, allow));
1263
+ tail = run.catch(() => {
1264
+ });
1265
+ return run.finally(() => {
1266
+ outstanding -= 1;
1267
+ if (outstanding === 0) arm();
1268
+ });
1269
+ },
1270
+ close: shut
1271
+ };
1272
+ }
1273
+
1274
+ // src/vite/capture/cache.ts
1275
+ function createCaptureCache(limits = { entries: 64, bytes: 64 * 1024 * 1024 }) {
1276
+ const held = /* @__PURE__ */ new Map();
1277
+ let bytes = 0;
1278
+ const drop = (key) => {
1279
+ const png = held.get(key);
1280
+ if (!png) return;
1281
+ held.delete(key);
1282
+ bytes -= png.length;
1283
+ };
1284
+ return {
1285
+ get(key) {
1286
+ const png = held.get(key);
1287
+ if (!png) return void 0;
1288
+ held.delete(key);
1289
+ held.set(key, png);
1290
+ return png;
1291
+ },
1292
+ set(key, png) {
1293
+ drop(key);
1294
+ if (png.length > limits.bytes) return;
1295
+ held.set(key, png);
1296
+ bytes += png.length;
1297
+ for (const oldest of held.keys()) {
1298
+ if (held.size <= limits.entries && bytes <= limits.bytes) break;
1299
+ drop(oldest);
1300
+ }
1301
+ },
1302
+ clear() {
1303
+ held.clear();
1304
+ bytes = 0;
1305
+ },
1306
+ get size() {
1307
+ return held.size;
1308
+ },
1309
+ get bytes() {
1310
+ return bytes;
1311
+ }
1312
+ };
1313
+ }
1314
+
1315
+ // src/vite/capture/endpoint.ts
1316
+ var MAX_BODY = 16 * 1024 * 1024;
1317
+ var keyOf2 = (payload) => createHash("sha1").update(stableStringify(payload)).digest("hex");
1318
+ var urlHost = (address) => {
1319
+ const v4 = address.replace(/^::ffff:(?=\d+\.\d+\.\d+\.\d+$)/, "");
1320
+ return v4.includes(":") ? `[${v4.replace("%", "%25")}]` : v4;
1321
+ };
1322
+ var socketOrigin = (req, protocol) => `${protocol}://${urlHost(req.socket.localAddress ?? "127.0.0.1")}:${req.socket.localPort}`;
1323
+ var LOOPBACK = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
1324
+ function ownOrigin(origin, req, protocol, server) {
1325
+ let url;
1326
+ try {
1327
+ url = new URL(origin);
1328
+ } catch {
1329
+ return false;
1330
+ }
1331
+ if (url.origin === socketOrigin(req, protocol)) return true;
1332
+ if (url.protocol === `${protocol}:` && LOOPBACK.has(url.hostname) && url.port === String(req.socket.localPort)) return true;
1333
+ const printed = [...server.resolvedUrls?.local ?? [], ...server.resolvedUrls?.network ?? []];
1334
+ return printed.some((u) => new URL(u).origin === url.origin);
1335
+ }
1336
+ var json = (res, status, body2) => {
1337
+ res.statusCode = status;
1338
+ res.setHeader("Content-Type", "application/json");
1339
+ res.end(JSON.stringify(body2));
1340
+ };
1341
+ function readBody(req) {
1342
+ return new Promise((resolve6, reject) => {
1343
+ const chunks = [];
1344
+ let size = 0;
1345
+ req.on("data", (chunk) => {
1346
+ size += chunk.length;
1347
+ if (size <= MAX_BODY) return void chunks.push(chunk);
1348
+ req.pause();
1349
+ req.removeAllListeners("data");
1350
+ resolve6(null);
1351
+ });
1352
+ req.on("end", () => resolve6(Buffer.concat(chunks).toString("utf8")));
1353
+ req.on("error", reject);
1354
+ });
1355
+ }
1356
+ function serveCapture(server, base, allowOrigins = () => []) {
1357
+ const cache = createCaptureCache();
1358
+ let launched = null;
1359
+ let revision = 0;
1360
+ const browser = () => {
1361
+ if (!launched) {
1362
+ const launching = launchCaptureBrowser();
1363
+ launched = launching;
1364
+ void launching.then((result) => {
1365
+ if ("unavailable" in result && launched === launching) launched = null;
1366
+ });
1367
+ }
1368
+ return launched;
1369
+ };
1370
+ const protocol = server.config.server.https ? "https" : "http";
1371
+ const capture = async (req, res) => {
1372
+ const origin = req.headers.origin;
1373
+ if (origin !== void 0 && !ownOrigin(origin, req, protocol, server)) {
1374
+ return json(res, 403, { error: "a capture is only taken for this server\u2019s own pages" });
1375
+ }
1376
+ const type = (req.headers["content-type"] ?? "").split(";")[0]?.trim().toLowerCase();
1377
+ if (type !== "application/json") return json(res, 415, { error: "a capture payload is sent as application/json" });
1378
+ const body2 = await readBody(req);
1379
+ if (body2 === null) {
1380
+ res.setHeader("Connection", "close");
1381
+ json(res, 413, { error: "capture payload is too large" });
1382
+ return void res.once("finish", () => req.destroy());
1383
+ }
1384
+ let parsed;
1385
+ try {
1386
+ parsed = JSON.parse(body2);
1387
+ } catch {
1388
+ return json(res, 400, { error: "capture payload is not JSON" });
1389
+ }
1390
+ const payload = parsePayload(parsed);
1391
+ if ("error" in payload) return json(res, 400, payload);
1392
+ const key = keyOf2(payload);
1393
+ let png = cache.get(key);
1394
+ if (!png) {
1395
+ const result = await browser();
1396
+ if ("unavailable" in result) return json(res, 503, { available: false, reason: result.unavailable });
1397
+ const page = `${socketOrigin(req, protocol)}${base}${payload.isolate ? CAPTURE_PAGES.frame : CAPTURE_PAGES.shell}`;
1398
+ const at = revision;
1399
+ try {
1400
+ png = await result.capture(page, payload, allowOrigins());
1401
+ } catch (err) {
1402
+ const message = messageOf(err);
1403
+ if (message === QUEUE_FULL) return json(res, 503, { available: false, reason: QUEUE_FULL });
1404
+ return json(res, 500, { error: message });
1405
+ }
1406
+ if (at === revision) cache.set(key, png);
1407
+ }
1408
+ res.statusCode = 200;
1409
+ res.setHeader("Content-Type", "image/png");
1410
+ res.end(png);
1411
+ };
1412
+ const status = async (res) => {
1413
+ const result = await captureAvailability();
1414
+ json(res, 200, "unavailable" in result ? { available: false, reason: result.unavailable } : { available: true });
1415
+ };
1416
+ server.middlewares.use((req, res, next) => {
1417
+ if (req.url?.split("?")[0] !== `${base}${CAPTURE_PATH}`) return next();
1418
+ if (req.method === "GET") return void status(res).catch(next);
1419
+ if (req.method === "POST") return void capture(req, res).catch(next);
1420
+ next();
1421
+ });
1422
+ return {
1423
+ revise() {
1424
+ revision += 1;
1425
+ cache.clear();
1426
+ },
1427
+ async close() {
1428
+ revision += 1;
1429
+ cache.clear();
1430
+ const closing = launched;
1431
+ launched = null;
1432
+ const result = await closing;
1433
+ if (result && !("unavailable" in result)) await result.close();
1434
+ }
1435
+ };
1436
+ }
1151
1437
 
1152
1438
  // src/vite/servePages.ts
1153
- var PAGES2 = { "/": "shell-entry.js", "/index.html": "shell-entry.js", "/frame.html": "frame-entry.js" };
1154
- function servePages(server, base, title) {
1439
+ var PAGES2 = {
1440
+ "/": "shell-entry.js",
1441
+ "/index.html": "shell-entry.js",
1442
+ "/frame.html": "frame-entry.js",
1443
+ [`/${CAPTURE_PAGES.shell}`]: "capture-entry.js",
1444
+ [`/${CAPTURE_PAGES.frame}`]: "capture-frame-entry.js"
1445
+ };
1446
+ function servePages(server, base, shell) {
1155
1447
  server.middlewares.use((req, res, next) => {
1156
1448
  const url = req.url ?? "/";
1157
1449
  const path = url.split("?")[0] ?? "/";
@@ -1159,7 +1451,7 @@ function servePages(server, base, title) {
1159
1451
  const page = path === prefix ? "/" : path.startsWith(`${prefix}/`) ? path.slice(prefix.length) : null;
1160
1452
  const entry = req.method === "GET" && page !== null ? PAGES2[page] : void 0;
1161
1453
  if (!entry) return next();
1162
- server.transformIndexHtml(url, html(entry, "serve", title())).then(
1454
+ server.transformIndexHtml(url, html(entry, "serve", shell().title)).then(
1163
1455
  (doc) => {
1164
1456
  res.statusCode = 200;
1165
1457
  res.setHeader("Content-Type", "text/html");
@@ -1168,15 +1460,14 @@ function servePages(server, base, title) {
1168
1460
  (err) => next(err)
1169
1461
  );
1170
1462
  });
1463
+ return serveCapture(server, base, () => shell().allowOrigins);
1171
1464
  }
1172
-
1173
- // src/vite/shellTitle.ts
1174
- function shellTitle(code, file) {
1465
+ function configOf(code, file) {
1175
1466
  let program;
1176
1467
  try {
1177
1468
  program = parseFile(code, file);
1178
1469
  } catch {
1179
- return void 0;
1470
+ return null;
1180
1471
  }
1181
1472
  const bindings = /* @__PURE__ */ new Map();
1182
1473
  let config;
@@ -1191,7 +1482,46 @@ function shellTitle(code, file) {
1191
1482
  }
1192
1483
  }
1193
1484
  const wrapper = calleeOf(config, bindings);
1194
- return stringOf(prop(objectOf(config, bindings, new Set(wrapper ? [wrapper] : [])), "title"), bindings);
1485
+ return { config: objectOf(config, bindings, new Set(wrapper ? [wrapper] : [])), bindings };
1486
+ }
1487
+ function shellTitle(code, file) {
1488
+ const read = configOf(code, file);
1489
+ return read ? stringOf(prop(read.config, "title"), read.bindings) : void 0;
1490
+ }
1491
+ function arrayOf(node, bindings, depth = 0) {
1492
+ if (!node || depth > 16) return null;
1493
+ if (node.type === "ArrayExpression") return node;
1494
+ if (node.type === "Identifier") return arrayOf(bindings.get(node.name), bindings, depth + 1);
1495
+ if ("expression" in node && (node.type.startsWith("TS") || node.type === "ParenthesizedExpression")) {
1496
+ return arrayOf(node.expression, bindings, depth + 1);
1497
+ }
1498
+ return null;
1499
+ }
1500
+ var originOf = (url) => {
1501
+ if (url === void 0) return [];
1502
+ try {
1503
+ const { origin } = new URL(url);
1504
+ return origin === "null" ? [] : [origin];
1505
+ } catch {
1506
+ return [];
1507
+ }
1508
+ };
1509
+ function shellAllowOrigins(code, file) {
1510
+ const read = configOf(code, file);
1511
+ if (!read) return [];
1512
+ const capture = objectOf(prop(read.config, "capture"), read.bindings, /* @__PURE__ */ new Set());
1513
+ const list = arrayOf(prop(capture, "allowOrigins"), read.bindings);
1514
+ return (list?.elements ?? []).flatMap((el) => el && el.type !== "SpreadElement" ? originOf(stringOf(el, read.bindings)) : []);
1515
+ }
1516
+ function readShellFields(file) {
1517
+ let code;
1518
+ try {
1519
+ if (file === void 0) throw new Error("no shell config");
1520
+ code = readFileSync(file, "utf8");
1521
+ } catch {
1522
+ return { title: void 0, allowOrigins: [] };
1523
+ }
1524
+ return { title: shellTitle(code, file), allowOrigins: shellAllowOrigins(code, file) };
1195
1525
  }
1196
1526
  var SHIMS = [
1197
1527
  [/^@?storybook\/preview-api$/, "@weasel-js/forge/preview-api"],
@@ -1208,6 +1538,105 @@ function storybookShims() {
1208
1538
  }
1209
1539
  };
1210
1540
  }
1541
+
1542
+ // src/vite/capture/entries.ts
1543
+ var body = (mount) => `import index from 'virtual:forge/index.js';
1544
+ import importers from 'virtual:forge/importers.js';
1545
+ import setup from 'virtual:forge/frame-config.js';
1546
+
1547
+ ${mount}
1548
+ // The endpoint drops its captures on any edit; a page loaded before one would render the old module.
1549
+ import.meta.hot?.accept('virtual:forge/importers.js', () => location.reload());
1550
+ `;
1551
+ function captureEntry() {
1552
+ return `import { mountCapture } from '@weasel-js/forge/frame';
1553
+ import '@weasel-js/labkit/styles.css';
1554
+ import '@weasel-js/forge/shell.css';
1555
+ import config from 'virtual:forge/shell-config.js';
1556
+ ${body("mountCapture({ index, importers, setup, labTheme: config.labTheme });")}`;
1557
+ }
1558
+ function captureFrameEntry(styles) {
1559
+ return `import { mountCapture } from '@weasel-js/forge/frame';
1560
+ import '@weasel-js/forge/frame.css';
1561
+ ${styles.map((path) => `import ${JSON.stringify(path)};
1562
+ `).join("")}${body("mountCapture({ index, importers, setup });")}`;
1563
+ }
1564
+
1565
+ // src/vite/entries.ts
1566
+ function shellEntry(base, stories) {
1567
+ return `import { followEdits, mountWorkshop } from '@weasel-js/forge/shell';
1568
+ import '@weasel-js/labkit/styles.css';
1569
+ import '@weasel-js/forge/shell.css';
1570
+ import index from 'virtual:forge/index.js';
1571
+ import importers from 'virtual:forge/importers.js';
1572
+ import config from 'virtual:forge/shell-config.js';
1573
+ import setup from 'virtual:forge/frame-config.js';
1574
+
1575
+ const workshop = mountWorkshop({
1576
+ index,
1577
+ importers,
1578
+ setup,
1579
+ config,
1580
+ frameUrl: ${JSON.stringify(`${base}frame.html`)},
1581
+ captureBase: ${JSON.stringify(base)},
1582
+ stories: ${JSON.stringify(stories)},
1583
+ });
1584
+ // Read from source on first request, so the graph costs nothing until the page is up.
1585
+ import('virtual:forge/deps.js').then((m) => workshop.setDependencies(m.default));
1586
+ const edits = followEdits(import.meta.hot, workshop);
1587
+ import.meta.hot?.accept('virtual:forge/importers.js', (next) => edits.importers(next?.default));
1588
+ `;
1589
+ }
1590
+ function frameEntry(styles) {
1591
+ return `import { mountFrame } from '@weasel-js/forge/frame';
1592
+ import '@weasel-js/forge/frame.css';
1593
+ ${styles.map((path) => `import ${JSON.stringify(path)};
1594
+ `).join("")}import index from 'virtual:forge/index.js';
1595
+ import importers from 'virtual:forge/importers.js';
1596
+ import setup from 'virtual:forge/frame-config.js';
1597
+
1598
+ mountFrame({ index, importers, setup });
1599
+ // The importers module is shared with the workshop page, which accepts a story edit in place; a frame document
1600
+ // showing the old module reloads instead, and without this the edit would dead-end here and reload every page.
1601
+ import.meta.hot?.accept('virtual:forge/importers.js', () => location.reload());
1602
+ `;
1603
+ }
1604
+
1605
+ // src/vite/virtualModules.ts
1606
+ var PREFIX = "virtual:forge/";
1607
+ var MODULES = /* @__PURE__ */ new Set([
1608
+ "index.js",
1609
+ "importers.js",
1610
+ "deps.js",
1611
+ "frame-config.js",
1612
+ "shell-config.js",
1613
+ "shell-entry.js",
1614
+ "frame-entry.js",
1615
+ "capture-entry.js",
1616
+ "capture-frame-entry.js"
1617
+ ]);
1618
+ function virtualModules(options, src) {
1619
+ const configModule = (path) => path ? `export { default } from ${JSON.stringify(resolve(src.root(), path))};
1620
+ ` : "export default {};\n";
1621
+ const frameStyles = () => (options.frameStyles ?? []).map((path) => resolve(src.root(), path));
1622
+ return {
1623
+ "index.js": () => `export default ${JSON.stringify(src.index())};
1624
+ `,
1625
+ "importers.js": () => {
1626
+ const lines = [...src.files()].filter(([, entries]) => entries.length > 0).map(([file]) => ` ${JSON.stringify(file)}: () => import(${JSON.stringify(file)}),`);
1627
+ return `export default {
1628
+ ${lines.join("\n")}
1629
+ };
1630
+ `;
1631
+ },
1632
+ "frame-config.js": () => configModule(options.frameConfig),
1633
+ "shell-config.js": () => configModule(options.shellConfig),
1634
+ "shell-entry.js": () => shellEntry(src.base(), options.stories),
1635
+ "frame-entry.js": () => frameEntry(frameStyles()),
1636
+ "capture-entry.js": () => captureEntry(),
1637
+ "capture-frame-entry.js": () => captureFrameEntry(frameStyles())
1638
+ };
1639
+ }
1211
1640
  var WAIT_MS = 5e3;
1212
1641
  var REWRITE_MS = 250;
1213
1642
  var watchDir = (dir, onEvent) => {
@@ -1219,7 +1648,7 @@ var watchDir = (dir, onEvent) => {
1219
1648
  };
1220
1649
  function fileEventsArrive({ waitMs = WAIT_MS, watch = watchDir } = {}) {
1221
1650
  const dir = mkdtempSync(join(tmpdir(), "forge-watch-"));
1222
- return new Promise((resolve5) => {
1651
+ return new Promise((resolve6) => {
1223
1652
  let settled2 = false;
1224
1653
  let writes = 0;
1225
1654
  let stop;
@@ -1232,7 +1661,7 @@ function fileEventsArrive({ waitMs = WAIT_MS, watch = watchDir } = {}) {
1232
1661
  clearTimeout(deadline);
1233
1662
  stop?.();
1234
1663
  rmSync(dir, { recursive: true, force: true });
1235
- resolve5(arrived);
1664
+ resolve6(arrived);
1236
1665
  };
1237
1666
  const write = () => writeFileSync(join(dir, "probe"), String(writes++));
1238
1667
  try {
@@ -1265,8 +1694,6 @@ function watchProbe({ probe } = {}) {
1265
1694
  }
1266
1695
 
1267
1696
  // src/vite/plugin.ts
1268
- var PREFIX = "virtual:forge/";
1269
- var MODULES = /* @__PURE__ */ new Set(["index.js", "importers.js", "deps.js", "frame-config.js", "shell-config.js", "shell-entry.js", "frame-entry.js"]);
1270
1697
  var SCRIPT2 = /\.[cm]?[jt]sx?$/;
1271
1698
  function forge(options) {
1272
1699
  let root = process.cwd();
@@ -1337,32 +1764,9 @@ function forge(options) {
1337
1764
  }
1338
1765
  return graph;
1339
1766
  }
1340
- const titleUnder = (at) => {
1341
- if (!options.shellConfig) return void 0;
1342
- const file = resolve(at, options.shellConfig);
1343
- try {
1344
- return shellTitle(readFileSync(file, "utf8"), file);
1345
- } catch {
1346
- return void 0;
1347
- }
1348
- };
1349
- const configModule = (path) => path ? `export { default } from ${JSON.stringify(resolve(root, path))};
1350
- ` : "export default {};\n";
1351
- const modules = {
1352
- "index.js": () => `export default ${JSON.stringify(index())};
1353
- `,
1354
- "importers.js": () => {
1355
- const lines = [...files()].filter(([, entries]) => entries.length > 0).map(([file]) => ` ${JSON.stringify(file)}: () => import(${JSON.stringify(file)}),`);
1356
- return `export default {
1357
- ${lines.join("\n")}
1358
- };
1359
- `;
1360
- },
1361
- "frame-config.js": () => configModule(options.frameConfig),
1362
- "shell-config.js": () => configModule(options.shellConfig),
1363
- "shell-entry.js": () => shellEntry(base, options.stories),
1364
- "frame-entry.js": () => frameEntry((options.frameStyles ?? []).map((path) => resolve(root, path)))
1365
- };
1767
+ const shellUnder = (at) => readShellFields(options.shellConfig ? resolve(at, options.shellConfig) : void 0);
1768
+ const modules = virtualModules(options, { index, files, root: () => root, base: () => base });
1769
+ let capture = null;
1366
1770
  const invalidate = (server, names) => {
1367
1771
  for (const env of Object.values(server.environments)) {
1368
1772
  for (const name of names) {
@@ -1373,12 +1777,12 @@ ${lines.join("\n")}
1373
1777
  };
1374
1778
  function redeps(server) {
1375
1779
  if (depsSent === null || depsPending) return;
1376
- const resolve5 = async (spec, importer) => (await server.environments.client.pluginContainer.resolveId(spec, importer))?.id ?? null;
1780
+ const resolve6 = async (spec, importer) => (await server.environments.client.pluginContainer.resolveId(spec, importer))?.id ?? null;
1377
1781
  depsPending = Promise.resolve().then(async () => {
1378
- const graph = await graphOf(resolve5);
1379
- const json = JSON.stringify(graph);
1380
- if (json === depsSent) return;
1381
- depsSent = json;
1782
+ const graph = await graphOf(resolve6);
1783
+ const json2 = JSON.stringify(graph);
1784
+ if (json2 === depsSent) return;
1785
+ depsSent = json2;
1382
1786
  invalidate(server, ["deps.js"]);
1383
1787
  server.ws.send({ type: "custom", event: "forge:deps", data: graph });
1384
1788
  }).catch(logError).finally(() => {
@@ -1442,7 +1846,7 @@ ${lines.join("\n")}
1442
1846
  ];
1443
1847
  const optimizeDeps = { entries };
1444
1848
  if (command !== "build") return { optimizeDeps };
1445
- return { optimizeDeps, build: { rolldownOptions: { input: writePages(at, titleUnder(at)) } } };
1849
+ return { optimizeDeps, build: { rolldownOptions: { input: writePages(at, shellUnder(at).title) } } };
1446
1850
  },
1447
1851
  generateBundle: {
1448
1852
  order: "post",
@@ -1479,6 +1883,7 @@ ${lines.join("\n")}
1479
1883
  },
1480
1884
  hotUpdate({ modules: modules2, server }) {
1481
1885
  if (this.environment.name !== "client") return;
1886
+ capture?.revise();
1482
1887
  for (const file of storiesReaching(modules2)) server.ws.send({ type: "custom", event: "forge:story", data: { file } });
1483
1888
  },
1484
1889
  configureServer(server) {
@@ -1495,8 +1900,9 @@ ${lines.join("\n")}
1495
1900
  }, logError);
1496
1901
  });
1497
1902
  }
1498
- servePages(server, base, () => titleUnder(root));
1499
- }
1903
+ capture = servePages(server, base, () => shellUnder(root));
1904
+ },
1905
+ closeServer: () => capture?.close()
1500
1906
  }
1501
1907
  ];
1502
1908
  }
@@ -1540,7 +1946,6 @@ function forgeTest(options) {
1540
1946
  const lines = [
1541
1947
  "",
1542
1948
  `import { test as __forge_test } from "vitest";`,
1543
- `import { page as __forge_page } from "vitest/browser";`,
1544
1949
  `import { runStory as __forge_runStory } from "@weasel-js/forge/test";`,
1545
1950
  // shell.css draws the story host; frame.css still holds the boundary's fault rule.
1546
1951
  `import "@weasel-js/forge/shell.css";`,
@@ -1551,7 +1956,6 @@ function forgeTest(options) {
1551
1956
  `const __forge_timeout = ${TIMEOUT_MS};`,
1552
1957
  "const __forge_options = {",
1553
1958
  ` setup: ${options.frameConfig ? "__forge_frame_config" : "undefined"},`,
1554
- " viewport: (width, height) => __forge_page.viewport(width, height),",
1555
1959
  "};",
1556
1960
  "const __forge_run = async (exportName) => __forge_runStory(await import(/* @vite-ignore */ import.meta.url), exportName, __forge_file, __forge_title, __forge_options);",
1557
1961
  // An isolated story asks for a document of its own, which the test page is not; its reason goes in the name.