@valbuild/server 0.147.0 → 0.148.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 (29) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/browser/dist/valbuild-server-browser.cjs.dev.js +2 -2
  3. package/browser/dist/valbuild-server-browser.cjs.prod.js +2 -2
  4. package/browser/dist/valbuild-server-browser.esm.js +2 -2
  5. package/dist/{ValRouterCore-7ffcc08c.cjs.prod.js → ValRouterCore-42772c21.cjs.prod.js} +980 -53
  6. package/dist/{ValRouterCore-f5004f8f.esm.js → ValRouterCore-7ca3dc68.esm.js} +972 -54
  7. package/dist/{ValRouterCore-3fd3d444.cjs.dev.js → ValRouterCore-9bdaaf89.cjs.dev.js} +980 -53
  8. package/dist/declarations/src/ValOps.d.ts +32 -11
  9. package/dist/declarations/src/ValOpsMemory.d.ts +4 -4
  10. package/dist/declarations/src/ValServer.d.ts +1 -2
  11. package/dist/declarations/src/checkRemoteRef.d.ts +40 -2
  12. package/dist/declarations/src/createFixPatch.d.ts +25 -3
  13. package/dist/declarations/src/fixFiles.d.ts +42 -0
  14. package/dist/declarations/src/fixHandlers.d.ts +14 -0
  15. package/dist/declarations/src/getSettings.d.ts +1 -0
  16. package/dist/declarations/src/index.d.ts +1 -0
  17. package/dist/declarations/src/platform.d.ts +16 -0
  18. package/dist/declarations/src/remoteFileCheck.d.ts +116 -0
  19. package/dist/declarations/src/remoteHostCheck.d.ts +101 -0
  20. package/dist/declarations/src/studioValidation.d.ts +84 -0
  21. package/dist/declarations/src/uploadRemoteFile.d.ts +1 -1
  22. package/dist/declarations/src/videosetFixes.d.ts +3 -2
  23. package/dist/{getJsonEntryAtCommit-df65d861.cjs.prod.js → getJsonEntryAtCommit-2e36e805.cjs.prod.js} +3 -4
  24. package/dist/{getJsonEntryAtCommit-1e08d724.esm.js → getJsonEntryAtCommit-40b6d9c1.esm.js} +3 -4
  25. package/dist/{getJsonEntryAtCommit-e96beb9e.cjs.dev.js → getJsonEntryAtCommit-64f67a50.cjs.dev.js} +3 -4
  26. package/dist/valbuild-server.cjs.dev.js +1352 -695
  27. package/dist/valbuild-server.cjs.prod.js +1352 -695
  28. package/dist/valbuild-server.esm.js +1352 -696
  29. package/package.json +4 -5
@@ -12,7 +12,7 @@ var path = require('path');
12
12
  var fs = require('fs');
13
13
  var vm = require('node:vm');
14
14
  var node_module = require('node:module');
15
- var ValRouterCore = require('./ValRouterCore-3fd3d444.cjs.dev.js');
15
+ var ValRouterCore = require('./ValRouterCore-9bdaaf89.cjs.dev.js');
16
16
  var server = require('@valbuild/ui/server');
17
17
  var z = require('zod');
18
18
  var zodValidationError = require('zod-validation-error');
@@ -23,10 +23,10 @@ var buffer = require('buffer');
23
23
  var http = require('node:http');
24
24
  var https = require('node:https');
25
25
  var net = require('node:net');
26
- var sucrase = require('sucrase');
27
26
  var sizeOf = require('image-size');
28
27
  var http$1 = require('http');
29
28
  var https$1 = require('https');
29
+ var sucrase = require('sucrase');
30
30
  var fs$1 = require('fs/promises');
31
31
 
32
32
  function _interopDefault (e) { return e && e.__esModule ? e : { 'default': e }; }
@@ -3189,8 +3189,8 @@ class ValOpsFS extends ValRouterCore.ValOps {
3189
3189
  // Cheap, and this is the one path that creates staging directories, so it
3190
3190
  // is where the ones nobody claimed get noticed.
3191
3191
  sweepStaleUploads(patchesDir);
3192
- const buffer = ValRouterCore.bufferFromDataUrl(data);
3193
- if (!buffer) {
3192
+ const buffer$1 = ValRouterCore.bufferFromDataUrl(data, base64 => buffer.Buffer.from(base64, "base64"));
3193
+ if (!buffer$1) {
3194
3194
  return {
3195
3195
  error: {
3196
3196
  message: "Could not create buffer from data url. Not a data url? First chars were: " + data.slice(0, 20)
@@ -3198,7 +3198,7 @@ class ValOpsFS extends ValRouterCore.ValOps {
3198
3198
  };
3199
3199
  }
3200
3200
  this.host.writeUf8File(metadataFilePath, JSON.stringify(metadata));
3201
- this.host.writeBinaryFile(patchFilePath, buffer);
3201
+ this.host.writeBinaryFile(patchFilePath, buffer$1);
3202
3202
  return {
3203
3203
  patchId,
3204
3204
  filePath
@@ -9258,568 +9258,279 @@ function readableError(error) {
9258
9258
  }
9259
9259
  }
9260
9260
 
9261
- /** The platform every server on Node runs with. See {@link ValServerPlatform}. */
9262
- const nodePlatform = {
9263
- createOps: createValOps,
9264
- probeUrl: url => probeUrl(url),
9265
- jwt: {
9266
- verifyJwt,
9267
- decodeJwtWithoutVerifying,
9268
- encodeJwt,
9269
- getExpire
9270
- },
9271
- readValLoginToken,
9272
- resolveRemoteFileAuth
9273
- };
9274
-
9275
- async function createValServer(valModules, route, opts, config, callbacks, formatter,
9276
9261
  /**
9277
- * Called after a save has applied its patches. EXPERIMENTAL — see
9278
- * `ValServerOptions.commitPrepared`.
9279
- */
9280
- commitPrepared,
9281
- /**
9282
- * What a publish does in http mode. EXPERIMENTAL — see
9283
- * `ValServerOptions.publishOverride`.
9262
+ * The size and length of an ISO base media file (`.mp4`, `.m4v`, `.mov`),
9263
+ * read from its headers. Nothing is decoded.
9264
+ *
9265
+ * Hand-written rather than a media library, deliberately: `@valbuild/server`
9266
+ * is loaded by every app that runs Val, and the four numbers wanted here are
9267
+ * three small boxes away from the start of the `moov` box:
9268
+ *
9269
+ * moov > mvhd timescale and duration (version 0 and 1)
9270
+ * moov > mvex > mehd duration of a FRAGMENTED file, whose mvhd says 0
9271
+ * moov > trak > tkhd width and height (16.16 fixed point) and the
9272
+ * display matrix, for a rotated phone video
9273
+ * moov > trak > mdia > hdlr `vide`: which trak is the picture
9274
+ *
9275
+ * Only the box headers are read on the way to `moov`, and then `moov` itself,
9276
+ * so a multi-GB file costs a few small reads: `mdat` is seeked past, never
9277
+ * loaded. That is what {@link ByteSource} is for.
9284
9278
  */
9285
- publishOverride) {
9286
- const valServerConfig = await initHandlerOptions(route, opts, config);
9287
- return ValRouterCore.ValServer(valModules, {
9288
- formatter,
9289
- commitPrepared,
9290
- publishOverride,
9291
- ...valServerConfig
9292
- }, callbacks, nodePlatform);
9279
+
9280
+ /** Random access to a file's bytes, so a large `mdat` can be skipped. */
9281
+
9282
+ function bufferByteSource(buffer) {
9283
+ return {
9284
+ size: buffer.length,
9285
+ read: (offset, length) => buffer.subarray(offset, offset + length)
9286
+ };
9293
9287
  }
9294
9288
 
9295
- // TODO: remove
9296
9289
  /**
9297
- * `fs` and `path` are imported INSIDE this function, not at the top of the file.
9298
- *
9299
- * This is the only thing in this module that touches either, and it is a local
9300
- * development convenience: scanning upwards for a `.git` to guess the commit and
9301
- * branch. A static import put `fs` in the module graph of everything reaching
9302
- * `createValApiRouter` -- which is every server integration, including ones that
9303
- * run where there is no filesystem. Workerd provides no `fs`, so such a build
9304
- * could not be bundled at all without stubbing it.
9305
- *
9306
- * The `await import` costs nothing here: the only caller is the CLI, on a
9307
- * machine that has both.
9290
+ * Run `read` against a file on disk, reading only what it asks for. The file
9291
+ * is closed afterwards whatever happens.
9308
9292
  */
9309
- async function safeReadGit(cwd) {
9310
- const {
9311
- promises: fs
9312
- } = await Promise.resolve().then(function () { return /*#__PURE__*/_interopNamespace(require('fs')); });
9313
- const path = await Promise.resolve().then(function () { return /*#__PURE__*/_interopNamespace(require('path')); });
9314
- async function findGitHead(currentDir, depth) {
9315
- const gitHeadPath = path.join(currentDir, ".git", "HEAD");
9316
- if (depth > 1000) {
9317
- console.error(`Reached max depth while scanning for .git folder. Current working dir: ${cwd}.`);
9318
- return {
9319
- commit: undefined,
9320
- branch: undefined
9321
- };
9322
- }
9323
- try {
9324
- const headContents = await fs.readFile(gitHeadPath, "utf-8");
9325
- const match = headContents.match(/^ref: refs\/heads\/(.+)/);
9326
- if (match) {
9327
- const branchName = match[1];
9328
- return {
9329
- branch: branchName,
9330
- commit: await readCommit(currentDir, branchName)
9331
- };
9332
- } else {
9333
- return {
9334
- commit: undefined,
9335
- branch: undefined
9336
- };
9293
+ function withFileByteSource(absolutePath, read) {
9294
+ const fd = fs__default["default"].openSync(absolutePath, "r");
9295
+ try {
9296
+ const size = fs__default["default"].fstatSync(fd).size;
9297
+ return read({
9298
+ size,
9299
+ read: (offset, length) => {
9300
+ const wanted = Math.max(0, Math.min(length, size - offset));
9301
+ const buffer = Buffer.alloc(wanted);
9302
+ let filled = 0;
9303
+ while (filled < wanted) {
9304
+ const n = fs__default["default"].readSync(fd, buffer, filled, wanted - filled, offset + filled);
9305
+ if (n === 0) {
9306
+ break;
9307
+ }
9308
+ filled += n;
9309
+ }
9310
+ return buffer.subarray(0, filled);
9337
9311
  }
9338
- } catch {
9339
- const parentDir = path.dirname(currentDir);
9312
+ });
9313
+ } finally {
9314
+ fs__default["default"].closeSync(fd);
9315
+ }
9316
+ }
9317
+ /**
9318
+ * A `moov` larger than this is not a header worth reading into memory. A
9319
+ * two-hour film's is a few MB; this is far past any real one.
9320
+ */
9321
+ const MAX_MOOV_SIZE = 64 * 1024 * 1024;
9340
9322
 
9341
- // We've reached the root directory
9342
- if (parentDir === currentDir) {
9343
- return {
9344
- commit: undefined,
9345
- branch: undefined
9346
- };
9323
+ /**
9324
+ * Whatever of {@link IsoBmffMetadata} the file declares. A field that cannot be
9325
+ * read is absent; nothing here throws on a file that is not ISO BMFF.
9326
+ */
9327
+ function readIsoBmffMetadata(source) {
9328
+ const moov = findTopLevelBox(source, "moov");
9329
+ if (!moov) {
9330
+ return {};
9331
+ }
9332
+ const metadata = {};
9333
+ const mvhd = childBox(moov, "mvhd");
9334
+ const timescaleAndDuration = mvhd ? readMvhd(mvhd) : undefined;
9335
+ if (timescaleAndDuration) {
9336
+ const {
9337
+ timescale,
9338
+ duration
9339
+ } = timescaleAndDuration;
9340
+ if (duration > 0) {
9341
+ metadata.duration = duration / timescale;
9342
+ } else {
9343
+ // A fragmented file (CMAF) says 0 in mvhd and puts the length in mehd,
9344
+ // in the same timescale.
9345
+ const mehd = childBox(childBox(moov, "mvex"), "mehd");
9346
+ const fragmentDuration = mehd ? readMehd(mehd) : undefined;
9347
+ if (fragmentDuration !== undefined && fragmentDuration > 0) {
9348
+ metadata.duration = fragmentDuration / timescale;
9347
9349
  }
9348
- return findGitHead(parentDir, depth + 1);
9349
9350
  }
9350
9351
  }
9351
- try {
9352
- return findGitHead(cwd, 0);
9353
- } catch (err) {
9354
- console.error("Error while reading .git", err);
9355
- return {
9356
- commit: undefined,
9357
- branch: undefined
9358
- };
9359
- }
9360
- }
9361
-
9362
- /** Only reached from {@link safeReadGit}; same reason for the local imports. */
9363
- async function readCommit(gitDir, branchName) {
9364
- const {
9365
- promises: fs
9366
- } = await Promise.resolve().then(function () { return /*#__PURE__*/_interopNamespace(require('fs')); });
9367
- const path = await Promise.resolve().then(function () { return /*#__PURE__*/_interopNamespace(require('path')); });
9368
- try {
9369
- return (await fs.readFile(path.join(gitDir, ".git", "refs", "heads", branchName), "utf-8")).trim();
9370
- } catch {
9371
- return undefined;
9352
+ for (const trak of childBoxes(moov, "trak")) {
9353
+ const hdlr = childBox(childBox(trak, "mdia"), "hdlr");
9354
+ if (!hdlr || hdlr.length < 12 || hdlr.toString("latin1", 8, 12) !== "vide") {
9355
+ continue;
9356
+ }
9357
+ const tkhd = childBox(trak, "tkhd");
9358
+ const size = tkhd ? readTkhd(tkhd) : undefined;
9359
+ if (size && size.width > 0 && size.height > 0) {
9360
+ metadata.width = size.width;
9361
+ metadata.height = size.height;
9362
+ break;
9363
+ }
9372
9364
  }
9365
+ return metadata;
9373
9366
  }
9374
- function createValApiRouter(route, valServerPromise, convert) {
9375
- const uiRequestHandler = server.createUIRequestHandler();
9376
- return ValRouterCore.createValRequestHandler(route, valServerPromise, convert, uiRequestHandler);
9377
- }
9378
-
9379
- const JsFileLookupMapping = [
9380
- // NOTE: first one matching will be used
9381
- [".cjs.d.ts", [".esm.js", ".mjs.js"]], [".cjs.js", [".esm.js", ".mjs.js"]], [".cjs", [".mjs"]], [".d.ts", [".js", ".esm.js", ".mjs.js"]]];
9382
- const MAX_CACHE_SIZE = 100 * 1024 * 1024; // 100 mb
9383
- const MAX_OBJECT_KEY_SIZE = 2 ** 27; // https://stackoverflow.com/questions/13367391/is-there-a-limit-on-length-of-the-key-string-in-js-object
9384
9367
 
9385
- class ValModuleLoader {
9386
- cache;
9387
- cacheSize;
9388
- constructor(projectRoot, compilerOptions,
9389
- // TODO: remove this?
9390
- sourceFileHandler, host = {
9391
- ...ts__default["default"].sys,
9392
- writeFile: (fileName, data, encoding) => {
9393
- fs__default["default"].mkdirSync(path__default["default"].dirname(fileName), {
9394
- recursive: true
9395
- });
9396
- fs__default["default"].writeFileSync(fileName, typeof data === "string" ? data : new Uint8Array(data), encoding);
9397
- },
9398
- rmFile: fs__default["default"].rmSync,
9399
- readBuffer: fileName => {
9400
- try {
9401
- return fs__default["default"].readFileSync(fileName);
9402
- } catch {
9368
+ /** The payload (after the header) of the first top-level box of a type. */
9369
+ function findTopLevelBox(source, type) {
9370
+ let offset = 0;
9371
+ while (offset + 8 <= source.size) {
9372
+ const header = source.read(offset, 16);
9373
+ if (header.length < 8) {
9374
+ return undefined;
9375
+ }
9376
+ let size = header.readUInt32BE(0);
9377
+ const boxType = header.toString("latin1", 4, 8);
9378
+ let headerSize = 8;
9379
+ if (size === 1) {
9380
+ if (header.length < 16) {
9403
9381
  return undefined;
9404
9382
  }
9383
+ size = Number(header.readBigUInt64BE(8));
9384
+ headerSize = 16;
9385
+ } else if (size === 0) {
9386
+ size = source.size - offset;
9405
9387
  }
9406
- }, disableCache = false) {
9407
- this.projectRoot = projectRoot;
9408
- this.compilerOptions = compilerOptions;
9409
- this.sourceFileHandler = sourceFileHandler;
9410
- this.host = host;
9411
- this.disableCache = disableCache;
9412
- this.cache = {};
9413
- this.cacheSize = 0;
9414
- }
9415
- getModule(modulePath) {
9416
- if (!modulePath) {
9417
- throw Error(`Illegal module path: "${modulePath}"`);
9388
+ if (size < headerSize) {
9389
+ return undefined;
9418
9390
  }
9419
- const code = this.host.readFile(modulePath);
9420
- if (!code) {
9421
- throw Error(`Could not read file "${modulePath}"`);
9422
- }
9423
- let compiledCode;
9424
- if (this.cache[code] && !this.disableCache) {
9425
- // TODO: use hash instead of code as key
9426
- compiledCode = this.cache[code];
9427
- } else {
9428
- compiledCode = sucrase.transform(code, {
9429
- filePath: modulePath,
9430
- disableESTransforms: true,
9431
- transforms: ["typescript"]
9432
- }).code;
9433
- if (!this.disableCache) {
9434
- if (this.cacheSize > MAX_CACHE_SIZE) {
9435
- console.warn("Cache size exceeded, clearing cache");
9436
- this.cache = {};
9437
- this.cacheSize = 0;
9438
- }
9439
- if (code.length < MAX_OBJECT_KEY_SIZE) {
9440
- this.cache[code] = compiledCode;
9441
- this.cacheSize += code.length + compiledCode.length; // code is mostly ASCII so 1 byte per char
9442
- }
9391
+ if (boxType === type) {
9392
+ if (size - headerSize > MAX_MOOV_SIZE) {
9393
+ return undefined;
9443
9394
  }
9395
+ const payload = source.read(offset + headerSize, size - headerSize);
9396
+ return payload.length === size - headerSize ? payload : undefined;
9444
9397
  }
9445
- return compiledCode;
9398
+ offset += size;
9446
9399
  }
9447
- resolveModulePath(containingFilePath, requestedModuleName) {
9448
- var _this$host$realpath, _this$host;
9449
- let sourceFileName = this.sourceFileHandler.resolveSourceModulePath(containingFilePath, requestedModuleName);
9450
- if (requestedModuleName === "@vercel/stega") {
9451
- sourceFileName = this.sourceFileHandler.resolveSourceModulePath(containingFilePath, "@vercel/stega").replace("stega/dist", "stega/dist/esm");
9452
- }
9453
- const matches = this.findMatchingJsFile(sourceFileName);
9454
- if (matches.match === false) {
9455
- let debugInfo = "";
9456
- if (sourceFileName.includes("val.config")) {
9457
- debugInfo = `\n@valbuild directory scan:\n${this.host.readDirectory("/", ["js", "ts", "json"], [], ["**/@valbuild/*"]).join("\n")}`;
9400
+ return undefined;
9401
+ }
9402
+
9403
+ /** Each child box of a container payload, as `[type, payload]`. */
9404
+ function* boxesIn(payload) {
9405
+ let offset = 0;
9406
+ while (offset + 8 <= payload.length) {
9407
+ let size = payload.readUInt32BE(offset);
9408
+ const type = payload.toString("latin1", offset + 4, offset + 8);
9409
+ let headerSize = 8;
9410
+ if (size === 1) {
9411
+ if (offset + 16 > payload.length) {
9412
+ return;
9458
9413
  }
9459
- throw Error(`Could not find matching js file for module "${requestedModuleName}" requested by: "${containingFilePath}". Tried:\n${matches.tried.join("\n")}${debugInfo}`);
9414
+ size = Number(payload.readBigUInt64BE(offset + 8));
9415
+ headerSize = 16;
9416
+ } else if (size === 0) {
9417
+ size = payload.length - offset;
9460
9418
  }
9461
- const filePath = matches.match;
9462
- // resolve all symlinks (preconstruct for example symlinks the dist folder)
9463
- const followedPath = ((_this$host$realpath = (_this$host = this.host).realpath) === null || _this$host$realpath === void 0 ? void 0 : _this$host$realpath.call(_this$host, filePath)) ?? filePath;
9464
- if (!followedPath) {
9465
- throw Error(`File path was empty: "${filePath}", containing file: "${containingFilePath}", requested module: "${requestedModuleName}"`);
9419
+ if (size < headerSize || offset + size > payload.length) {
9420
+ return;
9466
9421
  }
9467
- return followedPath;
9422
+ yield [type, payload.subarray(offset + headerSize, offset + size)];
9423
+ offset += size;
9468
9424
  }
9469
- findMatchingJsFile(filePath) {
9470
- let requiresReplacements = false;
9471
- for (const [currentEnding] of JsFileLookupMapping) {
9472
- if (filePath.endsWith(currentEnding)) {
9473
- requiresReplacements = true;
9474
- break;
9475
- }
9476
- }
9477
- // avoid unnecessary calls to fileExists if we don't need to replace anything
9478
- if (!requiresReplacements) {
9479
- if (this.host.fileExists(filePath)) {
9480
- return {
9481
- match: filePath
9482
- };
9483
- }
9425
+ }
9426
+ function childBoxes(payload, type) {
9427
+ if (!payload) {
9428
+ return [];
9429
+ }
9430
+ const found = [];
9431
+ for (const [boxType, child] of boxesIn(payload)) {
9432
+ if (boxType === type) {
9433
+ found.push(child);
9484
9434
  }
9485
- const tried = [];
9486
- for (const [currentEnding, replacements] of JsFileLookupMapping) {
9487
- if (filePath.endsWith(currentEnding)) {
9488
- for (const replacement of replacements) {
9489
- const newFilePath = filePath.slice(0, -currentEnding.length) + replacement;
9490
- if (this.host.fileExists(newFilePath)) {
9491
- return {
9492
- match: newFilePath
9493
- };
9494
- } else {
9495
- tried.push(newFilePath);
9496
- }
9497
- }
9498
- }
9435
+ }
9436
+ return found;
9437
+ }
9438
+ function childBox(payload, type) {
9439
+ return childBoxes(payload, type)[0];
9440
+ }
9441
+
9442
+ /** "Unknown" in a version 1 box. Not a literal: `0x…n` needs an ES2020 target. */
9443
+ const ALL_ONES_64 = BigInt("0xffffffffffffffff");
9444
+ function readMvhd(mvhd) {
9445
+ const version = mvhd[0];
9446
+ if (version === 1) {
9447
+ if (mvhd.length < 32) return undefined;
9448
+ const timescale = mvhd.readUInt32BE(20);
9449
+ const duration = mvhd.readBigUInt64BE(24);
9450
+ if (timescale === 0 || duration === ALL_ONES_64) {
9451
+ return undefined;
9499
9452
  }
9500
9453
  return {
9501
- match: false,
9502
- tried: tried.concat(filePath)
9454
+ timescale,
9455
+ duration: Number(duration)
9503
9456
  };
9504
9457
  }
9458
+ if (mvhd.length < 20) return undefined;
9459
+ const timescale = mvhd.readUInt32BE(12);
9460
+ const duration = mvhd.readUInt32BE(16);
9461
+ // All ones means "unknown" in a version 0 box.
9462
+ if (timescale === 0 || duration === 0xffffffff) {
9463
+ return undefined;
9464
+ }
9465
+ return {
9466
+ timescale,
9467
+ duration
9468
+ };
9469
+ }
9470
+ function readMehd(mehd) {
9471
+ if (mehd[0] === 1) {
9472
+ return mehd.length >= 12 ? Number(mehd.readBigUInt64BE(4)) : undefined;
9473
+ }
9474
+ return mehd.length >= 8 ? mehd.readUInt32BE(4) : undefined;
9505
9475
  }
9506
9476
 
9507
9477
  /**
9508
- * An implementation of methods in the various ts.*Host interfaces
9509
- * that uses ValFS to resolve modules and read/write files.
9478
+ * The size a track is SHOWN at: tkhd's width and height, swapped when the
9479
+ * display matrix turns the picture a quarter turn — a phone records upright
9480
+ * video sideways and says so here, and a page lays it out as it is shown.
9510
9481
  */
9511
-
9512
- class ValFSHost {
9513
- constructor(valFS, currentDirectory) {
9514
- this.valFS = valFS;
9515
- this.currentDirectory = currentDirectory;
9516
- }
9517
- useCaseSensitiveFileNames = true;
9518
- readDirectory(rootDir, extensions, excludes, includes, depth) {
9519
- return this.valFS.readDirectory(rootDir, extensions, excludes, includes, depth);
9520
- }
9521
- rmFile(fileName) {
9522
- this.valFS.rmFile(fileName);
9523
- }
9524
- writeFile(fileName, text, encoding) {
9525
- this.valFS.writeFile(fileName, text, encoding);
9526
- }
9527
- getCurrentDirectory() {
9528
- return this.currentDirectory;
9529
- }
9530
- getCanonicalFileName(fileName) {
9531
- if (path__default["default"].isAbsolute(fileName)) {
9532
- return path__default["default"].normalize(fileName);
9533
- }
9534
- return path__default["default"].resolve(this.getCurrentDirectory(), fileName);
9535
- }
9536
- fileExists(fileName) {
9537
- return this.valFS.fileExists(fileName);
9538
- }
9539
- readFile(fileName) {
9540
- return this.valFS.readFile(fileName);
9541
- }
9542
- readBuffer(fileName) {
9543
- return this.valFS.readBuffer(fileName);
9544
- }
9545
- realpath(path) {
9546
- return this.valFS.realpath(path);
9482
+ function readTkhd(tkhd) {
9483
+ // Past the version-specific times and ids, then 16 bytes of reserved,
9484
+ // layer, alternate group, volume and reserved, then the 36-byte matrix.
9485
+ const matrixAt = tkhd[0] === 1 ? 4 + 32 + 16 : 4 + 20 + 16;
9486
+ const widthAt = matrixAt + 36;
9487
+ if (tkhd.length < widthAt + 8) {
9488
+ return undefined;
9547
9489
  }
9490
+ const a = tkhd.readInt32BE(matrixAt);
9491
+ const b = tkhd.readInt32BE(matrixAt + 4);
9492
+ const c = tkhd.readInt32BE(matrixAt + 12);
9493
+ const d = tkhd.readInt32BE(matrixAt + 16);
9494
+ const width = Math.round(tkhd.readUInt32BE(widthAt) / 0x10000);
9495
+ const height = Math.round(tkhd.readUInt32BE(widthAt + 4) / 0x10000);
9496
+ const quarterTurn = a === 0 && d === 0 && b !== 0 && c !== 0;
9497
+ return quarterTurn ? {
9498
+ width: height,
9499
+ height: width
9500
+ } : {
9501
+ width,
9502
+ height
9503
+ };
9548
9504
  }
9549
9505
 
9550
9506
  /**
9551
- * The size and length of an ISO base media file (`.mp4`, `.m4v`, `.mov`),
9552
- * read from its headers. Nothing is decoded.
9507
+ * The size and length of a Matroska file (`.webm`, and `.mkv`, which is the
9508
+ * same container), read from its headers. Nothing is decoded.
9553
9509
  *
9554
- * Hand-written rather than a media library, deliberately: `@valbuild/server`
9555
- * is loaded by every app that runs Val, and the four numbers wanted here are
9556
- * three small boxes away from the start of the `moov` box:
9510
+ * Hand-written for the same reason `isoBmff.ts` is: `@valbuild/server` is
9511
+ * loaded by every app that runs Val, and what is wanted here is a handful of
9512
+ * integers two levels into the file:
9557
9513
  *
9558
- * moov > mvhd timescale and duration (version 0 and 1)
9559
- * moov > mvex > mehd duration of a FRAGMENTED file, whose mvhd says 0
9560
- * moov > trak > tkhd width and height (16.16 fixed point) and the
9561
- * display matrix, for a rotated phone video
9562
- * moov > trak > mdia > hdlr `vide`: which trak is the picture
9514
+ * EBML the header; a file without one is not Matroska
9515
+ * Segment > Info TimecodeScale and Duration
9516
+ * Segment > Tracks > TrackEntry TrackType 1 (video) >
9517
+ * Video PixelWidth / PixelHeight, and DisplayWidth /
9518
+ * DisplayHeight when they are in pixels
9563
9519
  *
9564
- * Only the box headers are read on the way to `moov`, and then `moov` itself,
9565
- * so a multi-GB file costs a few small reads: `mdat` is seeked past, never
9566
- * loaded. That is what {@link ByteSource} is for.
9567
- */
9568
-
9569
- /** Random access to a file's bytes, so a large `mdat` can be skipped. */
9570
-
9571
- function bufferByteSource(buffer) {
9572
- return {
9573
- size: buffer.length,
9574
- read: (offset, length) => buffer.subarray(offset, offset + length)
9575
- };
9576
- }
9577
-
9578
- /**
9579
- * Run `read` against a file on disk, reading only what it asks for. The file
9580
- * is closed afterwards whatever happens.
9581
- */
9582
- function withFileByteSource(absolutePath, read) {
9583
- const fd = fs__default["default"].openSync(absolutePath, "r");
9584
- try {
9585
- const size = fs__default["default"].fstatSync(fd).size;
9586
- return read({
9587
- size,
9588
- read: (offset, length) => {
9589
- const wanted = Math.max(0, Math.min(length, size - offset));
9590
- const buffer = Buffer.alloc(wanted);
9591
- let filled = 0;
9592
- while (filled < wanted) {
9593
- const n = fs__default["default"].readSync(fd, buffer, filled, wanted - filled, offset + filled);
9594
- if (n === 0) {
9595
- break;
9596
- }
9597
- filled += n;
9598
- }
9599
- return buffer.subarray(0, filled);
9600
- }
9601
- });
9602
- } finally {
9603
- fs__default["default"].closeSync(fd);
9604
- }
9605
- }
9606
- /**
9607
- * A `moov` larger than this is not a header worth reading into memory. A
9608
- * two-hour film's is a few MB; this is far past any real one.
9609
- */
9610
- const MAX_MOOV_SIZE = 64 * 1024 * 1024;
9611
-
9612
- /**
9613
- * Whatever of {@link IsoBmffMetadata} the file declares. A field that cannot be
9614
- * read is absent; nothing here throws on a file that is not ISO BMFF.
9615
- */
9616
- function readIsoBmffMetadata(source) {
9617
- const moov = findTopLevelBox(source, "moov");
9618
- if (!moov) {
9619
- return {};
9620
- }
9621
- const metadata = {};
9622
- const mvhd = childBox(moov, "mvhd");
9623
- const timescaleAndDuration = mvhd ? readMvhd(mvhd) : undefined;
9624
- if (timescaleAndDuration) {
9625
- const {
9626
- timescale,
9627
- duration
9628
- } = timescaleAndDuration;
9629
- if (duration > 0) {
9630
- metadata.duration = duration / timescale;
9631
- } else {
9632
- // A fragmented file (CMAF) says 0 in mvhd and puts the length in mehd,
9633
- // in the same timescale.
9634
- const mehd = childBox(childBox(moov, "mvex"), "mehd");
9635
- const fragmentDuration = mehd ? readMehd(mehd) : undefined;
9636
- if (fragmentDuration !== undefined && fragmentDuration > 0) {
9637
- metadata.duration = fragmentDuration / timescale;
9638
- }
9639
- }
9640
- }
9641
- for (const trak of childBoxes(moov, "trak")) {
9642
- const hdlr = childBox(childBox(trak, "mdia"), "hdlr");
9643
- if (!hdlr || hdlr.length < 12 || hdlr.toString("latin1", 8, 12) !== "vide") {
9644
- continue;
9645
- }
9646
- const tkhd = childBox(trak, "tkhd");
9647
- const size = tkhd ? readTkhd(tkhd) : undefined;
9648
- if (size && size.width > 0 && size.height > 0) {
9649
- metadata.width = size.width;
9650
- metadata.height = size.height;
9651
- break;
9652
- }
9653
- }
9654
- return metadata;
9655
- }
9656
-
9657
- /** The payload (after the header) of the first top-level box of a type. */
9658
- function findTopLevelBox(source, type) {
9659
- let offset = 0;
9660
- while (offset + 8 <= source.size) {
9661
- const header = source.read(offset, 16);
9662
- if (header.length < 8) {
9663
- return undefined;
9664
- }
9665
- let size = header.readUInt32BE(0);
9666
- const boxType = header.toString("latin1", 4, 8);
9667
- let headerSize = 8;
9668
- if (size === 1) {
9669
- if (header.length < 16) {
9670
- return undefined;
9671
- }
9672
- size = Number(header.readBigUInt64BE(8));
9673
- headerSize = 16;
9674
- } else if (size === 0) {
9675
- size = source.size - offset;
9676
- }
9677
- if (size < headerSize) {
9678
- return undefined;
9679
- }
9680
- if (boxType === type) {
9681
- if (size - headerSize > MAX_MOOV_SIZE) {
9682
- return undefined;
9683
- }
9684
- const payload = source.read(offset + headerSize, size - headerSize);
9685
- return payload.length === size - headerSize ? payload : undefined;
9686
- }
9687
- offset += size;
9688
- }
9689
- return undefined;
9690
- }
9691
-
9692
- /** Each child box of a container payload, as `[type, payload]`. */
9693
- function* boxesIn(payload) {
9694
- let offset = 0;
9695
- while (offset + 8 <= payload.length) {
9696
- let size = payload.readUInt32BE(offset);
9697
- const type = payload.toString("latin1", offset + 4, offset + 8);
9698
- let headerSize = 8;
9699
- if (size === 1) {
9700
- if (offset + 16 > payload.length) {
9701
- return;
9702
- }
9703
- size = Number(payload.readBigUInt64BE(offset + 8));
9704
- headerSize = 16;
9705
- } else if (size === 0) {
9706
- size = payload.length - offset;
9707
- }
9708
- if (size < headerSize || offset + size > payload.length) {
9709
- return;
9710
- }
9711
- yield [type, payload.subarray(offset + headerSize, offset + size)];
9712
- offset += size;
9713
- }
9714
- }
9715
- function childBoxes(payload, type) {
9716
- if (!payload) {
9717
- return [];
9718
- }
9719
- const found = [];
9720
- for (const [boxType, child] of boxesIn(payload)) {
9721
- if (boxType === type) {
9722
- found.push(child);
9723
- }
9724
- }
9725
- return found;
9726
- }
9727
- function childBox(payload, type) {
9728
- return childBoxes(payload, type)[0];
9729
- }
9730
-
9731
- /** "Unknown" in a version 1 box. Not a literal: `0x…n` needs an ES2020 target. */
9732
- const ALL_ONES_64 = BigInt("0xffffffffffffffff");
9733
- function readMvhd(mvhd) {
9734
- const version = mvhd[0];
9735
- if (version === 1) {
9736
- if (mvhd.length < 32) return undefined;
9737
- const timescale = mvhd.readUInt32BE(20);
9738
- const duration = mvhd.readBigUInt64BE(24);
9739
- if (timescale === 0 || duration === ALL_ONES_64) {
9740
- return undefined;
9741
- }
9742
- return {
9743
- timescale,
9744
- duration: Number(duration)
9745
- };
9746
- }
9747
- if (mvhd.length < 20) return undefined;
9748
- const timescale = mvhd.readUInt32BE(12);
9749
- const duration = mvhd.readUInt32BE(16);
9750
- // All ones means "unknown" in a version 0 box.
9751
- if (timescale === 0 || duration === 0xffffffff) {
9752
- return undefined;
9753
- }
9754
- return {
9755
- timescale,
9756
- duration
9757
- };
9758
- }
9759
- function readMehd(mehd) {
9760
- if (mehd[0] === 1) {
9761
- return mehd.length >= 12 ? Number(mehd.readBigUInt64BE(4)) : undefined;
9762
- }
9763
- return mehd.length >= 8 ? mehd.readUInt32BE(4) : undefined;
9764
- }
9765
-
9766
- /**
9767
- * The size a track is SHOWN at: tkhd's width and height, swapped when the
9768
- * display matrix turns the picture a quarter turn — a phone records upright
9769
- * video sideways and says so here, and a page lays it out as it is shown.
9770
- */
9771
- function readTkhd(tkhd) {
9772
- // Past the version-specific times and ids, then 16 bytes of reserved,
9773
- // layer, alternate group, volume and reserved, then the 36-byte matrix.
9774
- const matrixAt = tkhd[0] === 1 ? 4 + 32 + 16 : 4 + 20 + 16;
9775
- const widthAt = matrixAt + 36;
9776
- if (tkhd.length < widthAt + 8) {
9777
- return undefined;
9778
- }
9779
- const a = tkhd.readInt32BE(matrixAt);
9780
- const b = tkhd.readInt32BE(matrixAt + 4);
9781
- const c = tkhd.readInt32BE(matrixAt + 12);
9782
- const d = tkhd.readInt32BE(matrixAt + 16);
9783
- const width = Math.round(tkhd.readUInt32BE(widthAt) / 0x10000);
9784
- const height = Math.round(tkhd.readUInt32BE(widthAt + 4) / 0x10000);
9785
- const quarterTurn = a === 0 && d === 0 && b !== 0 && c !== 0;
9786
- return quarterTurn ? {
9787
- width: height,
9788
- height: width
9789
- } : {
9790
- width,
9791
- height
9792
- };
9793
- }
9794
-
9795
- /**
9796
- * The size and length of a Matroska file (`.webm`, and `.mkv`, which is the
9797
- * same container), read from its headers. Nothing is decoded.
9798
- *
9799
- * Hand-written for the same reason `isoBmff.ts` is: `@valbuild/server` is
9800
- * loaded by every app that runs Val, and what is wanted here is a handful of
9801
- * integers two levels into the file:
9802
- *
9803
- * EBML the header; a file without one is not Matroska
9804
- * Segment > Info TimecodeScale and Duration
9805
- * Segment > Tracks > TrackEntry TrackType 1 (video) >
9806
- * Video PixelWidth / PixelHeight, and DisplayWidth /
9807
- * DisplayHeight when they are in pixels
9808
- *
9809
- * Matroska is EBML: every element is a variable-length ID, a variable-length
9810
- * size and a payload. Only element HEADERS are read on the way through the
9811
- * Segment, and then the payloads of Info and Tracks, which are a few hundred
9812
- * bytes. The Clusters — the frames, which are the file — are never read: the
9813
- * walk stops at the first one.
9814
- *
9815
- * That is allowed because the spec (RFC 9559, section 6.3) requires the first
9816
- * Info and Tracks to come before the first Cluster, or to be named by a
9817
- * SeekHead that does. A file written by a browser's MediaRecorder, or by
9818
- * anything else that streams, does not know its own length when it starts:
9819
- * its Segment and Clusters have the "unknown" size, and its Info has no
9820
- * Duration. The Segment is then read as running to the end of the file, and
9821
- * the duration is left out rather than counted from the Clusters — a caller
9822
- * reports it, as it does for a fragmented mp4.
9520
+ * Matroska is EBML: every element is a variable-length ID, a variable-length
9521
+ * size and a payload. Only element HEADERS are read on the way through the
9522
+ * Segment, and then the payloads of Info and Tracks, which are a few hundred
9523
+ * bytes. The Clusters — the frames, which are the file — are never read: the
9524
+ * walk stops at the first one.
9525
+ *
9526
+ * That is allowed because the spec (RFC 9559, section 6.3) requires the first
9527
+ * Info and Tracks to come before the first Cluster, or to be named by a
9528
+ * SeekHead that does. A file written by a browser's MediaRecorder, or by
9529
+ * anything else that streams, does not know its own length when it starts:
9530
+ * its Segment and Clusters have the "unknown" size, and its Info has no
9531
+ * Duration. The Segment is then read as running to the end of the file, and
9532
+ * the duration is left out rather than counted from the Clusters — a caller
9533
+ * reports it, as it does for a fragmented mp4.
9823
9534
  */
9824
9535
 
9825
9536
  const EBML_HEADER = 0x1a45dfa3;
@@ -10544,7 +10255,18 @@ function getValidationErrorFileRef(validationError) {
10544
10255
  }
10545
10256
 
10546
10257
  const textEncoder$1 = new TextEncoder();
10547
- async function checkRemoteRef(remoteHost, ref, projectRoot, schema, metadata) {
10258
+ async function checkRemoteRef(
10259
+ /**
10260
+ * Unused: a re-derived ref keeps the host it already names (see below).
10261
+ * Kept so the exported signature does not change.
10262
+ */
10263
+ _remoteHost, ref, projectRoot, schema, metadata,
10264
+ /**
10265
+ * Where the bytes come from when the ref has to be re-derived. Defaults to
10266
+ * a download cached under `projectRoot`; the Studio passes one that keeps
10267
+ * nothing on disk (`FixFiles.readRemoteFile`).
10268
+ */
10269
+ readRemote = (ref, fileExt, fileHash) => getFileBufferFromRemote(ref, fileExt, fileHash, projectRoot)) {
10548
10270
  const remoteRefDataRes = core.Internal.remote.splitRemoteRef(ref);
10549
10271
  if (remoteRefDataRes.status === "error") {
10550
10272
  return remoteRefDataRes;
@@ -10589,7 +10311,7 @@ async function checkRemoteRef(remoteHost, ref, projectRoot, schema, metadata) {
10589
10311
  };
10590
10312
  }
10591
10313
  // Validation hash does not match, so we need to re-validate the remote content
10592
- const fileBufferRes = await getFileBufferFromRemote(ref, fileExt, remoteRefDataRes.fileHash, projectRoot);
10314
+ const fileBufferRes = await readRemote(ref, fileExt, remoteRefDataRes.fileHash);
10593
10315
 
10594
10316
  // We could not download the remote file for some reason (for example, the file hash is wrong)
10595
10317
  if (fileBufferRes.status === "error") {
@@ -10615,7 +10337,7 @@ async function checkRemoteRef(remoteHost, ref, projectRoot, schema, metadata) {
10615
10337
  } catch (err) {
10616
10338
  return {
10617
10339
  status: "error",
10618
- error: `Failed to extract metadata from remote file: ${err instanceof Error ? err.message : "Unknown error"}. Cached file: ${fileBufferRes.cachedFilePath}`
10340
+ error: `Failed to extract metadata from remote file: ${err instanceof Error ? err.message : "Unknown error"}${fileBufferRes.cachedFilePath ? `. Cached file: ${fileBufferRes.cachedFilePath}` : ""}`
10619
10341
  };
10620
10342
  }
10621
10343
  const updatedMetadata = {
@@ -10634,7 +10356,11 @@ async function checkRemoteRef(remoteHost, ref, projectRoot, schema, metadata) {
10634
10356
  return {
10635
10357
  status: "fix-required",
10636
10358
  metadata: updatedMetadata,
10637
- ref: core.Internal.remote.createRemoteRef(remoteHost, {
10359
+ // On the host the ref was already on. Re-deriving a ref changes what it
10360
+ // says about the file, never where it is served from: a Hosted project's
10361
+ // file is in R2 and served only at its own address, and moving a ref to
10362
+ // another host is a fix of its own (`remote:rewrite-host`).
10363
+ ref: core.Internal.remote.createRemoteRef(remoteRefDataRes.remoteHost, {
10638
10364
  publicProjectId: remoteRefDataRes.projectId,
10639
10365
  coreVersion,
10640
10366
  bucket: remoteRefDataRes.bucket,
@@ -10653,6 +10379,12 @@ function getCachedRemoteFilePath(fileExt, currentFileHash, remoteFileCacheDir) {
10653
10379
  const remoteFilePath = path__default["default"].join(remoteFileCacheDir, currentFileHash + "." + fileExt);
10654
10380
  return remoteFilePath;
10655
10381
  }
10382
+
10383
+ /**
10384
+ * A remote file's bytes, and the hash they actually have — which is the one a
10385
+ * re-derived ref is made from, whatever hash the old ref claimed.
10386
+ */
10387
+
10656
10388
  async function getFileBufferFromRemote(ref, fileExt, currentFileHash, projectRoot) {
10657
10389
  const remoteFileCacheDir = getCachedRemoteFileDir(projectRoot);
10658
10390
  const remoteFilePath = getCachedRemoteFilePath(fileExt, currentFileHash, remoteFileCacheDir);
@@ -10691,14 +10423,55 @@ async function getFileBufferFromRemote(ref, fileExt, currentFileHash, projectRoo
10691
10423
  cachedFilePath: remoteFilePath
10692
10424
  };
10693
10425
  }
10426
+
10427
+ /**
10428
+ * Download a remote file to `filePath`, from where its ref says -- and, for a
10429
+ * platform ref whose host fails, from the same file on the project's own
10430
+ * `valstart.dev` address, which always serves it (`fallbackRefOf`). The bytes
10431
+ * from the fallback are checked against the ref's file hash before they are
10432
+ * kept: a different host answering is only safe because of that check.
10433
+ *
10434
+ * Rejects with the FIRST host's error when both fail: that is the one the ref
10435
+ * names, and the one a person can recognise.
10436
+ */
10694
10437
  async function downloadFileFromRemote(ref, filePath) {
10695
- return new Promise((resolve, reject) => {
10696
- const url = new URL(ref);
10438
+ try {
10439
+ return await downloadFromUrl(ref, filePath);
10440
+ } catch (err) {
10441
+ const fallback = ValRouterCore.fallbackRefOf(ref);
10442
+ const parsed = core.Internal.remote.splitRemoteRef(ref);
10443
+ if (fallback === null || parsed.status === "error") {
10444
+ throw err;
10445
+ }
10446
+ try {
10447
+ const res = await downloadFromUrl(fallback, filePath);
10448
+ if (res.status === "error") {
10449
+ throw err;
10450
+ }
10451
+ } catch {
10452
+ throw err;
10453
+ }
10454
+ const bytes = await fs__default["default"].promises.readFile(filePath);
10455
+ if (core.Internal.remote.getFileHash(bytes) !== parsed.fileHash) {
10456
+ await fs__default["default"].promises.unlink(filePath).catch(() => undefined);
10457
+ throw new Error(`${ref}. Its host failed, and the file at ${fallback} is not the one the ref names.`, {
10458
+ cause: err
10459
+ });
10460
+ }
10461
+ return {
10462
+ status: "success",
10463
+ filePath
10464
+ };
10465
+ }
10466
+ }
10467
+ async function downloadFromUrl(ref, filePath) {
10468
+ return new Promise((resolve, reject) => {
10469
+ const url = new URL(ref);
10697
10470
  const client = url.protocol === "https:" ? https__default$1["default"] : http__default$1["default"];
10698
10471
  const request = client.get(url, response => {
10699
10472
  if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
10700
10473
  // Handle redirects
10701
- return downloadFileFromRemote(response.headers.location, filePath).then(resolve).catch(reject);
10474
+ return downloadFromUrl(response.headers.location, filePath).then(resolve).catch(reject);
10702
10475
  }
10703
10476
  if (response.statusCode == 404) {
10704
10477
  return reject(new Error(`${ref}. File not found. The URL is most likely incorrect.`));
@@ -10719,6 +10492,45 @@ async function downloadFileFromRemote(ref, filePath) {
10719
10492
  });
10720
10493
  }
10721
10494
 
10495
+ /**
10496
+ * Where `createFixPatch` reads a project file's bytes, and where it puts a
10497
+ * remote file it was asked to download.
10498
+ *
10499
+ * Every path is the file's path from the project root, the way a source names
10500
+ * it: `/public/val/logo.png`, never an absolute path on some disk. That is the
10501
+ * point: a project in http mode, or one the platform serves from a Worker, has
10502
+ * no disk to join a path onto, and a fix that reads a file has to work there
10503
+ * too. `diskFixFiles` is the one the CLI and the language server use, and does
10504
+ * exactly what `createFixPatch` did with `fs` before this existed.
10505
+ *
10506
+ * Not yet covered, and still read from `projectRoot` directly: the CLI's
10507
+ * listing of a media directory for files it does not track, and
10508
+ * `videos:check-all-files` (`videosetCheckAllFilesPatch`), which lists one.
10509
+ * See `docs/plans/studio-validate.md`.
10510
+ */
10511
+
10512
+ /** {@link FixFiles} over a project on disk. */
10513
+ function diskFixFiles(projectRoot) {
10514
+ const absolute = filePath => path__default["default"].join(projectRoot, filePath);
10515
+ return {
10516
+ readFile: filePath => fs__default["default"].promises.readFile(absolute(filePath)),
10517
+ readVideoMetadata: filePath => extractVideoMetadataFromFile(absolute(filePath)),
10518
+ // Kept under `.val/remote-file-cache`, so the next run does not download
10519
+ // it again.
10520
+ readRemoteFile: (ref, fileExt, fileHash) => getFileBufferFromRemote(ref, fileExt, fileHash, projectRoot),
10521
+ saveRemoteFile: async (url, filePath) => {
10522
+ const target = absolute(filePath);
10523
+ await fs__default["default"].promises.mkdir(path__default["default"].dirname(target), {
10524
+ recursive: true
10525
+ });
10526
+ const res = await downloadFileFromRemote(url, target);
10527
+ return res.status === "error" ? res : {
10528
+ status: "success"
10529
+ };
10530
+ }
10531
+ };
10532
+ }
10533
+
10722
10534
  const textEncoder = new TextEncoder();
10723
10535
 
10724
10536
  /**
@@ -10755,6 +10567,7 @@ async function openRemoteUploadSession(ctx) {
10755
10567
  let publicProjectId = ctx.publicProjectId;
10756
10568
  let remoteFileBuckets = ctx.remoteFileBuckets;
10757
10569
  let remoteFilesCounter = ctx.remoteFilesCounter;
10570
+ let remoteHost = ctx.remote.remoteHost;
10758
10571
  if (!publicProjectId || !remoteFileBuckets) {
10759
10572
  if (!projectName) {
10760
10573
  return fail("Project name not found. Add project name to val.config or set the VAL_PROJECT environment variable");
@@ -10767,6 +10580,7 @@ async function openRemoteUploadSession(ctx) {
10767
10580
  }
10768
10581
  publicProjectId = settingsRes.data.publicProjectId;
10769
10582
  remoteFileBuckets = settingsRes.data.remoteFileBuckets.map(b => b.bucket);
10583
+ remoteHost = settingsRes.data.remoteHost ?? remoteHost;
10770
10584
  }
10771
10585
  if (!publicProjectId) {
10772
10586
  return fail("Could not get public project id");
@@ -10787,7 +10601,8 @@ async function openRemoteUploadSession(ctx) {
10787
10601
  publicProjectId,
10788
10602
  bucket,
10789
10603
  remoteFileBuckets,
10790
- remoteFilesCounter
10604
+ remoteFilesCounter,
10605
+ remoteHost
10791
10606
  }
10792
10607
  };
10793
10608
  }
@@ -10809,7 +10624,7 @@ async function uploadBytesToRemote(ctx, session, relativeFilePath, fileBuffer, m
10809
10624
  const fileHash = core.Internal.remote.getFileHash(fileBuffer);
10810
10625
  const coreVersion = core.Internal.VERSION.core || "unknown";
10811
10626
  const fileExt = ValRouterCore.getFileExt(relativeFilePath);
10812
- const ref = core.Internal.remote.createRemoteRef(ctx.remote.remoteHost, {
10627
+ const ref = core.Internal.remote.createRemoteRef(session.remoteHost, {
10813
10628
  publicProjectId: session.publicProjectId,
10814
10629
  coreVersion,
10815
10630
  bucket: session.bucket,
@@ -12327,7 +12142,7 @@ async function videosetCheckAllFilesPatch({
12327
12142
  * `alt` and anything already there are someone's, and stay.
12328
12143
  */
12329
12144
  async function videosetAddMetadataPatch({
12330
- projectRoot,
12145
+ files,
12331
12146
  sourcePath,
12332
12147
  validationError,
12333
12148
  moduleSource,
@@ -12361,7 +12176,7 @@ async function videosetAddMetadataPatch({
12361
12176
  }
12362
12177
  let metadata;
12363
12178
  try {
12364
- metadata = galleryEntryOf(key).remote ? await extractVideoMetadataFromUrl(key, nameForTypeOf(key)) : await extractVideoMetadataFromFile(path__default["default"].join(projectRoot, key));
12179
+ metadata = galleryEntryOf(key).remote ? await extractVideoMetadataFromUrl(key, nameForTypeOf(key)) : await files.readVideoMetadata(key);
12365
12180
  } catch (err) {
12366
12181
  return fail(`Failed to read video metadata from ${key}: ${err instanceof Error ? err.message : String(err)}`);
12367
12182
  }
@@ -12459,9 +12274,10 @@ function mediaValueOf(value) {
12459
12274
  async function createFixPatch(config, apply, sourcePath, validationError, remoteFiles, moduleSource, moduleSchema) {
12460
12275
  const remainingErrors = [];
12461
12276
  const patch$1 = [];
12277
+ const files = config.files ?? diskFixFiles(config.projectRoot);
12462
12278
  for (const fix of validationError.fixes || []) {
12463
12279
  if (fix === "image:check-metadata" || fix === "image:add-metadata") {
12464
- const imageMetadata = await getImageMetadata(config.projectRoot, validationError);
12280
+ const imageMetadata = await getImageMetadata(files, validationError);
12465
12281
  if (imageMetadata.width === undefined || imageMetadata.height === undefined) {
12466
12282
  remainingErrors.push({
12467
12283
  ...validationError,
@@ -12528,7 +12344,7 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
12528
12344
  }
12529
12345
  }
12530
12346
  } else if (fix === "file:add-metadata" || fix === "file:check-metadata") {
12531
- const fileMetadata = await getFileMetadata(config.projectRoot, validationError);
12347
+ const fileMetadata = await getFileMetadata(files, validationError);
12532
12348
  if (fileMetadata === undefined) {
12533
12349
  remainingErrors.push({
12534
12350
  ...validationError,
@@ -12589,7 +12405,7 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
12589
12405
  }
12590
12406
  let metadata;
12591
12407
  try {
12592
- metadata = await getVideoMetadata(config.projectRoot, fileRef);
12408
+ metadata = await getVideoMetadata(files, fileRef);
12593
12409
  } catch (err) {
12594
12410
  remainingErrors.push({
12595
12411
  ...validationError,
@@ -12625,7 +12441,7 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
12625
12441
  // A set entry's twin of `video:add-metadata`: the file is the entry's
12626
12442
  // KEY, and only what is missing is written.
12627
12443
  const fixed = await videosetAddMetadataPatch({
12628
- projectRoot: config.projectRoot,
12444
+ files,
12629
12445
  sourcePath,
12630
12446
  validationError,
12631
12447
  moduleSource,
@@ -12649,9 +12465,9 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
12649
12465
  let metadata = remoteFile.metadata;
12650
12466
  if (!metadata) {
12651
12467
  if (fix === "image:upload-remote") {
12652
- metadata = await getImageMetadata(config.projectRoot, validationError);
12468
+ metadata = await getImageMetadata(files, validationError);
12653
12469
  } else if (fix === "file:upload-remote") {
12654
- metadata = await getFileMetadata(config.projectRoot, validationError);
12470
+ metadata = await getFileMetadata(files, validationError);
12655
12471
  }
12656
12472
  }
12657
12473
  if (!metadata) {
@@ -12785,11 +12601,7 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
12785
12601
  });
12786
12602
  continue;
12787
12603
  }
12788
- const absoluteFilePath = path__default["default"].join(config.projectRoot, splitRemoteRefDataRes.filePath);
12789
- await fs__default["default"].promises.mkdir(path__default["default"].dirname(absoluteFilePath), {
12790
- recursive: true
12791
- });
12792
- const res = await downloadFileFromRemote(url, absoluteFilePath);
12604
+ const res = await files.saveRemoteFile(url, filePath);
12793
12605
  if (res.status === "error") {
12794
12606
  remainingErrors.push({
12795
12607
  ...validationError,
@@ -12825,10 +12637,9 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
12825
12637
  // image as unreadable, and with `--fix` REMOVED it from the gallery.
12826
12638
  continue;
12827
12639
  }
12828
- const filename = path__default["default"].join(config.projectRoot, entry.localPath);
12829
12640
  let buffer;
12830
12641
  try {
12831
- buffer = fs__default["default"].readFileSync(filename);
12642
+ buffer = await files.readFile(entry.localPath);
12832
12643
  } catch {
12833
12644
  if (apply) {
12834
12645
  const removePath = patch.sourceToPatchPath(sourcePath).concat([entryKey]);
@@ -12841,16 +12652,16 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
12841
12652
  } else {
12842
12653
  remainingErrors.push({
12843
12654
  ...validationError,
12844
- message: `Could not read file: ${filename} - file might not exist or can not be accessed`,
12655
+ message: `Could not read file: ${path__default["default"].join(config.projectRoot, entry.localPath)} - file might not exist or can not be accessed`,
12845
12656
  fixes: undefined
12846
12657
  });
12847
12658
  }
12848
12659
  continue;
12849
12660
  }
12850
12661
  if (fix === "images:check-all-files") {
12851
- const actualMetadata = await extractImageMetadata(filename, buffer);
12662
+ const actualMetadata = await extractImageMetadata(entry.localPath, buffer);
12852
12663
  const stored = storedEntry;
12853
- const metadataIsCorrect = stored.width === actualMetadata.width && stored.height === actualMetadata.height && stored.mimeType === actualMetadata.mimeType;
12664
+ const metadataIsCorrect = mediaDirEntryMismatches("images", stored, actualMetadata).length === 0;
12854
12665
  if (!metadataIsCorrect) {
12855
12666
  if (apply) {
12856
12667
  patch$1.push({
@@ -12875,9 +12686,9 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
12875
12686
  }
12876
12687
  }
12877
12688
  } else if (fix === "files:check-all-files") {
12878
- const actualMetadata = await extractFileMetadata(filename);
12689
+ const actualMetadata = await extractFileMetadata(entry.localPath);
12879
12690
  const stored = storedEntry;
12880
- const metadataIsCorrect = stored.mimeType === actualMetadata.mimeType;
12691
+ const metadataIsCorrect = mediaDirEntryMismatches("files", stored, actualMetadata).length === 0;
12881
12692
  if (!metadataIsCorrect) {
12882
12693
  if (apply) {
12883
12694
  patch$1.push({
@@ -12935,7 +12746,27 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
12935
12746
  schema: schemaAtPath
12936
12747
  } = core.Internal.resolvePath(modulePath, moduleSource, moduleSchema);
12937
12748
  if (schemaAtPath.type === "image" || schemaAtPath.type === "file") {
12938
- const res = await checkRemoteRef(config.remoteHost, v.path, config.projectRoot, schemaAtPath, v.metadata);
12749
+ // First, whether the ref's host still serves its project at all: a
12750
+ // ref on a domain the project dropped is pointed at the project's own
12751
+ // address, not re-derived from bytes nobody can download from there.
12752
+ const moved = await ValRouterCore.movedRemoteHost(v.path, config.askHostProject);
12753
+ if (moved) {
12754
+ if (apply) {
12755
+ patch$1.push({
12756
+ op: "replace",
12757
+ path: patch.sourceToPatchPath(sourcePath).concat(["path"]),
12758
+ value: moved.ref
12759
+ });
12760
+ } else {
12761
+ remainingErrors.push({
12762
+ ...validationError,
12763
+ message: `${moved.why}. Run 'val validate --fix' to point it at ${moved.to}, which always serves it.`,
12764
+ fixes: ["remote:rewrite-host"]
12765
+ });
12766
+ }
12767
+ continue;
12768
+ }
12769
+ const res = await checkRemoteRef(config.remoteHost, v.path, config.projectRoot, schemaAtPath, v.metadata, files.readRemoteFile);
12939
12770
  if (res.status === "success") ; else if (res.status === "error") {
12940
12771
  remainingErrors.push({
12941
12772
  ...validationError,
@@ -12964,162 +12795,966 @@ async function createFixPatch(config, apply, sourcePath, validationError, remote
12964
12795
  fixes: undefined
12965
12796
  });
12966
12797
  }
12967
- } else {
12968
- remainingErrors.push({
12969
- ...validationError,
12970
- message: "Could not check remote ref: schema type is not image or file: " + (schemaAtPath === null || schemaAtPath === void 0 ? void 0 : schemaAtPath.type),
12971
- fixes: undefined
12972
- });
12798
+ } else {
12799
+ remainingErrors.push({
12800
+ ...validationError,
12801
+ message: "Could not check remote ref: schema type is not image or file: " + (schemaAtPath === null || schemaAtPath === void 0 ? void 0 : schemaAtPath.type),
12802
+ fixes: undefined
12803
+ });
12804
+ }
12805
+ } else if (fix === "remote:rewrite-host") {
12806
+ // The new ref is derived from the old one alone, so nothing the error
12807
+ // carries is trusted: the value is read from the module, and only a
12808
+ // platform ref that is not already on the project's own address moves.
12809
+ const [, modulePath] = core.Internal.splitModuleFilePathAndModulePath(sourcePath);
12810
+ const current = moduleSource !== undefined && moduleSchema !== undefined ? core.Internal.resolvePath(modulePath, moduleSource, moduleSchema).source : undefined;
12811
+ const ref = typeof current === "object" && current !== null && "path" in current && typeof current.path === "string" ? current.path : undefined;
12812
+ const parsed = ref === undefined ? undefined : core.Internal.remote.splitRemoteRef(ref);
12813
+ const rewritten = ref !== undefined && (parsed === null || parsed === void 0 ? void 0 : parsed.status) === "success" && ValRouterCore.needsHostCheck(parsed.remoteHost, parsed.projectId) ? ValRouterCore.rewriteRefHost(ref, ValRouterCore.fallbackRemoteHost(parsed.projectId)) : null;
12814
+ if (rewritten === null) {
12815
+ remainingErrors.push({
12816
+ ...validationError,
12817
+ message: "Cannot point this at the project's own address: it is not a platform ref on another host",
12818
+ fixes: undefined
12819
+ });
12820
+ continue;
12821
+ }
12822
+ patch$1.push({
12823
+ op: "replace",
12824
+ path: patch.sourceToPatchPath(sourcePath).concat(["path"]),
12825
+ value: rewritten
12826
+ });
12827
+ } else if (fix === "view:check-module") {
12828
+ // The schema is the authority: it names the module, and there is exactly
12829
+ // one valid pointer. So this is written rather than reported — nobody has
12830
+ // a decision to make here.
12831
+ const [, modulePath] = core.Internal.splitModuleFilePathAndModulePath(sourcePath);
12832
+ if (moduleSource === undefined || moduleSchema === undefined) {
12833
+ remainingErrors.push({
12834
+ ...validationError,
12835
+ message: "Unexpected error while checking a view (no module source or schema)",
12836
+ fixes: undefined
12837
+ });
12838
+ continue;
12839
+ }
12840
+ const {
12841
+ schema: schemaAtPath
12842
+ } = core.Internal.resolvePath(modulePath, moduleSource, moduleSchema);
12843
+ if (schemaAtPath.type !== "view") {
12844
+ remainingErrors.push({
12845
+ ...validationError,
12846
+ message: `Could not fix view: schema at ${sourcePath} is '${schemaAtPath.type}', not a view`,
12847
+ fixes: undefined
12848
+ });
12849
+ continue;
12850
+ }
12851
+ if (apply) {
12852
+ patch$1.push({
12853
+ op: "replace",
12854
+ path: core.Internal.createPatchPath(modulePath),
12855
+ value: {
12856
+ view: schemaAtPath.moduleFilePath
12857
+ }
12858
+ });
12859
+ } else {
12860
+ remainingErrors.push({
12861
+ ...validationError,
12862
+ message: `This view points at the wrong module. Expected '${schemaAtPath.moduleFilePath}'.`
12863
+ });
12864
+ }
12865
+ }
12866
+ }
12867
+ if (!validationError.fixes || validationError.fixes.length === 0) {
12868
+ remainingErrors.push(validationError);
12869
+ }
12870
+ return {
12871
+ patch: patch$1,
12872
+ remainingErrors
12873
+ };
12874
+ }
12875
+ function getRemoteValueFromValidationError(v) {
12876
+ if (v.value && typeof v.value !== "object") {
12877
+ return {
12878
+ success: false,
12879
+ message: "Unexpected error while checking remote (not an object)"
12880
+ };
12881
+ }
12882
+ if (!v.value) {
12883
+ return {
12884
+ success: false,
12885
+ message: "Unexpected error while checking remote (no value)"
12886
+ };
12887
+ }
12888
+ if (typeof v.value !== "object" || v.value === null || !("path" in v.value)) {
12889
+ return {
12890
+ success: false,
12891
+ message: "Unexpected error while checking remote (no path in value)"
12892
+ };
12893
+ }
12894
+ if (typeof v.value.path !== "string") {
12895
+ return {
12896
+ success: false,
12897
+ message: "Unexpected error while checking remote (path is not a string)"
12898
+ };
12899
+ }
12900
+ // Everything but the path is what Val read from the bytes.
12901
+ const {
12902
+ path,
12903
+ ...metadata
12904
+ } = v.value;
12905
+ return {
12906
+ success: true,
12907
+ path,
12908
+ metadata
12909
+ };
12910
+ }
12911
+
12912
+ /** The fields of a video that are read from its bytes, in the order written. */
12913
+ const VIDEO_METADATA_FIELDS = ["mimeType", "width", "height", "duration"];
12914
+
12915
+ /**
12916
+ * The video a `video:add-metadata` error is about, read from the module at its
12917
+ * path when the module is at hand — the schema there decides that it IS a
12918
+ * video — and from the error's own value otherwise.
12919
+ */
12920
+ function currentVideoValue(sourcePath, validationError, moduleSource, moduleSchema) {
12921
+ if (moduleSource !== undefined && moduleSchema !== undefined) {
12922
+ const [, modulePath] = core.Internal.splitModuleFilePathAndModulePath(sourcePath);
12923
+ const resolved = core.Internal.resolvePath(modulePath, moduleSource, moduleSchema);
12924
+ if (resolved.schema.type !== "video") {
12925
+ return {
12926
+ status: "error",
12927
+ message: `Could not add video metadata: schema at ${sourcePath} is '${resolved.schema.type}', not a video`
12928
+ };
12929
+ }
12930
+ const value = mediaValueOf(resolved.source);
12931
+ return value ? {
12932
+ status: "ok",
12933
+ value
12934
+ } : {
12935
+ status: "error",
12936
+ message: "Video is not an object!"
12937
+ };
12938
+ }
12939
+ const value = mediaValueOf(validationError.value);
12940
+ return value ? {
12941
+ status: "ok",
12942
+ value
12943
+ } : {
12944
+ status: "error",
12945
+ message: "Video is not an object!"
12946
+ };
12947
+ }
12948
+ async function getVideoMetadata(files, fileRef) {
12949
+ // Not read into memory: only the headers are, and a video is mostly not
12950
+ // headers. A remote video's headers are read over HTTP, with Range
12951
+ // requests, so it is not downloaded either.
12952
+ if (core.Internal.isRemoteMediaPath(fileRef)) {
12953
+ return extractVideoMetadataFromUrl(fileRef, nameForTypeOf(fileRef));
12954
+ }
12955
+ return files.readVideoMetadata(fileRef);
12956
+ }
12957
+ async function getImageMetadata(files, validationError) {
12958
+ const fileRef = getValidationErrorFileRef(validationError);
12959
+ if (!fileRef) {
12960
+ // TODO:
12961
+ throw Error("Cannot fix image without a file reference");
12962
+ }
12963
+ return extractImageMetadata(fileRef, await files.readFile(fileRef));
12964
+ }
12965
+ async function getFileMetadata(files, validationError) {
12966
+ const fileRef = getValidationErrorFileRef(validationError);
12967
+ if (!fileRef) {
12968
+ // TODO:
12969
+ throw Error("Cannot fix file without a file reference");
12970
+ }
12971
+ return extractFileMetadata(fileRef, await files.readFile(fileRef));
12972
+ }
12973
+
12974
+ /**
12975
+ * What an image or file directory's entry records that its bytes disagree
12976
+ * with, as `[field, recorded, actual]`. The one comparison: the CLI's
12977
+ * `*:check-all-files` fix and the Studio's full check both ask it.
12978
+ */
12979
+ function mediaDirEntryMismatches(type, stored, actual) {
12980
+ const fields = type === "images" ? ["width", "height", "mimeType"] : ["mimeType"];
12981
+ return fields.filter(field => stored[field] !== actual[field]).map(field => [field, stored[field], actual[field]]);
12982
+ }
12983
+
12984
+ /**
12985
+ * The Studio's full check, one module at a time: what `/validate` and
12986
+ * `/validate/fix` answer with. See `docs/plans/studio-validate.md`.
12987
+ *
12988
+ * Both take the sources the caller is looking at — base plus the patches it
12989
+ * names — so an error is about the content on the editor's screen, and a fix
12990
+ * is a patch on top of it.
12991
+ */
12992
+
12993
+ /**
12994
+ * Every error in one module: its sources against its schema, and its local
12995
+ * files against their bytes.
12996
+ *
12997
+ * Not filtered the way the publish gate filters (`partitionValidationErrors`):
12998
+ * the errors the gate hides because "the server repairs them on save" are the
12999
+ * ones this check exists to show, since nothing repairs them in http mode.
13000
+ *
13001
+ * Remote files are checked too: a stale ref is an error with a fix, and a
13002
+ * host that does not serve them is reported once, as a host, rather than as
13003
+ * an error on every image it holds (`checkRemoteFiles`).
13004
+ *
13005
+ * Not yet here: the media directories' own entries, step 5 of the plan.
13006
+ */
13007
+ async function checkModuleForStudio(ops, input) {
13008
+ const {
13009
+ moduleFilePath,
13010
+ schemas,
13011
+ sources
13012
+ } = input;
13013
+ if (!schemas[moduleFilePath]) {
13014
+ return {
13015
+ status: "unknown-module"
13016
+ };
13017
+ }
13018
+ const validation = await ops.validateSources(schemas, sources, undefined, [moduleFilePath]);
13019
+ const moduleErrors = validation.errors[moduleFilePath];
13020
+ if (moduleErrors !== null && moduleErrors !== void 0 && moduleErrors.invalidSource) {
13021
+ return {
13022
+ status: "invalid-source",
13023
+ message: moduleErrors.invalidSource.message
13024
+ };
13025
+ }
13026
+ const errors = {};
13027
+ const add = (sourcePath, found) => {
13028
+ errors[sourcePath] = (errors[sourcePath] ?? []).concat(found);
13029
+ };
13030
+ for (const [sourcePath, found] of Object.entries((moduleErrors === null || moduleErrors === void 0 ? void 0 : moduleErrors.validations) ?? {})) {
13031
+ add(sourcePath, found);
13032
+ }
13033
+ // `validateSources` hands back every local file whose metadata it could
13034
+ // not judge without the bytes — which is every image that has metadata at
13035
+ // all. Reading them is what turns "has metadata" into "has the RIGHT
13036
+ // metadata".
13037
+ const fileErrors = await ops.validateFiles(schemas, sources, validation.files, input.fileLastUpdatedByPatchId);
13038
+ for (const [sourcePath, found] of Object.entries(fileErrors)) {
13039
+ add(sourcePath, found);
13040
+ }
13041
+ const remote = await ops.validateRemoteFiles(schemas, sources, validation.remoteFiles, input.fileLastUpdatedByPatchId, input.network);
13042
+ for (const [sourcePath, found] of Object.entries(remote.errors)) {
13043
+ add(sourcePath, found);
13044
+ }
13045
+ const mediaDir = await checkMediaDirEntries(ops, input);
13046
+ for (const [sourcePath, found] of Object.entries(mediaDir.errors)) {
13047
+ add(sourcePath, found);
13048
+ }
13049
+ return {
13050
+ status: "ok",
13051
+ errors,
13052
+ remoteHosts: [...remote.hosts, ...mediaDir.hosts]
13053
+ };
13054
+ }
13055
+
13056
+ /** A module that is an `s.imagedir()`, `s.filedir()` or `s.videodir()`. */
13057
+
13058
+ function mediaDirOf(input) {
13059
+ var _input$schemas$input$;
13060
+ const schema = (_input$schemas$input$ = input.schemas[input.moduleFilePath]) === null || _input$schemas$input$ === void 0 ? void 0 : _input$schemas$input$["executeSerialize"]();
13061
+ const source = input.sources[input.moduleFilePath];
13062
+ if ((schema === null || schema === void 0 ? void 0 : schema.type) !== "record" || !schema.mediaType || typeof schema.dir !== "string" || !isEntries(source)) {
13063
+ return undefined;
13064
+ }
13065
+ return {
13066
+ type: schema.mediaType,
13067
+ dir: schema.dir,
13068
+ entries: source
13069
+ };
13070
+ }
13071
+ function isEntries(source) {
13072
+ return typeof source === "object" && source !== null && !Array.isArray(source);
13073
+ }
13074
+
13075
+ /**
13076
+ * A media directory's own entries against their files: what
13077
+ * `*:check-all-files` asks, minus the listing of the directory on disk.
13078
+ *
13079
+ * Only what the directory TRACKS is checked. Listing the directory for files
13080
+ * it does not track stays the CLI's: in a managed project nothing puts a file
13081
+ * there but the directory itself, and in a connected one the files are in the
13082
+ * repository, where `val validate` sees them. See
13083
+ * `docs/plans/studio-validate.md`, "Media directories are checked by what
13084
+ * they track".
13085
+ *
13086
+ * - **A local entry** is read through the ops, from the pending patch that
13087
+ * wrote it if there is one. Gone: the row's **Fix** removes the entry. Wrong
13088
+ * size or type: the row's **Fix** writes what the bytes say. Both are
13089
+ * `*:check-all-files`, built for that one entry (see `fixForStudio`).
13090
+ * - **A video entry** is only asked whether its file is there, and gets no
13091
+ * fix: what a videodir records is read in the browser from the upload, and
13092
+ * is never compared with the file (`videosetCheckAllFilesPatch` says why).
13093
+ * - **A remote entry** is asked of its host, as any remote file is, but only
13094
+ * whether the host has it (`existenceOnly`).
13095
+ */
13096
+ async function checkMediaDirEntries(ops, input, onlyKey) {
13097
+ const errors = {};
13098
+ const mediaDir = mediaDirOf(input);
13099
+ if (!mediaDir) {
13100
+ return {
13101
+ errors,
13102
+ hosts: []
13103
+ };
13104
+ }
13105
+ const files = opsFixFiles(ops, input.fileLastUpdatedByPatchId);
13106
+ const remoteEntries = {};
13107
+ const type = mediaDir.type;
13108
+ const keys = Object.keys(mediaDir.entries).filter(key => onlyKey === undefined || key === onlyKey).sort();
13109
+ for (const key of keys) {
13110
+ const entryPath = core.Internal.createValPathOfItem(input.moduleFilePath, key);
13111
+ if (!entryPath) {
13112
+ continue;
13113
+ }
13114
+ const entry = galleryEntryOf(key);
13115
+ if (entry.remote) {
13116
+ remoteEntries[entryPath] = {
13117
+ path: key
13118
+ };
13119
+ continue;
13120
+ }
13121
+ if (type === "videos") {
13122
+ // A video: there or not, and nothing else. See above.
13123
+ const fromPatch = input.fileLastUpdatedByPatchId[entry.localPath] ?? null;
13124
+ const reader = await ops.openBinaryFile(entry.localPath, fromPatch);
13125
+ if (reader === null) {
13126
+ errors[entryPath] = [{
13127
+ message: "The file is gone, but the directory still lists it. Remove it from the directory, or put the file back.",
13128
+ keyError: true
13129
+ }];
13130
+ }
13131
+ continue;
13132
+ }
13133
+ const checkFix = type === "images" ? "images:check-all-files" : "files:check-all-files";
13134
+ let buffer;
13135
+ try {
13136
+ buffer = await files.readFile(entry.localPath);
13137
+ } catch {
13138
+ errors[entryPath] = [{
13139
+ message: "The file is gone, but the directory still lists it. Fix removes it from the directory.",
13140
+ keyError: true,
13141
+ fixes: [checkFix]
13142
+ }];
13143
+ continue;
13144
+ }
13145
+ const stored = mediaDir.entries[key];
13146
+ if (typeof stored !== "object" || stored === null) {
13147
+ // The record schema already reports an entry that is not an object.
13148
+ continue;
13149
+ }
13150
+ const actual = type === "images" ? await extractImageMetadata(entry.localPath, buffer) : await extractFileMetadata(entry.localPath);
13151
+ const mismatches = mediaDirEntryMismatches(type, {
13152
+ ...stored
13153
+ }, actual);
13154
+ if (mismatches.length > 0) {
13155
+ errors[entryPath] = [{
13156
+ message: `Does not match the file: ${mismatches.map(([field, recorded, read]) => `${field} ${String(recorded ?? "missing")} → ${String(read)}`).join(", ")}.`,
13157
+ keyError: true,
13158
+ fixes: [checkFix]
13159
+ }];
13160
+ }
13161
+ }
13162
+ if (Object.keys(remoteEntries).length === 0) {
13163
+ return {
13164
+ errors,
13165
+ hosts: []
13166
+ };
13167
+ }
13168
+ const sampleByHost = ops.sourceMode() === "managed";
13169
+ const published = sampleByHost ? (await ops.getSources()).sources[input.moduleFilePath] : undefined;
13170
+ const remote = await ValRouterCore.checkRemoteFiles({
13171
+ remoteFiles: remoteEntries,
13172
+ existenceOnly: true,
13173
+ schemaAt: () => undefined,
13174
+ // A directory's entry IS its key: published when the published directory
13175
+ // has the same key.
13176
+ isPublished: (_, ref) => typeof published === "object" && published !== null && !Array.isArray(published) && ref in published,
13177
+ pendingBytes: ops.pendingBytesOf(input.fileLastUpdatedByPatchId),
13178
+ sampleByHost,
13179
+ ...input.network
13180
+ });
13181
+ for (const [path, found] of Object.entries(remote.errors)) {
13182
+ errors[path] = (errors[path] ?? []).concat(found.map(error => ({
13183
+ ...error,
13184
+ keyError: true
13185
+ })));
13186
+ }
13187
+ return {
13188
+ errors,
13189
+ hosts: remote.hosts
13190
+ };
13191
+ }
13192
+ /**
13193
+ * The patch that fixes one error, built from the server's own reading of the
13194
+ * module rather than from the error the caller saw.
13195
+ *
13196
+ * The caller names the error by where it is and which fix it wants; the error
13197
+ * itself is found again here. That is what keeps a stale or hand-written
13198
+ * request from steering `createFixPatch` (CLAUDE.md, "Adding a ValidationFix
13199
+ * code"), and it is what lets an error reported at a FIELD be fixed at the
13200
+ * media value it belongs to: `validateFiles` reports a wrong width at
13201
+ * `…"hero"."width"`, while the fix rewrites `…"hero"`.
13202
+ *
13203
+ * Only {@link isStudioFix} codes are built. The others need a disk or a
13204
+ * remote host this server may not have, and are the CLI's for now.
13205
+ */
13206
+ async function fixForStudio(ops, input) {
13207
+ var _validation$errors$mo, _input$network;
13208
+ const {
13209
+ moduleFilePath,
13210
+ schemas,
13211
+ sources,
13212
+ fix
13213
+ } = input;
13214
+ if (!internal.isStudioFix(fix)) {
13215
+ return {
13216
+ status: "not-in-studio"
13217
+ };
13218
+ }
13219
+ const schema = schemas[moduleFilePath];
13220
+ if (!schema) {
13221
+ return {
13222
+ status: "unknown-module"
13223
+ };
13224
+ }
13225
+ const validation = await ops.validateSources(schemas, sources, undefined, [moduleFilePath]);
13226
+ const found = await findError(ops, ((_validation$errors$mo = validation.errors[moduleFilePath]) === null || _validation$errors$mo === void 0 ? void 0 : _validation$errors$mo.validations) ?? {}, validation.files, validation.remoteFiles, input);
13227
+ if (!found) {
13228
+ return {
13229
+ status: "not-found"
13230
+ };
13231
+ }
13232
+ const res = await createFixPatch({
13233
+ // Nothing a Studio fix runs reads `projectRoot`: every byte goes through
13234
+ // `files`. An empty root means a fix that slipped past that fails to
13235
+ // find its file rather than reading one off this server's disk.
13236
+ projectRoot: "",
13237
+ // A re-derived ref stays on the host it was on. Moving refs to another
13238
+ // host is its own fix (step 6 of the plan), not a side effect of this.
13239
+ remoteHost: found.remoteHost ?? core.DEFAULT_VAL_REMOTE_HOST,
13240
+ files: opsFixFiles(ops, input.fileLastUpdatedByPatchId),
13241
+ askHostProject: (_input$network = input.network) === null || _input$network === void 0 ? void 0 : _input$network.askHostProject
13242
+ }, true, found.sourcePath, found.error, {}, found.moduleSource ?? sources[moduleFilePath], schema["executeSerialize"]());
13243
+ if (!res) {
13244
+ return {
13245
+ status: "not-found"
13246
+ };
13247
+ }
13248
+ return {
13249
+ status: "ok",
13250
+ patch: res.patch,
13251
+ remainingErrors: res.remainingErrors
13252
+ };
13253
+ }
13254
+ async function findError(ops, validations, files, remoteFiles, input) {
13255
+ var _validations$sourcePa;
13256
+ const {
13257
+ sourcePath,
13258
+ fix
13259
+ } = input;
13260
+ const reported = (_validations$sourcePa = validations[sourcePath]) === null || _validations$sourcePa === void 0 ? void 0 : _validations$sourcePa.find(error => {
13261
+ var _error$fixes;
13262
+ return (_error$fixes = error.fixes) === null || _error$fixes === void 0 ? void 0 : _error$fixes.includes(fix);
13263
+ });
13264
+ if (reported) {
13265
+ return {
13266
+ sourcePath,
13267
+ error: reported
13268
+ };
13269
+ }
13270
+ if (fix === "images:check-all-files" || fix === "files:check-all-files") {
13271
+ var _checked$errors$sourc;
13272
+ // One entry, checked again on its own: a row whose file someone has since
13273
+ // put back, or whose entry someone already fixed, is "not found".
13274
+ const mediaDir = mediaDirOf(input);
13275
+ const [, modulePath] = core.Internal.splitModuleFilePathAndModulePath(sourcePath);
13276
+ const [key, ...rest] = modulePath ? core.Internal.splitModulePath(modulePath) : [];
13277
+ if (!mediaDir || key === undefined || rest.length > 0) {
13278
+ return undefined;
13279
+ }
13280
+ const checked = await checkMediaDirEntries(ops, input, key);
13281
+ const error = (_checked$errors$sourc = checked.errors[sourcePath]) === null || _checked$errors$sourc === void 0 ? void 0 : _checked$errors$sourc.find(candidate => {
13282
+ var _candidate$fixes;
13283
+ return (_candidate$fixes = candidate.fixes) === null || _candidate$fixes === void 0 ? void 0 : _candidate$fixes.includes(fix);
13284
+ });
13285
+ if (!error) {
13286
+ return undefined;
13287
+ }
13288
+ return {
13289
+ // `createFixPatch` fixes a directory at its root, every entry it is
13290
+ // handed: so it is handed this one.
13291
+ sourcePath: core.Internal.parentOfSourcePath(sourcePath),
13292
+ error: {
13293
+ message: error.message,
13294
+ value: {
13295
+ dir: mediaDir.dir,
13296
+ type: mediaDir.type
13297
+ },
13298
+ fixes: [fix]
13299
+ },
13300
+ moduleSource: {
13301
+ [key]: mediaDir.entries[key]
13302
+ }
13303
+ };
13304
+ }
13305
+ if (fix === "image:check-remote" || fix === "file:check-remote" || fix === "remote:rewrite-host") {
13306
+ var _checked$errors$sourc2;
13307
+ // Every remote value is in `remoteFiles`, sound or not, so check it again
13308
+ // before rewriting it: a ref someone already fixed, or whose host serves
13309
+ // the project again, is "not found", not a download and an identical ref.
13310
+ const value = remoteFiles[sourcePath];
13311
+ if (!value) {
13312
+ return undefined;
13313
+ }
13314
+ const checked = await ops.validateRemoteFiles(input.schemas, input.sources, {
13315
+ [sourcePath]: value
13316
+ }, input.fileLastUpdatedByPatchId, input.network);
13317
+ const found = (_checked$errors$sourc2 = checked.errors[sourcePath]) === null || _checked$errors$sourc2 === void 0 ? void 0 : _checked$errors$sourc2.find(error => {
13318
+ var _error$fixes2;
13319
+ return (_error$fixes2 = error.fixes) === null || _error$fixes2 === void 0 ? void 0 : _error$fixes2.includes(fix);
13320
+ });
13321
+ const parsed = core.Internal.remote.splitRemoteRef(value.path);
13322
+ if (!found || parsed.status === "error") {
13323
+ return undefined;
13324
+ }
13325
+ return {
13326
+ sourcePath,
13327
+ error: found,
13328
+ remoteHost: parsed.remoteHost
13329
+ };
13330
+ }
13331
+ if (fix !== "image:check-metadata" && fix !== "file:check-metadata") {
13332
+ return undefined;
13333
+ }
13334
+ // A metadata mismatch is reported by `validateFiles`, at the field that is
13335
+ // wrong, and always as `image:check-metadata` — even for a file, which has
13336
+ // no width to read. So the media value is found from the field, and the fix
13337
+ // is chosen by what its schema says it is.
13338
+ for (const [mediaPathS, value] of Object.entries(files)) {
13339
+ const mediaPath = mediaPathS;
13340
+ const type = mediaTypeAt(mediaPath, input);
13341
+ if (!type) {
13342
+ continue;
13343
+ }
13344
+ const fieldPaths = ValRouterCore.getFieldsForType(type).map(field => core.Internal.createValPathOfItem(mediaPath, field));
13345
+ if (mediaPath === sourcePath || fieldPaths.includes(sourcePath)) {
13346
+ // Every image with metadata is in `files`, right or wrong — being there
13347
+ // only means "not yet compared with its bytes". So compare it, and fix
13348
+ // only one that is actually wrong: a stale request for an image someone
13349
+ // has since fixed is "not found", not an empty patch.
13350
+ const mismatches = await ops.validateFiles(input.schemas, input.sources, {
13351
+ [mediaPath]: value
13352
+ }, input.fileLastUpdatedByPatchId);
13353
+ if (Object.keys(mismatches).length === 0) {
13354
+ return undefined;
13355
+ }
13356
+ return {
13357
+ sourcePath: mediaPath,
13358
+ error: {
13359
+ message: "Metadata does not match the file",
13360
+ value,
13361
+ fixes: [type === "image" ? "image:check-metadata" : "file:check-metadata"]
13362
+ }
13363
+ };
13364
+ }
13365
+ }
13366
+ return undefined;
13367
+ }
13368
+ function mediaTypeAt(sourcePath, {
13369
+ moduleFilePath,
13370
+ schemas,
13371
+ sources
13372
+ }) {
13373
+ const schema = schemas[moduleFilePath];
13374
+ if (!schema) {
13375
+ return undefined;
13376
+ }
13377
+ const [, modulePath] = core.Internal.splitModuleFilePathAndModulePath(sourcePath);
13378
+ try {
13379
+ const {
13380
+ schema: atPath
13381
+ } = core.Internal.resolvePath(modulePath, sources[moduleFilePath], schema["executeSerialize"]());
13382
+ return atPath.type === "image" || atPath.type === "file" ? atPath.type : undefined;
13383
+ } catch {
13384
+ return undefined;
13385
+ }
13386
+ }
13387
+
13388
+ /**
13389
+ * {@link FixFiles} over whatever store this server has: the disk in fs mode,
13390
+ * the content service in http mode. A file a pending patch wrote is read from
13391
+ * that patch, as `validateFiles` reads it, so a fix is made from the bytes
13392
+ * the check was made from.
13393
+ */
13394
+ function opsFixFiles(ops, fileLastUpdatedByPatchId) {
13395
+ const readFile = async filePath => {
13396
+ const fromPatch = fileLastUpdatedByPatchId[filePath];
13397
+ const buffer = fromPatch ? await ops.getBase64EncodedBinaryFileFromPatch(filePath, fromPatch.patchId, fromPatch.remote) : await ops.getBinaryFile(filePath);
13398
+ if (buffer === null) {
13399
+ throw new Error(`Could not read ${filePath}`);
13400
+ }
13401
+ // A view of the same bytes, not a copy: the readers below take a Buffer,
13402
+ // and ops answer with a Uint8Array because they run in a browser too.
13403
+ return Buffer.from(buffer.buffer, buffer.byteOffset, buffer.byteLength);
13404
+ };
13405
+ return {
13406
+ readFile,
13407
+ readVideoMetadata: async filePath => extractVideoMetadata(filePath, await readFile(filePath)),
13408
+ readRemoteFile: readRemoteFileOverHttp,
13409
+ saveRemoteFile: async () => ({
13410
+ status: "error",
13411
+ error: "Downloading a remote file is not available in the Studio yet. Run `val validate --fix` in the project."
13412
+ })
13413
+ };
13414
+ }
13415
+
13416
+ /**
13417
+ * A remote file's bytes, downloaded and kept nowhere: this server may have no
13418
+ * disk to keep them on (http mode, a Worker on the platform).
13419
+ *
13420
+ * A platform ref whose host fails is read from the project's own
13421
+ * `valstart.dev` address instead (`fallbackRefOf`), and those bytes are kept
13422
+ * only if they hash to what the ref names: a different host answering is safe
13423
+ * because of that check, and only because of it.
13424
+ */
13425
+ const readRemoteFileOverHttp = async ref => {
13426
+ const first = await fetchBytes(ref);
13427
+ if (first.status === "success") {
13428
+ return first;
13429
+ }
13430
+ const fallback = ValRouterCore.fallbackRefOf(ref);
13431
+ const parsed = core.Internal.remote.splitRemoteRef(ref);
13432
+ if (fallback === null || parsed.status === "error") {
13433
+ return first;
13434
+ }
13435
+ const second = await fetchBytes(fallback);
13436
+ if (second.status === "success" && second.fileHash === parsed.fileHash) {
13437
+ return second;
13438
+ }
13439
+ return first;
13440
+ };
13441
+ async function fetchBytes(url) {
13442
+ try {
13443
+ const res = await fetch(url, {
13444
+ signal: AbortSignal.timeout(30_000)
13445
+ });
13446
+ if (!res.ok) {
13447
+ return {
13448
+ status: "error",
13449
+ error: `The file could not be downloaded from its address: HTTP ${res.status}.`
13450
+ };
13451
+ }
13452
+ const fileBuffer = Buffer.from(await res.arrayBuffer());
13453
+ return {
13454
+ status: "success",
13455
+ fileBuffer,
13456
+ fileHash: core.Internal.remote.getFileHash(fileBuffer)
13457
+ };
13458
+ } catch (err) {
13459
+ return {
13460
+ status: "error",
13461
+ error: `The file could not be downloaded from its address: ${err instanceof Error ? err.message : "unknown error"}.`
13462
+ };
13463
+ }
13464
+ }
13465
+
13466
+ /** The platform every server on Node runs with. See {@link ValServerPlatform}. */
13467
+ const nodePlatform = {
13468
+ createOps: createValOps,
13469
+ probeUrl: url => probeUrl(url),
13470
+ jwt: {
13471
+ verifyJwt,
13472
+ decodeJwtWithoutVerifying,
13473
+ encodeJwt,
13474
+ getExpire
13475
+ },
13476
+ bytesToBase64: bytes => Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64"),
13477
+ readValLoginToken,
13478
+ resolveRemoteFileAuth,
13479
+ studioValidation: {
13480
+ checkModuleForStudio,
13481
+ fixForStudio
13482
+ }
13483
+ };
13484
+
13485
+ async function createValServer(valModules, route, opts, config, callbacks, formatter,
13486
+ /**
13487
+ * Called after a save has applied its patches. EXPERIMENTAL — see
13488
+ * `ValServerOptions.commitPrepared`.
13489
+ */
13490
+ commitPrepared,
13491
+ /**
13492
+ * What a publish does in http mode. EXPERIMENTAL — see
13493
+ * `ValServerOptions.publishOverride`.
13494
+ */
13495
+ publishOverride) {
13496
+ const valServerConfig = await initHandlerOptions(route, opts, config);
13497
+ return ValRouterCore.ValServer(valModules, {
13498
+ formatter,
13499
+ commitPrepared,
13500
+ publishOverride,
13501
+ ...valServerConfig
13502
+ }, callbacks, nodePlatform);
13503
+ }
13504
+
13505
+ // TODO: remove
13506
+ /**
13507
+ * `fs` and `path` are imported INSIDE this function, not at the top of the file.
13508
+ *
13509
+ * This is the only thing in this module that touches either, and it is a local
13510
+ * development convenience: scanning upwards for a `.git` to guess the commit and
13511
+ * branch. A static import put `fs` in the module graph of everything reaching
13512
+ * `createValApiRouter` -- which is every server integration, including ones that
13513
+ * run where there is no filesystem. Workerd provides no `fs`, so such a build
13514
+ * could not be bundled at all without stubbing it.
13515
+ *
13516
+ * The `await import` costs nothing here: the only caller is the CLI, on a
13517
+ * machine that has both.
13518
+ */
13519
+ async function safeReadGit(cwd) {
13520
+ const {
13521
+ promises: fs
13522
+ } = await Promise.resolve().then(function () { return /*#__PURE__*/_interopNamespace(require('fs')); });
13523
+ const path = await Promise.resolve().then(function () { return /*#__PURE__*/_interopNamespace(require('path')); });
13524
+ async function findGitHead(currentDir, depth) {
13525
+ const gitHeadPath = path.join(currentDir, ".git", "HEAD");
13526
+ if (depth > 1000) {
13527
+ console.error(`Reached max depth while scanning for .git folder. Current working dir: ${cwd}.`);
13528
+ return {
13529
+ commit: undefined,
13530
+ branch: undefined
13531
+ };
13532
+ }
13533
+ try {
13534
+ const headContents = await fs.readFile(gitHeadPath, "utf-8");
13535
+ const match = headContents.match(/^ref: refs\/heads\/(.+)/);
13536
+ if (match) {
13537
+ const branchName = match[1];
13538
+ return {
13539
+ branch: branchName,
13540
+ commit: await readCommit(currentDir, branchName)
13541
+ };
13542
+ } else {
13543
+ return {
13544
+ commit: undefined,
13545
+ branch: undefined
13546
+ };
13547
+ }
13548
+ } catch {
13549
+ const parentDir = path.dirname(currentDir);
13550
+
13551
+ // We've reached the root directory
13552
+ if (parentDir === currentDir) {
13553
+ return {
13554
+ commit: undefined,
13555
+ branch: undefined
13556
+ };
13557
+ }
13558
+ return findGitHead(parentDir, depth + 1);
13559
+ }
13560
+ }
13561
+ try {
13562
+ return findGitHead(cwd, 0);
13563
+ } catch (err) {
13564
+ console.error("Error while reading .git", err);
13565
+ return {
13566
+ commit: undefined,
13567
+ branch: undefined
13568
+ };
13569
+ }
13570
+ }
13571
+
13572
+ /** Only reached from {@link safeReadGit}; same reason for the local imports. */
13573
+ async function readCommit(gitDir, branchName) {
13574
+ const {
13575
+ promises: fs
13576
+ } = await Promise.resolve().then(function () { return /*#__PURE__*/_interopNamespace(require('fs')); });
13577
+ const path = await Promise.resolve().then(function () { return /*#__PURE__*/_interopNamespace(require('path')); });
13578
+ try {
13579
+ return (await fs.readFile(path.join(gitDir, ".git", "refs", "heads", branchName), "utf-8")).trim();
13580
+ } catch {
13581
+ return undefined;
13582
+ }
13583
+ }
13584
+ function createValApiRouter(route, valServerPromise, convert) {
13585
+ const uiRequestHandler = server.createUIRequestHandler();
13586
+ return ValRouterCore.createValRequestHandler(route, valServerPromise, convert, uiRequestHandler);
13587
+ }
13588
+
13589
+ const JsFileLookupMapping = [
13590
+ // NOTE: first one matching will be used
13591
+ [".cjs.d.ts", [".esm.js", ".mjs.js"]], [".cjs.js", [".esm.js", ".mjs.js"]], [".cjs", [".mjs"]], [".d.ts", [".js", ".esm.js", ".mjs.js"]]];
13592
+ const MAX_CACHE_SIZE = 100 * 1024 * 1024; // 100 mb
13593
+ const MAX_OBJECT_KEY_SIZE = 2 ** 27; // https://stackoverflow.com/questions/13367391/is-there-a-limit-on-length-of-the-key-string-in-js-object
13594
+
13595
+ class ValModuleLoader {
13596
+ cache;
13597
+ cacheSize;
13598
+ constructor(projectRoot, compilerOptions,
13599
+ // TODO: remove this?
13600
+ sourceFileHandler, host = {
13601
+ ...ts__default["default"].sys,
13602
+ writeFile: (fileName, data, encoding) => {
13603
+ fs__default["default"].mkdirSync(path__default["default"].dirname(fileName), {
13604
+ recursive: true
13605
+ });
13606
+ fs__default["default"].writeFileSync(fileName, typeof data === "string" ? data : new Uint8Array(data), encoding);
13607
+ },
13608
+ rmFile: fs__default["default"].rmSync,
13609
+ readBuffer: fileName => {
13610
+ try {
13611
+ return fs__default["default"].readFileSync(fileName);
13612
+ } catch {
13613
+ return undefined;
13614
+ }
13615
+ }
13616
+ }, disableCache = false) {
13617
+ this.projectRoot = projectRoot;
13618
+ this.compilerOptions = compilerOptions;
13619
+ this.sourceFileHandler = sourceFileHandler;
13620
+ this.host = host;
13621
+ this.disableCache = disableCache;
13622
+ this.cache = {};
13623
+ this.cacheSize = 0;
13624
+ }
13625
+ getModule(modulePath) {
13626
+ if (!modulePath) {
13627
+ throw Error(`Illegal module path: "${modulePath}"`);
13628
+ }
13629
+ const code = this.host.readFile(modulePath);
13630
+ if (!code) {
13631
+ throw Error(`Could not read file "${modulePath}"`);
13632
+ }
13633
+ let compiledCode;
13634
+ if (this.cache[code] && !this.disableCache) {
13635
+ // TODO: use hash instead of code as key
13636
+ compiledCode = this.cache[code];
13637
+ } else {
13638
+ compiledCode = sucrase.transform(code, {
13639
+ filePath: modulePath,
13640
+ disableESTransforms: true,
13641
+ transforms: ["typescript"]
13642
+ }).code;
13643
+ if (!this.disableCache) {
13644
+ if (this.cacheSize > MAX_CACHE_SIZE) {
13645
+ console.warn("Cache size exceeded, clearing cache");
13646
+ this.cache = {};
13647
+ this.cacheSize = 0;
13648
+ }
13649
+ if (code.length < MAX_OBJECT_KEY_SIZE) {
13650
+ this.cache[code] = compiledCode;
13651
+ this.cacheSize += code.length + compiledCode.length; // code is mostly ASCII so 1 byte per char
13652
+ }
12973
13653
  }
12974
- } else if (fix === "view:check-module") {
12975
- // The schema is the authority: it names the module, and there is exactly
12976
- // one valid pointer. So this is written rather than reported — nobody has
12977
- // a decision to make here.
12978
- const [, modulePath] = core.Internal.splitModuleFilePathAndModulePath(sourcePath);
12979
- if (moduleSource === undefined || moduleSchema === undefined) {
12980
- remainingErrors.push({
12981
- ...validationError,
12982
- message: "Unexpected error while checking a view (no module source or schema)",
12983
- fixes: undefined
12984
- });
12985
- continue;
13654
+ }
13655
+ return compiledCode;
13656
+ }
13657
+ resolveModulePath(containingFilePath, requestedModuleName) {
13658
+ var _this$host$realpath, _this$host;
13659
+ let sourceFileName = this.sourceFileHandler.resolveSourceModulePath(containingFilePath, requestedModuleName);
13660
+ if (requestedModuleName === "@vercel/stega") {
13661
+ sourceFileName = this.sourceFileHandler.resolveSourceModulePath(containingFilePath, "@vercel/stega").replace("stega/dist", "stega/dist/esm");
13662
+ }
13663
+ const matches = this.findMatchingJsFile(sourceFileName);
13664
+ if (matches.match === false) {
13665
+ let debugInfo = "";
13666
+ if (sourceFileName.includes("val.config")) {
13667
+ debugInfo = `\n@valbuild directory scan:\n${this.host.readDirectory("/", ["js", "ts", "json"], [], ["**/@valbuild/*"]).join("\n")}`;
12986
13668
  }
12987
- const {
12988
- schema: schemaAtPath
12989
- } = core.Internal.resolvePath(modulePath, moduleSource, moduleSchema);
12990
- if (schemaAtPath.type !== "view") {
12991
- remainingErrors.push({
12992
- ...validationError,
12993
- message: `Could not fix view: schema at ${sourcePath} is '${schemaAtPath.type}', not a view`,
12994
- fixes: undefined
12995
- });
12996
- continue;
13669
+ throw Error(`Could not find matching js file for module "${requestedModuleName}" requested by: "${containingFilePath}". Tried:\n${matches.tried.join("\n")}${debugInfo}`);
13670
+ }
13671
+ const filePath = matches.match;
13672
+ // resolve all symlinks (preconstruct for example symlinks the dist folder)
13673
+ const followedPath = ((_this$host$realpath = (_this$host = this.host).realpath) === null || _this$host$realpath === void 0 ? void 0 : _this$host$realpath.call(_this$host, filePath)) ?? filePath;
13674
+ if (!followedPath) {
13675
+ throw Error(`File path was empty: "${filePath}", containing file: "${containingFilePath}", requested module: "${requestedModuleName}"`);
13676
+ }
13677
+ return followedPath;
13678
+ }
13679
+ findMatchingJsFile(filePath) {
13680
+ let requiresReplacements = false;
13681
+ for (const [currentEnding] of JsFileLookupMapping) {
13682
+ if (filePath.endsWith(currentEnding)) {
13683
+ requiresReplacements = true;
13684
+ break;
12997
13685
  }
12998
- if (apply) {
12999
- patch$1.push({
13000
- op: "replace",
13001
- path: core.Internal.createPatchPath(modulePath),
13002
- value: {
13003
- view: schemaAtPath.moduleFilePath
13686
+ }
13687
+ // avoid unnecessary calls to fileExists if we don't need to replace anything
13688
+ if (!requiresReplacements) {
13689
+ if (this.host.fileExists(filePath)) {
13690
+ return {
13691
+ match: filePath
13692
+ };
13693
+ }
13694
+ }
13695
+ const tried = [];
13696
+ for (const [currentEnding, replacements] of JsFileLookupMapping) {
13697
+ if (filePath.endsWith(currentEnding)) {
13698
+ for (const replacement of replacements) {
13699
+ const newFilePath = filePath.slice(0, -currentEnding.length) + replacement;
13700
+ if (this.host.fileExists(newFilePath)) {
13701
+ return {
13702
+ match: newFilePath
13703
+ };
13704
+ } else {
13705
+ tried.push(newFilePath);
13004
13706
  }
13005
- });
13006
- } else {
13007
- remainingErrors.push({
13008
- ...validationError,
13009
- message: `This view points at the wrong module. Expected '${schemaAtPath.moduleFilePath}'.`
13010
- });
13707
+ }
13011
13708
  }
13012
13709
  }
13013
- }
13014
- if (!validationError.fixes || validationError.fixes.length === 0) {
13015
- remainingErrors.push(validationError);
13016
- }
13017
- return {
13018
- patch: patch$1,
13019
- remainingErrors
13020
- };
13021
- }
13022
- function getRemoteValueFromValidationError(v) {
13023
- if (v.value && typeof v.value !== "object") {
13024
- return {
13025
- success: false,
13026
- message: "Unexpected error while checking remote (not an object)"
13027
- };
13028
- }
13029
- if (!v.value) {
13030
- return {
13031
- success: false,
13032
- message: "Unexpected error while checking remote (no value)"
13033
- };
13034
- }
13035
- if (typeof v.value !== "object" || v.value === null || !("path" in v.value)) {
13036
- return {
13037
- success: false,
13038
- message: "Unexpected error while checking remote (no path in value)"
13039
- };
13040
- }
13041
- if (typeof v.value.path !== "string") {
13042
13710
  return {
13043
- success: false,
13044
- message: "Unexpected error while checking remote (path is not a string)"
13711
+ match: false,
13712
+ tried: tried.concat(filePath)
13045
13713
  };
13046
13714
  }
13047
- // Everything but the path is what Val read from the bytes.
13048
- const {
13049
- path,
13050
- ...metadata
13051
- } = v.value;
13052
- return {
13053
- success: true,
13054
- path,
13055
- metadata
13056
- };
13057
13715
  }
13058
13716
 
13059
- /** The fields of a video that are read from its bytes, in the order written. */
13060
- const VIDEO_METADATA_FIELDS = ["mimeType", "width", "height", "duration"];
13061
-
13062
13717
  /**
13063
- * The video a `video:add-metadata` error is about, read from the module at its
13064
- * path when the module is at hand — the schema there decides that it IS a
13065
- * video — and from the error's own value otherwise.
13718
+ * An implementation of methods in the various ts.*Host interfaces
13719
+ * that uses ValFS to resolve modules and read/write files.
13066
13720
  */
13067
- function currentVideoValue(sourcePath, validationError, moduleSource, moduleSchema) {
13068
- if (moduleSource !== undefined && moduleSchema !== undefined) {
13069
- const [, modulePath] = core.Internal.splitModuleFilePathAndModulePath(sourcePath);
13070
- const resolved = core.Internal.resolvePath(modulePath, moduleSource, moduleSchema);
13071
- if (resolved.schema.type !== "video") {
13072
- return {
13073
- status: "error",
13074
- message: `Could not add video metadata: schema at ${sourcePath} is '${resolved.schema.type}', not a video`
13075
- };
13721
+
13722
+ class ValFSHost {
13723
+ constructor(valFS, currentDirectory) {
13724
+ this.valFS = valFS;
13725
+ this.currentDirectory = currentDirectory;
13726
+ }
13727
+ useCaseSensitiveFileNames = true;
13728
+ readDirectory(rootDir, extensions, excludes, includes, depth) {
13729
+ return this.valFS.readDirectory(rootDir, extensions, excludes, includes, depth);
13730
+ }
13731
+ rmFile(fileName) {
13732
+ this.valFS.rmFile(fileName);
13733
+ }
13734
+ writeFile(fileName, text, encoding) {
13735
+ this.valFS.writeFile(fileName, text, encoding);
13736
+ }
13737
+ getCurrentDirectory() {
13738
+ return this.currentDirectory;
13739
+ }
13740
+ getCanonicalFileName(fileName) {
13741
+ if (path__default["default"].isAbsolute(fileName)) {
13742
+ return path__default["default"].normalize(fileName);
13076
13743
  }
13077
- const value = mediaValueOf(resolved.source);
13078
- return value ? {
13079
- status: "ok",
13080
- value
13081
- } : {
13082
- status: "error",
13083
- message: "Video is not an object!"
13084
- };
13744
+ return path__default["default"].resolve(this.getCurrentDirectory(), fileName);
13085
13745
  }
13086
- const value = mediaValueOf(validationError.value);
13087
- return value ? {
13088
- status: "ok",
13089
- value
13090
- } : {
13091
- status: "error",
13092
- message: "Video is not an object!"
13093
- };
13094
- }
13095
- async function getVideoMetadata(projectRoot, fileRef) {
13096
- // Not read into memory: only the headers are, and a video is mostly not
13097
- // headers. A remote video's headers are read over HTTP, with Range
13098
- // requests, so it is not downloaded either.
13099
- if (core.Internal.isRemoteMediaPath(fileRef)) {
13100
- return extractVideoMetadataFromUrl(fileRef, nameForTypeOf(fileRef));
13746
+ fileExists(fileName) {
13747
+ return this.valFS.fileExists(fileName);
13101
13748
  }
13102
- return extractVideoMetadataFromFile(path__default["default"].join(projectRoot, fileRef));
13103
- }
13104
- async function getImageMetadata(projectRoot, validationError) {
13105
- const fileRef = getValidationErrorFileRef(validationError);
13106
- if (!fileRef) {
13107
- // TODO:
13108
- throw Error("Cannot fix image without a file reference");
13749
+ readFile(fileName) {
13750
+ return this.valFS.readFile(fileName);
13109
13751
  }
13110
- const filename = path__default["default"].join(projectRoot, fileRef);
13111
- const buffer = fs__default["default"].readFileSync(filename);
13112
- return extractImageMetadata(filename, buffer);
13113
- }
13114
- async function getFileMetadata(projectRoot, validationError) {
13115
- const fileRef = getValidationErrorFileRef(validationError);
13116
- if (!fileRef) {
13117
- // TODO:
13118
- throw Error("Cannot fix file without a file reference");
13752
+ readBuffer(fileName) {
13753
+ return this.valFS.readBuffer(fileName);
13754
+ }
13755
+ realpath(path) {
13756
+ return this.valFS.realpath(path);
13119
13757
  }
13120
- const filename = path__default["default"].join(projectRoot, fileRef);
13121
- fs__default["default"].readFileSync(filename);
13122
- return extractFileMetadata(fileRef);
13123
13758
  }
13124
13759
 
13125
13760
  /**
@@ -13802,6 +14437,26 @@ async function handleViewCheckModule(ctx) {
13802
14437
  shouldApplyPatch: true
13803
14438
  };
13804
14439
  }
14440
+
14441
+ /**
14442
+ * `remote:rewrite-host`: a platform ref whose host no longer serves its
14443
+ * project. The server's full check reports it, and so does `createFixPatch`'s
14444
+ * `image:check-remote` branch, which is how `val validate` finds it: that
14445
+ * branch asks the host first. Either way the patch is `createFixPatch`'s, and
14446
+ * needs nothing but the ref -- the new host is derived from it.
14447
+ */
14448
+ async function handleRemoteRewriteHost(ctx) {
14449
+ if (!ctx.fix) {
14450
+ return {
14451
+ success: true,
14452
+ fixableErrorMessage: `${ctx.validationError.message} ` + "Run 'val validate --fix' to point it at the project's own address."
14453
+ };
14454
+ }
14455
+ return {
14456
+ success: true,
14457
+ shouldApplyPatch: true
14458
+ };
14459
+ }
13805
14460
  async function handleExternalUpload() {
13806
14461
  return {
13807
14462
  success: true,
@@ -13843,7 +14498,8 @@ const currentFixHandlers = {
13843
14498
  "files:check-all-files": handleCheckAllFiles,
13844
14499
  "jsonValues:extract-entry": handleJsonValuesExtractEntry,
13845
14500
  "external:upload": handleExternalUpload,
13846
- "view:check-module": handleViewCheckModule
14501
+ "view:check-module": handleViewCheckModule,
14502
+ "remote:rewrite-host": handleRemoteRewriteHost
13847
14503
  };
13848
14504
  const deprecatedFixHandlers = {
13849
14505
  "image:replace-metadata": handleFileMetadata
@@ -14612,6 +15268,7 @@ Object.defineProperty(exports, 'hasRemoteFileSchema', {
14612
15268
  });
14613
15269
  exports.InMemoryPatchStore = ValRouterCore.InMemoryPatchStore;
14614
15270
  exports.ValOpsMemory = ValRouterCore.ValOpsMemory;
15271
+ exports.askOncePerHost = ValRouterCore.askOncePerHost;
14615
15272
  exports.classifyJsonValuesOp = ValRouterCore.classifyJsonValuesOp;
14616
15273
  exports.formatPatchSourceError = ValRouterCore.formatPatchSourceError;
14617
15274
  exports.getFileExt = ValRouterCore.getFileExt;