@valbuild/server 0.128.0 → 0.130.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.
@@ -5,7 +5,7 @@ import { derefPatch, Internal, extractValModules, computeValModuleShas, VAL_EXTE
5
5
  export { hasRemoteFileSchema } from '@valbuild/core';
6
6
  import * as path from 'path';
7
7
  import path__default from 'path';
8
- import fs, { promises } from 'fs';
8
+ import fs from 'fs';
9
9
  import vm from 'node:vm';
10
10
  import { Module } from 'node:module';
11
11
  import { isSchemaSourceFixError, resolveSchemaSourceFixForError, Patch, getErrorMessageFromUnknownJson, JSONValue, PatchGroup, newestCommitSha, SerializedSchema, VAL_ENABLE_COOKIE_NAME, VAL_STATE_COOKIE, VAL_SESSION_COOKIE, Api } from '@valbuild/shared/internal';
@@ -2495,6 +2495,17 @@ class ValOps {
2495
2495
  adopt[moduleFilePath] = source;
2496
2496
  }
2497
2497
  this.promoteCommittedSources(adopt);
2498
+ /**
2499
+ * And the `.val.ts` TEXT, for a store that has nowhere else to keep it.
2500
+ *
2501
+ * Everything above adopts the SOURCE — the data a module evaluates to. The
2502
+ * file the patch was applied TO is a separate thing, and `getSourceFile` is
2503
+ * what the next `prepare()` reads. In `fs` mode that is the disk, which
2504
+ * `saveOrUploadFiles` has just rewritten, so this is a no-op. A store with
2505
+ * no disk has to be told, or every later save re-reads the source as it was
2506
+ * when the process started and silently reverts this one.
2507
+ */
2508
+ this.adoptPatchedSourceFiles(preparedCommit.patchedSourceFiles);
2498
2509
  /**
2499
2510
  * And the `.jsonValues()` entry content, which the sources above do not
2500
2511
  * carry — they hold markers. See {@link adoptedJsonEntries}.
@@ -4053,7 +4064,56 @@ class ValOps {
4053
4064
  });
4054
4065
  }
4055
4066
 
4067
+ /**
4068
+ * Take the `.val.ts` text a commit produced as the new committed source.
4069
+ *
4070
+ * A no-op where {@link getSourceFile} reads something the commit already
4071
+ * wrote — the disk in `fs` mode, the content service in `http` mode. Override
4072
+ * it in a store that holds the source itself. `null` means the commit deleted
4073
+ * the file.
4074
+ */
4075
+ adoptPatchedSourceFiles(_files) {}
4076
+
4056
4077
  // #region abstract ops
4078
+ /**
4079
+ * Whether the patches live HERE, in this server, or in Val's content service.
4080
+ *
4081
+ * Almost everything the routes branch on comes from this one fact, which is
4082
+ * why it is a named property rather than an `instanceof`. If this server owns
4083
+ * the store then there is no content service to authenticate to (so an absent
4084
+ * or unverifiable session is anonymous rather than a 401), no shared store for
4085
+ * a patch group to separate authors in, no deployments to report, and a
4086
+ * "publish" writes what the host does with it rather than pushing a commit. If
4087
+ * it does not, every one of those is the content service's and this server is
4088
+ * relaying.
4089
+ *
4090
+ * There were two implementations when the routes were written and `instanceof
4091
+ * ValOpsFS` meant this; a third made that reading wrong in a way that compiles
4092
+ * silently -- a store that is local, answers none of the checks, and gets the
4093
+ * http path with no content service behind it.
4094
+ *
4095
+ * It is also what `/stat` reports as `mode`. The wire name predates the third
4096
+ * implementation and names a class, but the question the client is asking is
4097
+ * this one: does it auto-save and hide the account panel, or does it publish.
4098
+ */
4099
+
4100
+ /**
4101
+ * Whether a request must carry a session this server verified.
4102
+ *
4103
+ * Split out of {@link patchesAreLocal}, which was answering two questions at
4104
+ * once. "Does this store auto-save or publish" is a BEHAVIOUR question, and
4105
+ * it is what `/stat` reports and the UI keys off. "May an unauthenticated
4106
+ * request write here" is a SECURITY question. With two implementations the
4107
+ * answers coincided -- fs is local dev where no credential exists, http is
4108
+ * remote -- so one flag served both and nothing noticed.
4109
+ *
4110
+ * A third implementation splits them. `ValOpsMemory`'s store is local, which
4111
+ * makes the first answer yes, and it is designed to run DEPLOYED, which makes
4112
+ * the second answer no. Reusing one flag gave a deployed host `getAuth`
4113
+ * returning anonymous success for a missing cookie, an invalid JWT, an
4114
+ * unparseable payload, or no configured secret -- on all 29 routes, including
4115
+ * the ones that create patches and publish.
4116
+ */
4057
4117
 
4058
4118
  /**
4059
4119
  * Save a patch's binary file from a `data:...;base64,...` URL.
@@ -5650,6 +5710,13 @@ function serializeSchemaSafely(schema) {
5650
5710
  class ValOpsFS extends ValOps {
5651
5711
  static VAL_DIR = ".val";
5652
5712
  host;
5713
+ /** The developer's own working tree. See {@link ValOps.patchesAreLocal}. */
5714
+ patchesAreLocal = true;
5715
+ /**
5716
+ * The developer's own machine, where there is no credential to require and
5717
+ * nothing to protect it from. See {@link ValOps.requiresAuth}.
5718
+ */
5719
+ requiresAuth = false;
5653
5720
  constructor(contentUrl, rootDir, valModules, options) {
5654
5721
  super(valModules, options);
5655
5722
  this.contentUrl = contentUrl;
@@ -7194,6 +7261,10 @@ const PATCH_GROUPS_CACHE_MS = 1000;
7194
7261
  class ValOpsHttp extends ValOps {
7195
7262
  authHeaders;
7196
7263
  root;
7264
+ /** Val's content service owns the store. See {@link ValOps.patchesAreLocal}. */
7265
+ patchesAreLocal = false;
7266
+ /** See {@link ValOps.requiresAuth}. */
7267
+ requiresAuth = true;
7197
7268
  constructor(contentUrl, project, commitSha,
7198
7269
  // TODO: CommitSha
7199
7270
  branch,
@@ -8625,6 +8696,643 @@ class ValOpsHttp extends ValOps {
8625
8696
  // #endregion history
8626
8697
  }
8627
8698
 
8699
+ /**
8700
+ * One stored patch. Everything the ordered chain needs and nothing else.
8701
+ */
8702
+
8703
+ /**
8704
+ * One pending binary file: an upload that has not been published yet.
8705
+ *
8706
+ * Keyed by the patch that carries it, exactly as `fs` mode keys the directory
8707
+ * it writes to. The patch's own `file` op holds a hash, not the bytes, so these
8708
+ * arrive on their own request and are joined up by `(patchId, filePath)`.
8709
+ */
8710
+
8711
+ /**
8712
+ * Where pending patches live.
8713
+ *
8714
+ * The point of the interface: `ValOpsMemory` is not tied to memory. The default
8715
+ * implementation below is explicitly NOT durable -- it dies with the process,
8716
+ * or in a Worker with the isolate -- and a durable one (a Durable Object, whose
8717
+ * single-threaded execution is the lock Val's fs store builds out of a file)
8718
+ * is a swap rather than a rewrite.
8719
+ *
8720
+ * ORDER IS THE CONTRACT. `list()` returns patch ids in the order they were
8721
+ * written, and that order is the chain: entry i's parent is entry i-1. This is
8722
+ * the same decision `ValOpsFS` makes with `patches.log` -- the server decides
8723
+ * where a patch goes, and it goes last -- and it exists for the same reason: an
8724
+ * order held in the patches themselves lets a client working from a stale view
8725
+ * strand every patch behind a parent that never landed.
8726
+ */
8727
+
8728
+ /** The default store. Not durable, deliberately and visibly. */
8729
+ class InMemoryPatchStore {
8730
+ order = [];
8731
+ byId = new Map();
8732
+ async list() {
8733
+ return [...this.order];
8734
+ }
8735
+ async get(patchId) {
8736
+ return this.byId.get(patchId) ?? null;
8737
+ }
8738
+ async append(patch) {
8739
+ if (!this.byId.has(patch.patchId)) {
8740
+ this.order.push(patch.patchId);
8741
+ }
8742
+ this.byId.set(patch.patchId, patch);
8743
+ }
8744
+ async delete(patchIds) {
8745
+ for (const patchId of patchIds) {
8746
+ const at = this.order.indexOf(patchId);
8747
+ if (at !== -1) {
8748
+ this.order.splice(at, 1);
8749
+ }
8750
+ this.byId.delete(patchId);
8751
+ // The bytes go with the patch. They are only reachable through it, so
8752
+ // keeping them would be a leak with no reader -- and these are images.
8753
+ for (const key of this.files.keys()) {
8754
+ if (key.startsWith(`${patchId}\u0000`)) {
8755
+ this.files.delete(key);
8756
+ }
8757
+ }
8758
+ }
8759
+ }
8760
+
8761
+ /*
8762
+ * `\u0000` cannot occur in a patch id or a path, so the two halves of the key
8763
+ * can never run together into a different pair.
8764
+ *
8765
+ * The PATH is normalised for the same reason `sourceFiles` is, and it is not
8766
+ * cosmetic: an upload arrives as `/public/val/x.png`, and the publish step
8767
+ * looks the same file up as `public/val/x.png` because that is what
8768
+ * `splitRemoteRef` yields from a remote ref. `ValOpsFS` never noticed --
8769
+ * `path.join` collapses the difference -- so this is another thing a
8770
+ * filesystem was doing for free. Without it a remote image uploads fine,
8771
+ * patches fine, and fails at publish with "No bytes held", naming a ref whose
8772
+ * bytes are sitting right there under the other spelling.
8773
+ */
8774
+ static fileKey(patchId, filePath) {
8775
+ return `${patchId}\u0000${filePath.replace(/^\//, "")}`;
8776
+ }
8777
+ files = new Map();
8778
+ async putFile(file) {
8779
+ this.files.set(InMemoryPatchStore.fileKey(file.patchId, file.filePath), file);
8780
+ }
8781
+ async getFile(patchId, filePath) {
8782
+ return this.files.get(InMemoryPatchStore.fileKey(patchId, filePath)) ?? null;
8783
+ }
8784
+ async deleteFile(patchId, filePath) {
8785
+ this.files.delete(InMemoryPatchStore.fileKey(patchId, filePath));
8786
+ }
8787
+ async filesOf(patchId) {
8788
+ return [...this.files.values()].filter(file => file.patchId === patchId);
8789
+ }
8790
+ }
8791
+ /**
8792
+ * A `ValOps` for a host that is neither a developer's machine nor
8793
+ * content.val.build.
8794
+ *
8795
+ * EXPERIMENTAL -- see VAL_PROMPT.md.
8796
+ *
8797
+ * `fs` mode assumes a working tree it can watch and write; `http` mode assumes
8798
+ * the content API owns the patch chain and a commit means a git commit. A host
8799
+ * that builds and publishes its own output is neither: it holds the source
8800
+ * already, it has nowhere to watch, and its "commit" is a new build.
8801
+ *
8802
+ * What this deliberately does NOT do:
8803
+ *
8804
+ * - **No filesystem.** Source comes from `sourceFiles`, patches from a store.
8805
+ * - **No watching.** `getStat` still long-polls -- the hold is what paces the
8806
+ * client -- but it parks on a signal rather than racing a timer against an
8807
+ * mtime poll that can never observe anything here. Nothing can edit files
8808
+ * behind Val's back: source changes only when the host publishes, and that
8809
+ * replaces the process.
8810
+ * - **Pending binary files, but no PUBLISHED local ones.** An upload is held in
8811
+ * the patch store like any other pending change, so the Studio can preview it
8812
+ * before it is published. What this has no answer for is a file that is
8813
+ * already published and served from a `/public` directory: this configuration
8814
+ * uses Val's REMOTE files, where a published image lives on the content host
8815
+ * and the source carries a URL. `getBinaryFile` answers `null` for those --
8816
+ * a miss, not a fault -- and `getBinaryFileMetadata` refuses by name.
8817
+ * - **No git history.** There is no repository here, so the history methods
8818
+ * answer `not-supported-in-fs-mode` -- the same closed error `ValOpsFS`
8819
+ * uses, so the History UI degrades the way it already knows how rather than
8820
+ * inventing a commit list. See the note above `listCommits`.
8821
+ */
8822
+ class ValOpsMemory extends ValOps {
8823
+ /**
8824
+ * The host's own store -- see {@link ValOps.patchesAreLocal}. `true` for the
8825
+ * same reason `fs` mode is: nothing is relayed to a content service, so
8826
+ * there is no session to verify against one and no group to separate authors
8827
+ * in. Where the two differ is not something a route asks about.
8828
+ */
8829
+ patchesAreLocal = true;
8830
+ /**
8831
+ * Required, unless the host explicitly takes the boundary itself.
8832
+ *
8833
+ * `patchesAreLocal` is true here and that is about publishing, not about who
8834
+ * may write -- see {@link ValOps.requiresAuth}.
8835
+ */
8836
+ requiresAuth;
8837
+ store;
8838
+ /**
8839
+ * The project's source, keyed WITHOUT a leading slash.
8840
+ *
8841
+ * Two spellings reach this. A host keys by project-relative path
8842
+ * (`src/routes/page.val.ts`) because that is what it built from; Val asks and
8843
+ * commits with a leading slash (`/src/routes/page.val.ts`). Normalised on the
8844
+ * way in so there is one entry per file — holding both spellings would let a
8845
+ * commit update one and leave the other as the stale answer.
8846
+ *
8847
+ * Not `readonly`: a save replaces the files it rewrote. See
8848
+ * {@link adoptPatchedSourceFiles}.
8849
+ */
8850
+ sourceFiles;
8851
+ contentUrl;
8852
+ constructor(valModules, options) {
8853
+ super(valModules, options);
8854
+ this.store = options.patchStore ?? new InMemoryPatchStore();
8855
+ this.contentUrl = options.contentUrl;
8856
+ this.requiresAuth = !options.unsafelyAllowUnauthenticated;
8857
+ if (options.unsafelyAllowUnauthenticated) {
8858
+ console.warn("Val: serving memory mode WITHOUT authentication. Every request that " + "reaches this server may read content, create patches and trigger a " + "publish. Only correct if the host authorises requests before they " + "get here.");
8859
+ }
8860
+ this.sourceFiles = Object.fromEntries(Object.entries(options.sourceFiles).map(([path, text]) => [ValOpsMemory.key(path), text]));
8861
+ }
8862
+
8863
+ // #region the six that carry the mode
8864
+
8865
+ async onInit() {
8866
+ // Nothing to prepare: there is no directory to create and no store to open.
8867
+ }
8868
+
8869
+ /**
8870
+ * Requests parked in {@link getStat}, waiting for something to happen.
8871
+ *
8872
+ * See there for why this exists. Resolved and emptied by
8873
+ * {@link announceChange}; never rejected, because a waiter that gives up does
8874
+ * so on its own timeout.
8875
+ */
8876
+ statWaiters = [];
8877
+
8878
+ /**
8879
+ * Writes so far. Sampled before reading, compared after registering.
8880
+ *
8881
+ * The registration is not atomic with the read above it: a patch written
8882
+ * between `currentStat()` and `statWaiters.push` was announced to a list this
8883
+ * waiter was not yet on, so the request slept the full interval with a change
8884
+ * already sitting there. Comparing the count closes that window without a
8885
+ * lock.
8886
+ */
8887
+ changeCount = 0;
8888
+
8889
+ /** Wake every parked `getStat`. Called by this instance's own writes. */
8890
+ announceChange() {
8891
+ this.changeCount += 1;
8892
+ const waiting = this.statWaiters;
8893
+ this.statWaiters = [];
8894
+ for (const wake of waiting) {
8895
+ wake();
8896
+ }
8897
+ }
8898
+ async currentStat() {
8899
+ return {
8900
+ baseSha: await this.getBaseSha(),
8901
+ schemaSha: await this.getSchemaSha(),
8902
+ sourcesSha: await this.getSourcesSha(),
8903
+ patches: await this.store.list()
8904
+ };
8905
+ }
8906
+ async getStat(params) {
8907
+ /*
8908
+ * A long poll, and it has to be one -- but parked on a SIGNAL rather than a
8909
+ * timer.
8910
+ *
8911
+ * The hold is what paces the client. `useStatus.ts` sets `wait: 0` between
8912
+ * stats unless it has a WebSocket, with the comment "we are long polling so
8913
+ * no point in waiting": the server holding the request open IS the rate
8914
+ * limit. An earlier version of this method answered immediately, on the
8915
+ * reasoning that nothing can edit files behind Val's back in an isolate --
8916
+ * true, and beside the point. It turned a 20s poll into a request every
8917
+ * 6ms, which is worse than what it replaced.
8918
+ *
8919
+ * What was actually wrong with `fs` mode here is not the hold, it is the
8920
+ * WATCHING: it races a 250ms mtime poll and an `fs.watch`, neither of which
8921
+ * can observe anything in an isolate (the shim's `watch` never fires and
8922
+ * mtime is always 0), so it burns CPU for 20s to learn nothing. This owns
8923
+ * its store, so it is TOLD instead -- zero timers while parked, and a patch
8924
+ * written by another tab is seen at once rather than up to 250ms later.
8925
+ *
8926
+ * The timeout is the backstop, and it is not only for an idle branch: a
8927
+ * store shared between isolates (a Durable Object -- see ValPatchStore) can
8928
+ * change without this instance writing anything, and nothing would announce
8929
+ * that. So it re-reads on the way out rather than assuming `no-change`.
8930
+ */
8931
+ // Before the read, not after: anything that happens from here on must be
8932
+ // observed, either by `differs` below or by the recheck in parkUntilChange.
8933
+ const seenAt = this.changeCount;
8934
+ const before = await this.currentStat();
8935
+ const differs = now => params === null || params.baseSha !== now.baseSha || params.schemaSha !== now.schemaSha || (params.patches ?? []).join(",") !== now.patches.join(",");
8936
+
8937
+ // Already behind: answer now. This is the case that matters for latency --
8938
+ // the client has just written a patch and is asking what happened.
8939
+ if (differs(before)) {
8940
+ return {
8941
+ type: "did-change",
8942
+ ...before
8943
+ };
8944
+ }
8945
+ await this.parkUntilChange(seenAt);
8946
+ const after = await this.currentStat();
8947
+ return {
8948
+ type: differs(after) ? "did-change" : "no-change",
8949
+ ...after
8950
+ };
8951
+ }
8952
+
8953
+ /** Resolves on the next write here, or when the poll interval runs out. */
8954
+ parkUntilChange(seenAt) {
8955
+ var _this$options;
8956
+ const timeoutMs = ((_this$options = this.options) === null || _this$options === void 0 ? void 0 : _this$options.statPollingInterval) ?? 20_000;
8957
+ return new Promise(resolve => {
8958
+ let settled = false;
8959
+ const done = () => {
8960
+ if (settled) return;
8961
+ settled = true;
8962
+ // Cleared on BOTH paths: a change that wins the race leaves a 20s timer
8963
+ // behind otherwise, and in a Worker a pending timer is a reason to keep
8964
+ // the isolate alive.
8965
+ clearTimeout(timer);
8966
+ // Removed on BOTH paths too, for the same kind of reason. Only
8967
+ // `announceChange` emptied this list, so a timed-out waiter stayed on
8968
+ // it forever: one dead closure per idle poll, on a server that polls
8969
+ // every 20 seconds indefinitely, and a later write walked all of them.
8970
+ const at = this.statWaiters.indexOf(done);
8971
+ if (at !== -1) {
8972
+ this.statWaiters.splice(at, 1);
8973
+ }
8974
+ resolve();
8975
+ };
8976
+ const timer = setTimeout(done, timeoutMs);
8977
+ this.statWaiters.push(done);
8978
+ // Registered; now look again. A write in the gap announced to a list this
8979
+ // waiter was not on yet, and waiting 20s for news that already arrived is
8980
+ // the bug this closes.
8981
+ if (this.changeCount !== seenAt) {
8982
+ done();
8983
+ }
8984
+ });
8985
+ }
8986
+ async fetchPatches(filters) {
8987
+ // An empty `patchIds` means "no filter", not "none": both shipped
8988
+ // implementations read it that way and callers rely on it. See
8989
+ // `scopedModulePatches` in ValOps.ts, which says why at length.
8990
+ const requested = filters.patchIds && filters.patchIds.length > 0 ? new Set(filters.patchIds) : null;
8991
+ const order = await this.store.list();
8992
+ const patches = [];
8993
+ for (const patchId of order) {
8994
+ if (requested !== null && !requested.has(patchId)) {
8995
+ continue;
8996
+ }
8997
+ const stored = await this.store.get(patchId);
8998
+ if (!stored) {
8999
+ continue;
9000
+ }
9001
+ patches.push({
9002
+ patchId: stored.patchId,
9003
+ path: stored.path,
9004
+ patch: stored.patch,
9005
+ createdAt: stored.createdAt,
9006
+ authorId: stored.authorId,
9007
+ baseSha: stored.baseSha,
9008
+ // Nothing here is ever applied-at-a-commit: a publish in this mode
9009
+ // rebuilds the site and starts a new process, so a patch that has been
9010
+ // applied is a patch this store no longer holds.
9011
+ appliedAt: null
9012
+ });
9013
+ }
9014
+ // The cast is unavoidable: the return type is conditional on a generic
9015
+ // TypeScript cannot narrow from a value. `ValOpsFS` does the same.
9016
+ return {
9017
+ patches: filters.excludePatchOps ? patches.map(({
9018
+ patch: _patch,
9019
+ ...rest
9020
+ }) => ({
9021
+ ...rest,
9022
+ patch: undefined
9023
+ })) : patches
9024
+ };
9025
+ }
9026
+ async saveSourceFilePatch(path, patch, patchId,
9027
+ /*
9028
+ * Ignored, exactly as in `fs` mode, and named so that is visible.
9029
+ *
9030
+ * The store's order IS the chain, so the server decides where a patch goes
9031
+ * and it goes last. There is no parent to name, and so nothing that can
9032
+ * point at nothing. A patch computed against a different state is caught
9033
+ * where it shows -- applying it -- rather than by refusing the write.
9034
+ */
9035
+ _parentRef, authorId, _sessionId,
9036
+ /* No shared store and no second author yet, so nothing for a group to
9037
+ * separate. Named rather than omitted: TypeScript allows an implementation
9038
+ * to take fewer parameters, so dropping it would look identical to
9039
+ * handling it. */
9040
+ _patchGroup) {
9041
+ await this.store.append({
9042
+ patchId,
9043
+ path,
9044
+ patch,
9045
+ authorId,
9046
+ createdAt: new Date().toISOString(),
9047
+ baseSha: await this.getBaseSha()
9048
+ });
9049
+ // Any `getStat` parked on this instance answers now instead of waiting out
9050
+ // its timeout. This is the whole reason the hold can be free: the store is
9051
+ // ours, so there is nothing to poll for.
9052
+ this.announceChange();
9053
+ return result.ok({
9054
+ patchId
9055
+ });
9056
+ }
9057
+
9058
+ /** One spelling for a path, whichever the caller used. See sourceFiles. */
9059
+ static key(path) {
9060
+ return path.replace(/^\//, "");
9061
+ }
9062
+
9063
+ /**
9064
+ * A save has rewritten these files; they are the committed source now.
9065
+ *
9066
+ * Without this every save after the first re-reads the source as it was when
9067
+ * this object was built, applies only its own patches to that, and parks a
9068
+ * file that reverts everything saved before it -- with no error, because
9069
+ * applying the patch to the ORIGINAL text succeeds. The Studio auto-saves, so
9070
+ * that is not an edge case: it is most of a session's work.
9071
+ *
9072
+ * `fs` mode gets this for free -- `saveOrUploadFiles` writes the disk that
9073
+ * `getSourceFile` reads. There is no disk here, so it is written down.
9074
+ */
9075
+ adoptPatchedSourceFiles(files) {
9076
+ for (const [path, text] of Object.entries(files)) {
9077
+ const key = ValOpsMemory.key(path);
9078
+ if (text === null) {
9079
+ delete this.sourceFiles[key];
9080
+ } else {
9081
+ this.sourceFiles[key] = text;
9082
+ }
9083
+ }
9084
+ }
9085
+ async getSourceFile(path) {
9086
+ const data = this.sourceFiles[ValOpsMemory.key(path)];
9087
+ if (data === undefined) {
9088
+ return {
9089
+ error: {
9090
+ message: `File not found: ${path}. In this mode the host hands Val its ` + `source, so a missing file means the host did not ship it -- not ` + `that it is absent from a disk.`
9091
+ }
9092
+ };
9093
+ }
9094
+ return {
9095
+ data
9096
+ };
9097
+ }
9098
+ async deletePatches(patchIds) {
9099
+ await this.store.delete(patchIds);
9100
+ this.announceChange();
9101
+ return {
9102
+ deleted: patchIds
9103
+ };
9104
+ }
9105
+
9106
+ /**
9107
+ * Push this commit's pending binary files to Val's content host.
9108
+ *
9109
+ * The half of publishing that `commitPrepared` cannot do. Val's remote files
9110
+ * upload at PUBLISH, not when the image is added: until then the bytes are a
9111
+ * pending change like any other, held by {@link ValPatchStore}. So a publish
9112
+ * has to walk the descriptors and push each one before the source that
9113
+ * references it goes live -- otherwise the new build ships a URL that 404s.
9114
+ *
9115
+ * `ValOpsFS.saveOrUploadFiles` does the same loop, alongside two things this
9116
+ * has no use for: copying LOCAL binaries into a working tree, and writing the
9117
+ * source files (which is `commitPrepared` here). Kept separate rather than
9118
+ * shared, because the shapes only look alike.
9119
+ *
9120
+ * Errors are collected rather than thrown. One image that will not upload
9121
+ * should name itself and leave the rest of the publish decidable, rather than
9122
+ * failing a save that has already applied its patches.
9123
+ */
9124
+ async uploadRemoteFiles(preparedCommit, auth) {
9125
+ var _this$options2;
9126
+ const uploaded = [];
9127
+ const errors = {};
9128
+ const project = (_this$options2 = this.options) === null || _this$options2 === void 0 ? void 0 : _this$options2.config.project;
9129
+ const descriptors = Object.entries(preparedCommit.patchedBinaryFilesDescriptors);
9130
+ const remote = descriptors.filter(([, descriptor]) => descriptor.remote);
9131
+
9132
+ /*
9133
+ * A LOCAL descriptor is refused, not ignored.
9134
+ *
9135
+ * This mode has no published local-file path -- there is no `/public` to
9136
+ * write into -- and the class docstring says so. But filtering to `remote`
9137
+ * meant a local one fell through silently, and the caller carries on:
9138
+ * source is adopted and the patch deleted, so `/save` answers 200 and the
9139
+ * uploaded bytes are gone with nothing anywhere saying why.
9140
+ *
9141
+ * Erroring here keeps the patch, because a save that cannot store what it
9142
+ * was given has not succeeded.
9143
+ */
9144
+ for (const [ref, descriptor] of descriptors) {
9145
+ if (descriptor.remote) continue;
9146
+ errors[ref] = {
9147
+ message: "Cannot publish a local file in this mode: there is no directory to " + "publish it to. Configure the project for Val's remote files, which " + "is what this configuration uploads."
9148
+ };
9149
+ }
9150
+ if (remote.length === 0) {
9151
+ return {
9152
+ uploaded,
9153
+ errors
9154
+ };
9155
+ }
9156
+ if (!this.contentUrl || !project) {
9157
+ // Named separately from a failed upload: nothing was attempted, and the
9158
+ // fix is configuration rather than a retry.
9159
+ for (const [ref] of remote) {
9160
+ errors[ref] = {
9161
+ message: "Cannot publish a remote file: this server has no " + (!project ? "`project` in val.config" : "content host configured") + ". Remote files need both, plus an api key."
9162
+ };
9163
+ }
9164
+ return {
9165
+ uploaded,
9166
+ errors
9167
+ };
9168
+ }
9169
+ for (const [ref, {
9170
+ patchId
9171
+ }] of remote) {
9172
+ const split = Internal.remote.splitRemoteRef(ref);
9173
+ if (split.status === "error") {
9174
+ errors[ref] = {
9175
+ message: "Failed to split remote ref: " + ref
9176
+ };
9177
+ continue;
9178
+ }
9179
+ const bytes = await this.getBase64EncodedBinaryFileFromPatch(split.filePath, patchId);
9180
+ if (!bytes) {
9181
+ errors[ref] = {
9182
+ message: `No bytes held for ${ref} (patch ${patchId}). The upload either never arrived or was dropped with its patch.`
9183
+ };
9184
+ continue;
9185
+ }
9186
+ const res = await uploadRemoteFile(this.contentUrl, project, split.bucket, split.fileHash, getFileExt(split.filePath), bytes, auth);
9187
+ if (!res.success) {
9188
+ errors[ref] = {
9189
+ message: res.error
9190
+ };
9191
+ continue;
9192
+ }
9193
+ uploaded.push(ref);
9194
+ }
9195
+ return {
9196
+ uploaded,
9197
+ errors
9198
+ };
9199
+ }
9200
+
9201
+ // #endregion
9202
+ // #region published local files -- these refuse rather than pretend
9203
+
9204
+ remoteOnly(method) {
9205
+ throw new Error(`${method} reads a PUBLISHED file from a local directory, and this ` + `configuration uses Val's REMOTE files: a published file lives on the ` + `content host and the source carries its URL. Pending uploads are held ` + `here and do work -- see saveBase64EncodedBinaryFileFromPatch.`);
9206
+ }
9207
+ async saveBase64EncodedBinaryFileFromPatch(filePath,
9208
+ /*
9209
+ * Ignored, as in `fs` mode: the file is keyed by the patch that carries it,
9210
+ * so there is no parent to resolve.
9211
+ */
9212
+ _parentRef, patchId, data, _type, metadata) {
9213
+ if (data === null) {
9214
+ // `null` records a DELETION. This is why the byte-taking sibling cannot
9215
+ // replace this method: there would be nothing to hand it.
9216
+ await this.store.deleteFile(patchId, filePath);
9217
+ return {
9218
+ patchId,
9219
+ filePath
9220
+ };
9221
+ }
9222
+ const buffer = bufferFromDataUrl(data);
9223
+ if (!buffer) {
9224
+ return {
9225
+ error: {
9226
+ message: "Could not create buffer from data url. Not a data url? First chars were: " + data.slice(0, 20)
9227
+ }
9228
+ };
9229
+ }
9230
+ await this.store.putFile({
9231
+ patchId,
9232
+ filePath,
9233
+ data: buffer,
9234
+ metadata
9235
+ });
9236
+ return {
9237
+ patchId,
9238
+ filePath
9239
+ };
9240
+ }
9241
+ async getBase64EncodedBinaryFileFromPatch(filePath, patchId,
9242
+ /*
9243
+ * Not consulted, and `fs` mode does not consult it either: a remote ref has
9244
+ * already been split by the caller, so what arrives here is the path inside
9245
+ * it and the pair (patchId, filePath) is the whole key.
9246
+ */
9247
+ _remote) {
9248
+ var _await$this$store$get;
9249
+ return ((_await$this$store$get = await this.store.getFile(patchId, filePath)) === null || _await$this$store$get === void 0 ? void 0 : _await$this$store$get.data) ?? null;
9250
+ }
9251
+ async getBase64EncodedBinaryFileMetadataFromPatch(filePath, type, patchId, _remote) {
9252
+ const file = await this.store.getFile(patchId, filePath);
9253
+ if (!file || file.metadata === undefined) {
9254
+ return {
9255
+ errors: [{
9256
+ message: "Metadata file not found",
9257
+ filePath
9258
+ }]
9259
+ };
9260
+ }
9261
+ /*
9262
+ * Checked, not trusted. The metadata is whatever the client sent with the
9263
+ * upload, and a missing `width` on an image is the difference between a
9264
+ * layout that reserves space and one that jumps -- reported here, where the
9265
+ * field is named, rather than surfacing as an undefined further on.
9266
+ */
9267
+ const fieldErrors = getFieldsForType(type).filter(field => !(field in file.metadata)).map(field => ({
9268
+ message: `Expected fields for type: ${type}. Field not found: '${field}'`,
9269
+ field
9270
+ }));
9271
+ if (fieldErrors.length > 0) {
9272
+ return {
9273
+ errors: fieldErrors
9274
+ };
9275
+ }
9276
+ return {
9277
+ metadata: file.metadata
9278
+ };
9279
+ }
9280
+ async getBinaryFile(_filePathOrRef) {
9281
+ // Null rather than a throw: callers treat this as "no such file", and a
9282
+ // read of a local file in a remote-files project is a miss, not a fault.
9283
+ return null;
9284
+ }
9285
+ async getBinaryFileMetadata(_filePath, _type) {
9286
+ return this.remoteOnly("getBinaryFileMetadata");
9287
+ }
9288
+
9289
+ // #endregion
9290
+ // #region no git here
9291
+
9292
+ // The same answer `ValOpsFS` gives, for the same reason and reusing its
9293
+ // error: history is a thing the content service holds, and there is no
9294
+ // repository behind this mode either. `not-supported-in-fs-mode` is the
9295
+ // closed union's member for exactly that, and the Studio already knows how
9296
+ // to show it -- a mode-specific member would be a new state to write UI for
9297
+ // that says the same sentence.
9298
+
9299
+ async listCommits() {
9300
+ return result.err({
9301
+ kind: "not-supported-in-fs-mode"
9302
+ });
9303
+ }
9304
+ async getCommitPatches() {
9305
+ return result.err({
9306
+ kind: "not-supported-in-fs-mode"
9307
+ });
9308
+ }
9309
+ async getCommitModules() {
9310
+ return result.err({
9311
+ kind: "not-supported-in-fs-mode"
9312
+ });
9313
+ }
9314
+ async getCommitAffectedFiles() {
9315
+ return result.err({
9316
+ kind: "not-supported-in-fs-mode"
9317
+ });
9318
+ }
9319
+ async getFileAtCommit() {
9320
+ return result.err({
9321
+ kind: "not-supported-in-fs-mode"
9322
+ });
9323
+ }
9324
+ gitPathOfModule(
9325
+ // Unused: there is no repository for a module path to be relative to.
9326
+ _moduleFilePath) {
9327
+ return result.err({
9328
+ kind: "not-supported-in-fs-mode"
9329
+ });
9330
+ }
9331
+ // #endregion history
9332
+ }
9333
+
9334
+ /** Kept exported so a host can name the error shape it may get back. */
9335
+
8628
9336
  /**
8629
9337
  * Everything that can go wrong reading, replaying or restoring history.
8630
9338
  *
@@ -9172,15 +9880,27 @@ moduleGitPath, valTsSource, key) {
9172
9880
  return result.ok(path__default.posix.join(moduleDir, entry.importPath));
9173
9881
  }
9174
9882
 
9175
- const host = process.env.VAL_CONTENT_URL || DEFAULT_CONTENT_HOST;
9883
+ /**
9884
+ * The fallback content host.
9885
+ *
9886
+ * Read at module scope, which is why {@link getSettings} takes an override: a
9887
+ * host that bundles its dependencies SEPARATELY from its own code cannot set
9888
+ * this. Such a build inlines env vars into the app and leaves dependency chunks
9889
+ * alone, so `process.env.VAL_CONTENT_URL` here is whatever it was when the
9890
+ * chunk was built -- usually nothing. A caller that knows the content url has
9891
+ * to be able to say so.
9892
+ */
9893
+ const defaultHost$1 = process.env.VAL_CONTENT_URL || DEFAULT_CONTENT_HOST;
9176
9894
  const SettingsSchema = z.object({
9177
9895
  publicProjectId: z.string(),
9178
9896
  remoteFileBuckets: z.array(z.object({
9179
9897
  bucket: z.string()
9180
9898
  }))
9181
9899
  });
9182
- async function getSettings(projectName, auth) {
9900
+ async function getSettings(projectName, auth, /** Overrides {@link defaultHost}. See the note there for why this exists. */
9901
+ contentUrl) {
9183
9902
  try {
9903
+ const host = contentUrl || defaultHost$1;
9184
9904
  const response = await fetch(`${host}/v1/${projectName}/settings`, {
9185
9905
  headers: "pat" in auth ? {
9186
9906
  "x-val-pat": auth.pat,
@@ -9307,6 +10027,48 @@ const DEFAULT_VAL_BUILD_URL = "https://admin.val.build";
9307
10027
  * var alone.
9308
10028
  */
9309
10029
  async function initHandlerOptions(route, opts, config) {
10030
+ /*
10031
+ * A host that handed us the source has settled the question.
10032
+ *
10033
+ * First, and without consulting the environment: the other two modes are
10034
+ * inferred (an api key in the env is enough to make a project "proxy"), and
10035
+ * this one cannot be, so an env var that happens to be set must not be able
10036
+ * to take a host that supplied its own source and point it at a content
10037
+ * service instead.
10038
+ */
10039
+ if (opts.sourceFiles !== undefined) {
10040
+ const valContentUrl = opts.valContentUrl || process.env.VAL_CONTENT_URL || DEFAULT_CONTENT_HOST;
10041
+ const valBuildUrl = opts.valBuildUrl || process.env.VAL_BUILD_URL || DEFAULT_VAL_BUILD_URL;
10042
+ /*
10043
+ * The same warning the other two modes get, and for the same reason.
10044
+ *
10045
+ * Returning early here skipped it, and the early return is about MODE
10046
+ * INFERENCE -- not about which URLs are safe. This mode still sends
10047
+ * `apiKey` to `valContentUrl` for remote-file settings and uploads, so a
10048
+ * host configured with a non-loopback `http://` content URL was putting a
10049
+ * credential on the wire with none of the warning fs and http modes give
10050
+ * for exactly that.
10051
+ */
10052
+ warnIfInsecureUrls({
10053
+ valBuildUrl,
10054
+ valContentUrl
10055
+ });
10056
+ return {
10057
+ mode: "memory",
10058
+ route,
10059
+ sourceFiles: opts.sourceFiles,
10060
+ patchStore: opts.patchStore,
10061
+ unsafelyAllowUnauthenticated: opts.unsafelyAllowUnauthenticated,
10062
+ valContentUrl,
10063
+ valBuildUrl,
10064
+ valEnableRedirectUrl: opts.valEnableRedirectUrl || process.env.VAL_ENABLE_REDIRECT_URL,
10065
+ valDisableRedirectUrl: opts.valDisableRedirectUrl || process.env.VAL_DISABLE_REDIRECT_URL,
10066
+ apiKey: opts.apiKey || process.env.VAL_API_KEY,
10067
+ valSecret: opts.valSecret || process.env.VAL_SECRET,
10068
+ project: opts.project || process.env.VAL_PROJECT,
10069
+ config
10070
+ };
10071
+ }
9310
10072
  const maybeApiKey = opts.apiKey || process.env.VAL_API_KEY;
9311
10073
  const maybeValSecret = opts.valSecret || process.env.VAL_SECRET;
9312
10074
  const isProxyMode = opts.mode === "proxy" || opts.mode === undefined && (maybeApiKey || maybeValSecret);
@@ -9420,6 +10182,29 @@ function createValOps(valModules, options) {
9420
10182
  config: options.config
9421
10183
  });
9422
10184
  }
10185
+ if (options.mode === "memory") {
10186
+ /*
10187
+ * No backend to authenticate AGAINST, which is not the same as nothing to
10188
+ * authenticate. That conflation is what made this mode serve every route to
10189
+ * anyone who could reach the port: fs mode skips auth because it is a
10190
+ * developer's own machine, and this one reuses its local-store flag while
10191
+ * running deployed. It requires a verified session unless the host says it
10192
+ * has its own boundary -- see `unsafelyAllowUnauthenticated`.
10193
+ *
10194
+ * The host still holds the source and decides what a publish means; that
10195
+ * part is `commitPrepared` on ValServerOptions.
10196
+ */
10197
+ return new ValOpsMemory(valModules, {
10198
+ formatter: options.formatter,
10199
+ config: options.config,
10200
+ sourceFiles: options.sourceFiles,
10201
+ patchStore: options.patchStore,
10202
+ unsafelyAllowUnauthenticated: options.unsafelyAllowUnauthenticated,
10203
+ // For pushing remote files at publish. A project with no `s.image()`
10204
+ // never reaches it, which is why nothing above requires it.
10205
+ contentUrl: options.valContentUrl
10206
+ });
10207
+ }
9423
10208
  throw new Error(
9424
10209
  // The union is exhausted above; this catches a config that came from
9425
10210
  // somewhere untyped.
@@ -9538,12 +10323,23 @@ async function resolveRemoteFileAuth(options) {
9538
10323
  };
9539
10324
  }
9540
10325
  if (options.mode !== "fs") {
9541
- // Unreachable through `initHandlerOptions`, which refuses to build a proxy
9542
- // config without an api key. Kept because this is exported.
10326
+ /*
10327
+ * `api-key-missing`, and the distinction matters to whoever reads it.
10328
+ *
10329
+ * The PAT below is read from a file in the server's own working directory,
10330
+ * which only `fs` mode has. Every other mode can be authenticated one way,
10331
+ * with an api key -- so the Studio must not offer `val login` here. It did,
10332
+ * because "local" used to mean "fs" and the third mode made that false: the
10333
+ * dialog told people to run a command, in a directory, that could not have
10334
+ * helped even if they found the right one.
10335
+ *
10336
+ * `project-not-configured` was also just wrong. The project may be
10337
+ * perfectly well configured; it is the credential that is absent.
10338
+ */
9543
10339
  return {
9544
10340
  status: "error",
9545
- errorCode: "project-not-configured",
9546
- message: "Remote file auth is not configured"
10341
+ errorCode: "api-key-missing",
10342
+ message: "Remote files need an api key here: this server cannot read a " + "personal access token, because that is a file in a working directory " + "and it has none. Set VAL_API_KEY."
9547
10343
  };
9548
10344
  }
9549
10345
  // `options.cwd`, which `initHandlerOptions` sets from `process.cwd()`. The
@@ -9578,6 +10374,11 @@ async function resolveRemoteFileAuth(options) {
9578
10374
  }
9579
10375
 
9580
10376
  /* eslint-disable @typescript-eslint/no-unused-vars */
10377
+
10378
+ /** What {@link ValServerOptions.publishOverride} is given. */
10379
+
10380
+ /** What a publish reports back, in either shape. */
10381
+
9581
10382
  const ValServer = (valModules, options, callbacks) => {
9582
10383
  const AIContentBlock = z.union([z.object({
9583
10384
  type: z.literal("text"),
@@ -9672,8 +10473,17 @@ const ValServer = (valModules, options, callbacks) => {
9672
10473
  };
9673
10474
  const getAuth = cookies => {
9674
10475
  const cookie = cookies[VAL_SESSION_COOKIE];
10476
+ /*
10477
+ * `requiresAuth`, not `patchesAreLocal`.
10478
+ *
10479
+ * These four exits return anonymous SUCCESS -- `{ error: null }` -- and all
10480
+ * 29 routes below treat that as authorised. That is right for fs mode,
10481
+ * which is a developer's own machine with no credential to require. It was
10482
+ * keyed on the wrong question: a local patch store is about publishing, not
10483
+ * about who may write, and memory mode is local AND deployed.
10484
+ */
9675
10485
  if (!options.valSecret) {
9676
- if (serverOps instanceof ValOpsFS) {
10486
+ if (!serverOps.requiresAuth) {
9677
10487
  return {
9678
10488
  error: null,
9679
10489
  id: null
@@ -9687,7 +10497,7 @@ const ValServer = (valModules, options, callbacks) => {
9687
10497
  if (typeof cookie === "string") {
9688
10498
  const verifiedToken = verifyJwt(cookie, options.valSecret);
9689
10499
  if (!verifiedToken.success) {
9690
- if (serverOps instanceof ValOpsFS) {
10500
+ if (!serverOps.requiresAuth) {
9691
10501
  return {
9692
10502
  error: null,
9693
10503
  id: null
@@ -9699,7 +10509,7 @@ const ValServer = (valModules, options, callbacks) => {
9699
10509
  }
9700
10510
  const verification = IntegratedServerJwtPayload.safeParse(verifiedToken.data);
9701
10511
  if (!verification.success) {
9702
- if (serverOps instanceof ValOpsFS) {
10512
+ if (!serverOps.requiresAuth) {
9703
10513
  return {
9704
10514
  error: null,
9705
10515
  id: null
@@ -9713,7 +10523,7 @@ const ValServer = (valModules, options, callbacks) => {
9713
10523
  id: verification.data.sub
9714
10524
  };
9715
10525
  } else {
9716
- if (serverOps instanceof ValOpsFS) {
10526
+ if (!serverOps.requiresAuth) {
9717
10527
  return {
9718
10528
  error: null,
9719
10529
  id: null
@@ -10115,7 +10925,10 @@ const ValServer = (valModules, options, callbacks) => {
10115
10925
  return remoteFileAuthRes;
10116
10926
  }
10117
10927
  const remoteFileAuth = remoteFileAuthRes.json.remoteFileAuth;
10118
- const settingsRes = await getSettings(options.project, remoteFileAuth);
10928
+
10929
+ // The content url this server was CONFIGURED with, not whatever was in
10930
+ // the environment when @valbuild/server was built. See getSettings.
10931
+ const settingsRes = await getSettings(options.project, remoteFileAuth, options.valContentUrl);
10119
10932
  if (!settingsRes.success) {
10120
10933
  console.warn("Could not get remote files settings: " + settingsRes.message);
10121
10934
  return {
@@ -10184,15 +10997,17 @@ const ValServer = (valModules, options, callbacks) => {
10184
10997
  json: currentStat.error
10185
10998
  };
10186
10999
  }
10187
- const mode = serverOps instanceof ValOpsFS ? "fs" : serverOps instanceof ValOpsHttp ? "http" : "unknown";
10188
- if (mode === "unknown") {
10189
- return {
10190
- status: 500,
10191
- json: {
10192
- message: "Server mode is neither fs nor http - this is an internal Val bug"
10193
- }
10194
- };
10195
- }
11000
+ /*
11001
+ * The wire value names a class and the question it answers does not.
11002
+ *
11003
+ * The client reads `mode` to decide whether it auto-saves and hides the
11004
+ * account panel, or whether it publishes and shows deployments -- which
11005
+ * is {@link ValOps.patchesAreLocal} and nothing else. It was derived by
11006
+ * `instanceof` while there were exactly two implementations, so a third
11007
+ * local store fell through to `"unknown"` and the Studio refused to
11008
+ * start against a server that was working.
11009
+ */
11010
+ const mode = serverOps.patchesAreLocal ? "fs" : "http";
10196
11011
  return {
10197
11012
  status: 200,
10198
11013
  json: {
@@ -10263,7 +11078,7 @@ const ValServer = (valModules, options, callbacks) => {
10263
11078
  }
10264
11079
  };
10265
11080
  }
10266
- if (serverOps instanceof ValOpsFS) {
11081
+ if (serverOps.patchesAreLocal) {
10267
11082
  // In FS mode patch-file uploads are buffered through this server (no remote round-trip),
10268
11083
  // so baseUrl points at /api/val/upload. AI image uploads, however, go straight to the
10269
11084
  // content host — we resolve a contentBaseUrl + a PAT-issued nonce here so the browser
@@ -10273,6 +11088,19 @@ const ValServer = (valModules, options, callbacks) => {
10273
11088
  let contentAuthNonce = null;
10274
11089
  if (!options.project) {
10275
11090
  console.warn("Direct content-host uploads (AI images) disabled: no `project` set in val.config (and VAL_PROJECT env var is not set).");
11091
+ } else if (!(serverOps instanceof ValOpsFS)) {
11092
+ /*
11093
+ * Presigning needs a content host to presign AGAINST, and a local
11094
+ * store does not necessarily have one configured: `fs` mode has the
11095
+ * developer's `valContentUrl`, and a host holding its own source has
11096
+ * no such setting.
11097
+ *
11098
+ * Warned and disabled, like the missing-project case above, rather
11099
+ * than failing the request: everything else this route answers --
11100
+ * the patch upload base url -- still works, and the only thing lost
11101
+ * is the browser posting AI images straight to the content host.
11102
+ */
11103
+ console.warn("Direct content-host uploads (AI images) disabled: this store has no content host to presign against.");
10276
11104
  } else {
10277
11105
  const authDataRes = await getRemoteFileAuth();
10278
11106
  if (authDataRes.status !== 200) {
@@ -10354,7 +11182,7 @@ const ValServer = (valModules, options, callbacks) => {
10354
11182
  patchIds
10355
11183
  } = req.body;
10356
11184
  const withPatchIds = req.body.withPatchIds ?? [];
10357
- if (serverOps instanceof ValOpsFS) {
11185
+ if (serverOps.patchesAreLocal) {
10358
11186
  return {
10359
11187
  status: 200,
10360
11188
  json: {
@@ -10417,7 +11245,7 @@ const ValServer = (valModules, options, callbacks) => {
10417
11245
  patchIds
10418
11246
  } = req.body;
10419
11247
  const withPatchIds = req.body.withPatchIds ?? [];
10420
- if (serverOps instanceof ValOpsFS) {
11248
+ if (serverOps.patchesAreLocal) {
10421
11249
  return {
10422
11250
  status: 200,
10423
11251
  json: {
@@ -10496,7 +11324,7 @@ const ValServer = (valModules, options, callbacks) => {
10496
11324
  } : {}),
10497
11325
  withPatchIds: requestedWith ?? []
10498
11326
  } : undefined;
10499
- if (patchGroup !== undefined && serverOps instanceof ValOpsFS) {
11327
+ if (patchGroup !== undefined && serverOps.patchesAreLocal) {
10500
11328
  /*
10501
11329
  * `fs` has no shared store and one author, so there is no group to
10502
11330
  * join. Refused rather than acknowledged: answering 200 would tell the
@@ -11267,7 +12095,7 @@ const ValServer = (valModules, options, callbacks) => {
11267
12095
  }
11268
12096
  const authDataRes = await getRemoteFileAuth();
11269
12097
  if (authDataRes.status !== 200) {
11270
- if (serverOps instanceof ValOpsFS && authDataRes.json.errorCode === "pat-error") {
12098
+ if (serverOps.patchesAreLocal && authDataRes.json.errorCode === "pat-error") {
11271
12099
  return {
11272
12100
  status: 200,
11273
12101
  json: {
@@ -11324,7 +12152,7 @@ const ValServer = (valModules, options, callbacks) => {
11324
12152
  };
11325
12153
  }
11326
12154
  };
11327
- if (serverOps instanceof ValOpsFS) {
12155
+ if (serverOps.patchesAreLocal) {
11328
12156
  return execFetch(getProfileAuthHeaders(authData, null, "application/json"));
11329
12157
  }
11330
12158
  if (!options.valSecret) {
@@ -11440,7 +12268,7 @@ const ValServer = (valModules, options, callbacks) => {
11440
12268
  * editing is told what was thrown away.
11441
12269
  */
11442
12270
  let removed = [];
11443
- if (preparedCommit.hasErrors && serverOps instanceof ValOpsFS) {
12271
+ if (preparedCommit.hasErrors && serverOps.patchesAreLocal) {
11444
12272
  removed = computePatchesToDrop(preparedCommit);
11445
12273
  /*
11446
12274
  * Nothing to drop means nothing a second `prepare` could do better.
@@ -11492,35 +12320,93 @@ const ValServer = (valModules, options, callbacks) => {
11492
12320
  }
11493
12321
  };
11494
12322
  }
11495
- if (serverOps instanceof ValOpsFS) {
11496
- var _remoteFileAuthRes;
11497
- const isRemoteRequired = getIsRemoteRequired(await serverOps.getSchemas());
11498
- let mode;
11499
- let remoteFileAuthRes;
11500
- if (isRemoteRequired) {
11501
- mode = "upload-remote";
11502
- remoteFileAuthRes = await getRemoteFileAuth();
11503
- } else {
11504
- mode = "skip-remote";
12323
+ if (serverOps.patchesAreLocal) {
12324
+ /*
12325
+ * Writing the files is the one step that is not shared.
12326
+ *
12327
+ * Everything around it is: applying the patches, telling the ops the
12328
+ * sources moved, handing the result to the host, dropping the patches
12329
+ * this request consumed. Only the destination differs -- `fs` mode has
12330
+ * a working tree to write into and remote binaries to push, and a host
12331
+ * that holds its own source has neither. So this branch is on the
12332
+ * store that CAN write files rather than on "is this local", which is
12333
+ * what the rest of the flow asks.
12334
+ */
12335
+ /*
12336
+ * Remote binary files, which publish separately from the source.
12337
+ *
12338
+ * Val uploads a remote file at PUBLISH rather than when it is added:
12339
+ * until then the bytes are a pending change like any other. So a
12340
+ * local store has to push them before the source that references them
12341
+ * goes live, or the new build ships a URL that 404s. `ValOpsFS` does
12342
+ * this inside `saveOrUploadFiles`, alongside writing its working
12343
+ * tree; a store with no working tree does only the push.
12344
+ */
12345
+ if (serverOps instanceof ValOpsMemory) {
12346
+ const isRemoteRequired = getIsRemoteRequired(await serverOps.getSchemas());
12347
+ if (isRemoteRequired) {
12348
+ const authRes = await getRemoteFileAuth();
12349
+ if (authRes.status !== 200) {
12350
+ return authRes;
12351
+ }
12352
+ const uploadRes = await serverOps.uploadRemoteFiles(preparedCommit, authRes.json.remoteFileAuth);
12353
+ if (Object.keys(uploadRes.errors).length > 0) {
12354
+ console.error("Val: Failed to upload remote files", uploadRes.errors);
12355
+ return {
12356
+ status: 400,
12357
+ json: {
12358
+ message: "Failed to save files",
12359
+ details: Object.entries(uploadRes.errors).map(([ref, error]) => ({
12360
+ message: `Got error: ${error.message} in ${ref}`
12361
+ }))
12362
+ }
12363
+ };
12364
+ }
12365
+ }
11505
12366
  }
11506
- if (remoteFileAuthRes && remoteFileAuthRes.status !== 200) {
11507
- return remoteFileAuthRes;
12367
+ if (serverOps instanceof ValOpsFS) {
12368
+ var _remoteFileAuthRes;
12369
+ const isRemoteRequired = getIsRemoteRequired(await serverOps.getSchemas());
12370
+ let mode;
12371
+ let remoteFileAuthRes;
12372
+ if (isRemoteRequired) {
12373
+ mode = "upload-remote";
12374
+ remoteFileAuthRes = await getRemoteFileAuth();
12375
+ } else {
12376
+ mode = "skip-remote";
12377
+ }
12378
+ if (remoteFileAuthRes && remoteFileAuthRes.status !== 200) {
12379
+ return remoteFileAuthRes;
12380
+ }
12381
+ const remoteFileAuth = (_remoteFileAuthRes = remoteFileAuthRes) === null || _remoteFileAuthRes === void 0 || (_remoteFileAuthRes = _remoteFileAuthRes.json) === null || _remoteFileAuthRes === void 0 ? void 0 : _remoteFileAuthRes.remoteFileAuth;
12382
+ const saveRes = await serverOps.saveOrUploadFiles(preparedCommit, mode, remoteFileAuth);
12383
+ if (Object.keys(saveRes.errors).length > 0) {
12384
+ console.error("Val: Failed to save files", saveRes.errors);
12385
+ return {
12386
+ status: 400,
12387
+ json: {
12388
+ message: "Failed to save files",
12389
+ details: Object.entries(saveRes.errors).map(([key, error]) => {
12390
+ return {
12391
+ message: `Got error: ${error} in ${key}`
12392
+ };
12393
+ })
12394
+ }
12395
+ };
12396
+ }
11508
12397
  }
11509
- const remoteFileAuth = (_remoteFileAuthRes = remoteFileAuthRes) === null || _remoteFileAuthRes === void 0 || (_remoteFileAuthRes = _remoteFileAuthRes.json) === null || _remoteFileAuthRes === void 0 ? void 0 : _remoteFileAuthRes.remoteFileAuth;
11510
- const saveRes = await serverOps.saveOrUploadFiles(preparedCommit, mode, remoteFileAuth);
11511
- if (Object.keys(saveRes.errors).length > 0) {
11512
- console.error("Val: Failed to save files", saveRes.errors);
11513
- return {
11514
- status: 400,
11515
- json: {
11516
- message: "Failed to save files",
11517
- details: Object.entries(saveRes.errors).map(([key, error]) => {
11518
- return {
11519
- message: `Got error: ${error} in ${key}`
11520
- };
11521
- })
11522
- }
11523
- };
12398
+ /*
12399
+ * The host's destination, after the store's own.
12400
+ *
12401
+ * Ordered this way so a store that writes has already succeeded by
12402
+ * the time the host is told: `commitPrepared` throwing fails the save,
12403
+ * and a host that publishes what it was handed should not be handed
12404
+ * files the store could not write.
12405
+ */
12406
+ if (options.commitPrepared) {
12407
+ await options.commitPrepared({
12408
+ patchedSourceFiles: preparedCommit.patchedSourceFiles
12409
+ });
11524
12410
  }
11525
12411
  /*
11526
12412
  * The files on disk are the committed content now, so say so here too.
@@ -11570,7 +12456,6 @@ const ValServer = (valModules, options, callbacks) => {
11570
12456
  };
11571
12457
  } else if (serverOps instanceof ValOpsHttp) {
11572
12458
  if (auth.error === undefined && auth.id) {
11573
- var _options$config$files;
11574
12459
  /*
11575
12460
  * The group this commit CLOSES has to be the caller's.
11576
12461
  *
@@ -11610,14 +12495,32 @@ const ValServer = (valModules, options, callbacks) => {
11610
12495
  }
11611
12496
  }
11612
12497
  const message = body.message || "Val CMS update (" + Object.keys(analysis.patchesByModule).length + " files changed)";
11613
- const commitRes = await serverOps.commit(preparedCommit, message, auth.id, ((_options$config$files = options.config.files) === null || _options$config$files === void 0 ? void 0 : _options$config$files.directory) || "/public/val", undefined,
12498
+ const commitToGit = () => {
12499
+ var _options$config$files;
12500
+ return serverOps.commit(preparedCommit, message, auth.id, ((_options$config$files = options.config.files) === null || _options$config$files === void 0 ? void 0 : _options$config$files.directory) || "/public/val", undefined,
12501
+ /*
12502
+ * Forwarded verbatim, and only the client can decide it: the
12503
+ * content API closes the group it is named without checking
12504
+ * that the commit shipped all of it, and whether it did needs
12505
+ * the patch sets, which live in the browser.
12506
+ */
12507
+ body.patchGroupId);
12508
+ };
11614
12509
  /*
11615
- * Forwarded verbatim, and only the client can decide it: the
11616
- * content API closes the group it is named without checking that
11617
- * the commit shipped all of it, and whether it did needs the
11618
- * patch sets, which live in the browser.
12510
+ * A host may publish somewhere other than the repository.
12511
+ *
12512
+ * See `publishOverride`. It is handed `commitToGit` rather than
12513
+ * having it skipped, so it can replace the commit or add to it --
12514
+ * and it is the host, not this route, that knows which.
11619
12515
  */
11620
- body.patchGroupId);
12516
+ const commitRes = options.publishOverride ? await options.publishOverride({
12517
+ patchedSourceFiles: preparedCommit.patchedSourceFiles,
12518
+ preparedCommit,
12519
+ message,
12520
+ authorId: auth.id,
12521
+ patchGroupId: body.patchGroupId,
12522
+ commitToGit
12523
+ }) : await commitToGit();
11621
12524
  if (commitRes.error) {
11622
12525
  console.error("Failed to commit", commitRes.error);
11623
12526
  if ("isNotFastForward" in commitRes && commitRes.isNotFastForward) {
@@ -11764,7 +12667,7 @@ const ValServer = (valModules, options, callbacks) => {
11764
12667
  };
11765
12668
  }
11766
12669
  };
11767
- if (serverOps instanceof ValOpsFS) {
12670
+ if (serverOps.patchesAreLocal) {
11768
12671
  return execFetch(getProfileAuthHeaders(authData, null, "application/json"));
11769
12672
  }
11770
12673
  if (!options.valSecret) {
@@ -11868,7 +12771,7 @@ const ValServer = (valModules, options, callbacks) => {
11868
12771
  };
11869
12772
  }
11870
12773
  };
11871
- if (serverOps instanceof ValOpsFS) {
12774
+ if (serverOps.patchesAreLocal) {
11872
12775
  return execFetch(getProfileAuthHeaders(authData, null, "application/json"));
11873
12776
  }
11874
12777
  if (!options.valSecret) {
@@ -11954,7 +12857,7 @@ const ValServer = (valModules, options, callbacks) => {
11954
12857
  };
11955
12858
  }
11956
12859
  };
11957
- if (serverOps instanceof ValOpsFS) {
12860
+ if (serverOps.patchesAreLocal) {
11958
12861
  return execFetch(getProfileAuthHeaders(authData, null, "application/json"));
11959
12862
  }
11960
12863
  if (!options.valSecret) {
@@ -12062,7 +12965,7 @@ const ValServer = (valModules, options, callbacks) => {
12062
12965
  };
12063
12966
  }
12064
12967
  };
12065
- if (serverOps instanceof ValOpsFS) {
12968
+ if (serverOps.patchesAreLocal) {
12066
12969
  return execFetch(getProfileAuthHeaders(authData, null, "application/json"));
12067
12970
  }
12068
12971
  if (!options.valSecret) {
@@ -12109,7 +13012,7 @@ const ValServer = (valModules, options, callbacks) => {
12109
13012
  }
12110
13013
  const authData = authDataRes.json.remoteFileAuth;
12111
13014
  let headers;
12112
- if (serverOps instanceof ValOpsFS) {
13015
+ if (serverOps.patchesAreLocal) {
12113
13016
  headers = getProfileAuthHeaders(authData, null, "application/json");
12114
13017
  } else {
12115
13018
  if (!("id" in auth) || !auth.id) {
@@ -12196,6 +13099,11 @@ const ValServer = (valModules, options, callbacks) => {
12196
13099
  }
12197
13100
  };
12198
13101
  }
13102
+ // Deliberately `ValOpsFS` and not `patchesAreLocal`: this writes
13103
+ // BYTES into a patch directory, which is a thing only the fs store
13104
+ // has. A local store without local binary files (ValOpsMemory, which
13105
+ // uses Val's remote files) skips it, and the draft image is served
13106
+ // from the content host rather than mirrored.
12199
13107
  if (serverOps instanceof ValOpsFS) {
12200
13108
  // Mirror the binaries from the content host into local patch
12201
13109
  // storage so /files?patch_id=... can serve them. Match upstream
@@ -12300,7 +13208,7 @@ const ValServer = (valModules, options, callbacks) => {
12300
13208
  }
12301
13209
  const authData = authDataRes.json.remoteFileAuth;
12302
13210
  let headers;
12303
- if (serverOps instanceof ValOpsFS) {
13211
+ if (serverOps.patchesAreLocal) {
12304
13212
  headers = getProfileAuthHeaders(authData, null, "application/json");
12305
13213
  } else {
12306
13214
  if (!("id" in auth) || !auth.id) {
@@ -12712,7 +13620,15 @@ chain, deleted, requested) {
12712
13620
  * `undefined` means "apply everything", which is what every caller that does
12713
13621
  * not ask for scoping gets and must keep getting.
12714
13622
  */
12715
- async function resolveOwnPatchScope(serverOps, opts) {
13623
+ async function resolveOwnPatchScope(
13624
+ /*
13625
+ * `ValOps`, not the two concrete stores: the one thing this needs is whether
13626
+ * a content service holds the groups, and it asks that below with an
13627
+ * `instanceof ValOpsHttp`. Naming the implementations here meant every new
13628
+ * store had to be added to a list to be allowed to call a function that does
13629
+ * not use it.
13630
+ */
13631
+ serverOps, opts) {
12716
13632
  let ownPatchIds;
12717
13633
  /** See where this is set: committed work is nobody's to hold back. */
12718
13634
  let scopeAlsoIncludesApplied = false;
@@ -13215,16 +14131,45 @@ function getIsRemoteRequired(schemas) {
13215
14131
  return false;
13216
14132
  }
13217
14133
 
13218
- async function createValServer(valModules, route, opts, config, callbacks, formatter) {
14134
+ async function createValServer(valModules, route, opts, config, callbacks, formatter,
14135
+ /**
14136
+ * Called after a save has applied its patches. EXPERIMENTAL — see
14137
+ * `ValServerOptions.commitPrepared`.
14138
+ */
14139
+ commitPrepared,
14140
+ /**
14141
+ * What a publish does in http mode. EXPERIMENTAL — see
14142
+ * `ValServerOptions.publishOverride`.
14143
+ */
14144
+ publishOverride) {
13219
14145
  const valServerConfig = await initHandlerOptions(route, opts, config);
13220
14146
  return ValServer(valModules, {
13221
14147
  formatter,
14148
+ commitPrepared,
14149
+ publishOverride,
13222
14150
  ...valServerConfig
13223
14151
  }, callbacks);
13224
14152
  }
13225
14153
 
13226
14154
  // TODO: remove
14155
+ /**
14156
+ * `fs` and `path` are imported INSIDE this function, not at the top of the file.
14157
+ *
14158
+ * This is the only thing in this module that touches either, and it is a local
14159
+ * development convenience: scanning upwards for a `.git` to guess the commit and
14160
+ * branch. A static import put `fs` in the module graph of everything reaching
14161
+ * `createValApiRouter` -- which is every server integration, including ones that
14162
+ * run where there is no filesystem. Workerd provides no `fs`, so such a build
14163
+ * could not be bundled at all without stubbing it.
14164
+ *
14165
+ * The `await import` costs nothing here: the only caller is the CLI, on a
14166
+ * machine that has both.
14167
+ */
13227
14168
  async function safeReadGit(cwd) {
14169
+ const {
14170
+ promises: fs
14171
+ } = await import('fs');
14172
+ const path = await import('path');
13228
14173
  async function findGitHead(currentDir, depth) {
13229
14174
  const gitHeadPath = path.join(currentDir, ".git", "HEAD");
13230
14175
  if (depth > 1000) {
@@ -13235,7 +14180,7 @@ async function safeReadGit(cwd) {
13235
14180
  };
13236
14181
  }
13237
14182
  try {
13238
- const headContents = await promises.readFile(gitHeadPath, "utf-8");
14183
+ const headContents = await fs.readFile(gitHeadPath, "utf-8");
13239
14184
  const match = headContents.match(/^ref: refs\/heads\/(.+)/);
13240
14185
  if (match) {
13241
14186
  const branchName = match[1];
@@ -13272,9 +14217,15 @@ async function safeReadGit(cwd) {
13272
14217
  };
13273
14218
  }
13274
14219
  }
14220
+
14221
+ /** Only reached from {@link safeReadGit}; same reason for the local imports. */
13275
14222
  async function readCommit(gitDir, branchName) {
14223
+ const {
14224
+ promises: fs
14225
+ } = await import('fs');
14226
+ const path = await import('path');
13276
14227
  try {
13277
- return (await promises.readFile(path.join(gitDir, ".git", "refs", "heads", branchName), "utf-8")).trim();
14228
+ return (await fs.readFile(path.join(gitDir, ".git", "refs", "heads", branchName), "utf-8")).trim();
13278
14229
  } catch {
13279
14230
  return undefined;
13280
14231
  }
@@ -15903,4 +16854,4 @@ function readCapturedReport(snapshotDir) {
15903
16854
  return JSON.parse(fs.readFileSync(reportPath, "utf-8"));
15904
16855
  }
15905
16856
 
15906
- export { DEFAULT_LOGIN_EXPIRES_IN_SECONDS, DEFAULT_LOGIN_HOST, DEFAULT_LOGIN_POLL_INTERVAL_SECONDS, EXTERNAL_RESULT, Service, ValFSHost, ValLoginError, ValModuleLoader, ValOpsFS, ValOpsHttp, ValSourceFileHandler, analyzeValModule, awaitValLoginConfirmation, checkRemoteRef, classifyJsonValuesOp, compareWithCapturedReport, createDefaultValFSHost, createFixPatch, createJsonEntryPathMap, createModulePathMap, createService, createValApiRouter, createValModuleFileInspector, createValOps, createValServer, currentFixHandlers, decodeJwtWithoutVerifying, defineExternal, describePatchStoreProblems, downloadFileFromRemote, encodeJwt, err, evalValConfigFile, extractFileMetadata, extractImageMetadata, extractJsonValuesEntry, findAndEvalValConfigFile, findJsonEntryFilePath, fixHandlers, formatPatchSourceError, formatSyntaxErrorTree, getCachedRemoteFileDir, getCachedRemoteFilePath, getCompilerOptions, getExpire, getFileExt, getModulePathRange, getPersonalAccessTokenPath, getSettings, getValidationErrorFileRef, handleCheckAllFiles, handleExternalUpload, handleFileMetadata, handleJsonValuesExtractEntry, handleRemoteFileCheck, handleRemoteFileDownload, handleRemoteFileUpload, handleRemoteGalleryFileUpload, handleUniqueFolderCheck, initHandlerOptions, isExternalResult, loadValModules, ok, parsePersonalAccessTokenFile, patchSourceFile, persistPersonalAccessToken, planJsonValuesEntryExtraction, readCapturedReport, readPatchStore, rebaseContentOp, replaySnapshot, resolveRemoteFileAuth, safeReadGit, startValLogin, uploadRemoteFile, validateMetadata, verifyJwt };
16857
+ export { DEFAULT_LOGIN_EXPIRES_IN_SECONDS, DEFAULT_LOGIN_HOST, DEFAULT_LOGIN_POLL_INTERVAL_SECONDS, EXTERNAL_RESULT, InMemoryPatchStore, Service, ValFSHost, ValLoginError, ValModuleLoader, ValOpsFS, ValOpsHttp, ValOpsMemory, ValSourceFileHandler, analyzeValModule, awaitValLoginConfirmation, checkRemoteRef, classifyJsonValuesOp, compareWithCapturedReport, createDefaultValFSHost, createFixPatch, createJsonEntryPathMap, createModulePathMap, createService, createValApiRouter, createValModuleFileInspector, createValOps, createValServer, currentFixHandlers, decodeJwtWithoutVerifying, defineExternal, describePatchStoreProblems, downloadFileFromRemote, encodeJwt, err, evalValConfigFile, extractFileMetadata, extractImageMetadata, extractJsonValuesEntry, findAndEvalValConfigFile, findJsonEntryFilePath, fixHandlers, formatPatchSourceError, formatSyntaxErrorTree, getCachedRemoteFileDir, getCachedRemoteFilePath, getCompilerOptions, getExpire, getFileExt, getModulePathRange, getPersonalAccessTokenPath, getSettings, getValidationErrorFileRef, handleCheckAllFiles, handleExternalUpload, handleFileMetadata, handleJsonValuesExtractEntry, handleRemoteFileCheck, handleRemoteFileDownload, handleRemoteFileUpload, handleRemoteGalleryFileUpload, handleUniqueFolderCheck, initHandlerOptions, isExternalResult, loadValModules, ok, parsePersonalAccessTokenFile, patchSourceFile, persistPersonalAccessToken, planJsonValuesEntryExtraction, readCapturedReport, readPatchStore, rebaseContentOp, replaySnapshot, resolveRemoteFileAuth, safeReadGit, startValLogin, uploadRemoteFile, validateMetadata, verifyJwt };