@valbuild/server 0.129.0 → 0.131.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.
@@ -2529,6 +2529,17 @@ class ValOps {
2529
2529
  adopt[moduleFilePath] = source;
2530
2530
  }
2531
2531
  this.promoteCommittedSources(adopt);
2532
+ /**
2533
+ * And the `.val.ts` TEXT, for a store that has nowhere else to keep it.
2534
+ *
2535
+ * Everything above adopts the SOURCE — the data a module evaluates to. The
2536
+ * file the patch was applied TO is a separate thing, and `getSourceFile` is
2537
+ * what the next `prepare()` reads. In `fs` mode that is the disk, which
2538
+ * `saveOrUploadFiles` has just rewritten, so this is a no-op. A store with
2539
+ * no disk has to be told, or every later save re-reads the source as it was
2540
+ * when the process started and silently reverts this one.
2541
+ */
2542
+ this.adoptPatchedSourceFiles(preparedCommit.patchedSourceFiles);
2532
2543
  /**
2533
2544
  * And the `.jsonValues()` entry content, which the sources above do not
2534
2545
  * carry — they hold markers. See {@link adoptedJsonEntries}.
@@ -4087,7 +4098,56 @@ class ValOps {
4087
4098
  });
4088
4099
  }
4089
4100
 
4101
+ /**
4102
+ * Take the `.val.ts` text a commit produced as the new committed source.
4103
+ *
4104
+ * A no-op where {@link getSourceFile} reads something the commit already
4105
+ * wrote — the disk in `fs` mode, the content service in `http` mode. Override
4106
+ * it in a store that holds the source itself. `null` means the commit deleted
4107
+ * the file.
4108
+ */
4109
+ adoptPatchedSourceFiles(_files) {}
4110
+
4090
4111
  // #region abstract ops
4112
+ /**
4113
+ * Whether the patches live HERE, in this server, or in Val's content service.
4114
+ *
4115
+ * Almost everything the routes branch on comes from this one fact, which is
4116
+ * why it is a named property rather than an `instanceof`. If this server owns
4117
+ * the store then there is no content service to authenticate to (so an absent
4118
+ * or unverifiable session is anonymous rather than a 401), no shared store for
4119
+ * a patch group to separate authors in, no deployments to report, and a
4120
+ * "publish" writes what the host does with it rather than pushing a commit. If
4121
+ * it does not, every one of those is the content service's and this server is
4122
+ * relaying.
4123
+ *
4124
+ * There were two implementations when the routes were written and `instanceof
4125
+ * ValOpsFS` meant this; a third made that reading wrong in a way that compiles
4126
+ * silently -- a store that is local, answers none of the checks, and gets the
4127
+ * http path with no content service behind it.
4128
+ *
4129
+ * It is also what `/stat` reports as `mode`. The wire name predates the third
4130
+ * implementation and names a class, but the question the client is asking is
4131
+ * this one: does it auto-save and hide the account panel, or does it publish.
4132
+ */
4133
+
4134
+ /**
4135
+ * Whether a request must carry a session this server verified.
4136
+ *
4137
+ * Split out of {@link patchesAreLocal}, which was answering two questions at
4138
+ * once. "Does this store auto-save or publish" is a BEHAVIOUR question, and
4139
+ * it is what `/stat` reports and the UI keys off. "May an unauthenticated
4140
+ * request write here" is a SECURITY question. With two implementations the
4141
+ * answers coincided -- fs is local dev where no credential exists, http is
4142
+ * remote -- so one flag served both and nothing noticed.
4143
+ *
4144
+ * A third implementation splits them. `ValOpsMemory`'s store is local, which
4145
+ * makes the first answer yes, and it is designed to run DEPLOYED, which makes
4146
+ * the second answer no. Reusing one flag gave a deployed host `getAuth`
4147
+ * returning anonymous success for a missing cookie, an invalid JWT, an
4148
+ * unparseable payload, or no configured secret -- on all 29 routes, including
4149
+ * the ones that create patches and publish.
4150
+ */
4091
4151
 
4092
4152
  /**
4093
4153
  * Save a patch's binary file from a `data:...;base64,...` URL.
@@ -5684,6 +5744,13 @@ function serializeSchemaSafely(schema) {
5684
5744
  class ValOpsFS extends ValOps {
5685
5745
  static VAL_DIR = ".val";
5686
5746
  host;
5747
+ /** The developer's own working tree. See {@link ValOps.patchesAreLocal}. */
5748
+ patchesAreLocal = true;
5749
+ /**
5750
+ * The developer's own machine, where there is no credential to require and
5751
+ * nothing to protect it from. See {@link ValOps.requiresAuth}.
5752
+ */
5753
+ requiresAuth = false;
5687
5754
  constructor(contentUrl, rootDir, valModules, options) {
5688
5755
  super(valModules, options);
5689
5756
  this.contentUrl = contentUrl;
@@ -7228,6 +7295,10 @@ const PATCH_GROUPS_CACHE_MS = 1000;
7228
7295
  class ValOpsHttp extends ValOps {
7229
7296
  authHeaders;
7230
7297
  root;
7298
+ /** Val's content service owns the store. See {@link ValOps.patchesAreLocal}. */
7299
+ patchesAreLocal = false;
7300
+ /** See {@link ValOps.requiresAuth}. */
7301
+ requiresAuth = true;
7231
7302
  constructor(contentUrl, project, commitSha,
7232
7303
  // TODO: CommitSha
7233
7304
  branch,
@@ -8659,6 +8730,643 @@ class ValOpsHttp extends ValOps {
8659
8730
  // #endregion history
8660
8731
  }
8661
8732
 
8733
+ /**
8734
+ * One stored patch. Everything the ordered chain needs and nothing else.
8735
+ */
8736
+
8737
+ /**
8738
+ * One pending binary file: an upload that has not been published yet.
8739
+ *
8740
+ * Keyed by the patch that carries it, exactly as `fs` mode keys the directory
8741
+ * it writes to. The patch's own `file` op holds a hash, not the bytes, so these
8742
+ * arrive on their own request and are joined up by `(patchId, filePath)`.
8743
+ */
8744
+
8745
+ /**
8746
+ * Where pending patches live.
8747
+ *
8748
+ * The point of the interface: `ValOpsMemory` is not tied to memory. The default
8749
+ * implementation below is explicitly NOT durable -- it dies with the process,
8750
+ * or in a Worker with the isolate -- and a durable one (a Durable Object, whose
8751
+ * single-threaded execution is the lock Val's fs store builds out of a file)
8752
+ * is a swap rather than a rewrite.
8753
+ *
8754
+ * ORDER IS THE CONTRACT. `list()` returns patch ids in the order they were
8755
+ * written, and that order is the chain: entry i's parent is entry i-1. This is
8756
+ * the same decision `ValOpsFS` makes with `patches.log` -- the server decides
8757
+ * where a patch goes, and it goes last -- and it exists for the same reason: an
8758
+ * order held in the patches themselves lets a client working from a stale view
8759
+ * strand every patch behind a parent that never landed.
8760
+ */
8761
+
8762
+ /** The default store. Not durable, deliberately and visibly. */
8763
+ class InMemoryPatchStore {
8764
+ order = [];
8765
+ byId = new Map();
8766
+ async list() {
8767
+ return [...this.order];
8768
+ }
8769
+ async get(patchId) {
8770
+ return this.byId.get(patchId) ?? null;
8771
+ }
8772
+ async append(patch) {
8773
+ if (!this.byId.has(patch.patchId)) {
8774
+ this.order.push(patch.patchId);
8775
+ }
8776
+ this.byId.set(patch.patchId, patch);
8777
+ }
8778
+ async delete(patchIds) {
8779
+ for (const patchId of patchIds) {
8780
+ const at = this.order.indexOf(patchId);
8781
+ if (at !== -1) {
8782
+ this.order.splice(at, 1);
8783
+ }
8784
+ this.byId.delete(patchId);
8785
+ // The bytes go with the patch. They are only reachable through it, so
8786
+ // keeping them would be a leak with no reader -- and these are images.
8787
+ for (const key of this.files.keys()) {
8788
+ if (key.startsWith(`${patchId}\u0000`)) {
8789
+ this.files.delete(key);
8790
+ }
8791
+ }
8792
+ }
8793
+ }
8794
+
8795
+ /*
8796
+ * `\u0000` cannot occur in a patch id or a path, so the two halves of the key
8797
+ * can never run together into a different pair.
8798
+ *
8799
+ * The PATH is normalised for the same reason `sourceFiles` is, and it is not
8800
+ * cosmetic: an upload arrives as `/public/val/x.png`, and the publish step
8801
+ * looks the same file up as `public/val/x.png` because that is what
8802
+ * `splitRemoteRef` yields from a remote ref. `ValOpsFS` never noticed --
8803
+ * `path.join` collapses the difference -- so this is another thing a
8804
+ * filesystem was doing for free. Without it a remote image uploads fine,
8805
+ * patches fine, and fails at publish with "No bytes held", naming a ref whose
8806
+ * bytes are sitting right there under the other spelling.
8807
+ */
8808
+ static fileKey(patchId, filePath) {
8809
+ return `${patchId}\u0000${filePath.replace(/^\//, "")}`;
8810
+ }
8811
+ files = new Map();
8812
+ async putFile(file) {
8813
+ this.files.set(InMemoryPatchStore.fileKey(file.patchId, file.filePath), file);
8814
+ }
8815
+ async getFile(patchId, filePath) {
8816
+ return this.files.get(InMemoryPatchStore.fileKey(patchId, filePath)) ?? null;
8817
+ }
8818
+ async deleteFile(patchId, filePath) {
8819
+ this.files.delete(InMemoryPatchStore.fileKey(patchId, filePath));
8820
+ }
8821
+ async filesOf(patchId) {
8822
+ return [...this.files.values()].filter(file => file.patchId === patchId);
8823
+ }
8824
+ }
8825
+ /**
8826
+ * A `ValOps` for a host that is neither a developer's machine nor
8827
+ * content.val.build.
8828
+ *
8829
+ * EXPERIMENTAL -- see VAL_PROMPT.md.
8830
+ *
8831
+ * `fs` mode assumes a working tree it can watch and write; `http` mode assumes
8832
+ * the content API owns the patch chain and a commit means a git commit. A host
8833
+ * that builds and publishes its own output is neither: it holds the source
8834
+ * already, it has nowhere to watch, and its "commit" is a new build.
8835
+ *
8836
+ * What this deliberately does NOT do:
8837
+ *
8838
+ * - **No filesystem.** Source comes from `sourceFiles`, patches from a store.
8839
+ * - **No watching.** `getStat` still long-polls -- the hold is what paces the
8840
+ * client -- but it parks on a signal rather than racing a timer against an
8841
+ * mtime poll that can never observe anything here. Nothing can edit files
8842
+ * behind Val's back: source changes only when the host publishes, and that
8843
+ * replaces the process.
8844
+ * - **Pending binary files, but no PUBLISHED local ones.** An upload is held in
8845
+ * the patch store like any other pending change, so the Studio can preview it
8846
+ * before it is published. What this has no answer for is a file that is
8847
+ * already published and served from a `/public` directory: this configuration
8848
+ * uses Val's REMOTE files, where a published image lives on the content host
8849
+ * and the source carries a URL. `getBinaryFile` answers `null` for those --
8850
+ * a miss, not a fault -- and `getBinaryFileMetadata` refuses by name.
8851
+ * - **No git history.** There is no repository here, so the history methods
8852
+ * answer `not-supported-in-fs-mode` -- the same closed error `ValOpsFS`
8853
+ * uses, so the History UI degrades the way it already knows how rather than
8854
+ * inventing a commit list. See the note above `listCommits`.
8855
+ */
8856
+ class ValOpsMemory extends ValOps {
8857
+ /**
8858
+ * The host's own store -- see {@link ValOps.patchesAreLocal}. `true` for the
8859
+ * same reason `fs` mode is: nothing is relayed to a content service, so
8860
+ * there is no session to verify against one and no group to separate authors
8861
+ * in. Where the two differ is not something a route asks about.
8862
+ */
8863
+ patchesAreLocal = true;
8864
+ /**
8865
+ * Required, unless the host explicitly takes the boundary itself.
8866
+ *
8867
+ * `patchesAreLocal` is true here and that is about publishing, not about who
8868
+ * may write -- see {@link ValOps.requiresAuth}.
8869
+ */
8870
+ requiresAuth;
8871
+ store;
8872
+ /**
8873
+ * The project's source, keyed WITHOUT a leading slash.
8874
+ *
8875
+ * Two spellings reach this. A host keys by project-relative path
8876
+ * (`src/routes/page.val.ts`) because that is what it built from; Val asks and
8877
+ * commits with a leading slash (`/src/routes/page.val.ts`). Normalised on the
8878
+ * way in so there is one entry per file — holding both spellings would let a
8879
+ * commit update one and leave the other as the stale answer.
8880
+ *
8881
+ * Not `readonly`: a save replaces the files it rewrote. See
8882
+ * {@link adoptPatchedSourceFiles}.
8883
+ */
8884
+ sourceFiles;
8885
+ contentUrl;
8886
+ constructor(valModules, options) {
8887
+ super(valModules, options);
8888
+ this.store = options.patchStore ?? new InMemoryPatchStore();
8889
+ this.contentUrl = options.contentUrl;
8890
+ this.requiresAuth = !options.unsafelyAllowUnauthenticated;
8891
+ if (options.unsafelyAllowUnauthenticated) {
8892
+ 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.");
8893
+ }
8894
+ this.sourceFiles = Object.fromEntries(Object.entries(options.sourceFiles).map(([path, text]) => [ValOpsMemory.key(path), text]));
8895
+ }
8896
+
8897
+ // #region the six that carry the mode
8898
+
8899
+ async onInit() {
8900
+ // Nothing to prepare: there is no directory to create and no store to open.
8901
+ }
8902
+
8903
+ /**
8904
+ * Requests parked in {@link getStat}, waiting for something to happen.
8905
+ *
8906
+ * See there for why this exists. Resolved and emptied by
8907
+ * {@link announceChange}; never rejected, because a waiter that gives up does
8908
+ * so on its own timeout.
8909
+ */
8910
+ statWaiters = [];
8911
+
8912
+ /**
8913
+ * Writes so far. Sampled before reading, compared after registering.
8914
+ *
8915
+ * The registration is not atomic with the read above it: a patch written
8916
+ * between `currentStat()` and `statWaiters.push` was announced to a list this
8917
+ * waiter was not yet on, so the request slept the full interval with a change
8918
+ * already sitting there. Comparing the count closes that window without a
8919
+ * lock.
8920
+ */
8921
+ changeCount = 0;
8922
+
8923
+ /** Wake every parked `getStat`. Called by this instance's own writes. */
8924
+ announceChange() {
8925
+ this.changeCount += 1;
8926
+ const waiting = this.statWaiters;
8927
+ this.statWaiters = [];
8928
+ for (const wake of waiting) {
8929
+ wake();
8930
+ }
8931
+ }
8932
+ async currentStat() {
8933
+ return {
8934
+ baseSha: await this.getBaseSha(),
8935
+ schemaSha: await this.getSchemaSha(),
8936
+ sourcesSha: await this.getSourcesSha(),
8937
+ patches: await this.store.list()
8938
+ };
8939
+ }
8940
+ async getStat(params) {
8941
+ /*
8942
+ * A long poll, and it has to be one -- but parked on a SIGNAL rather than a
8943
+ * timer.
8944
+ *
8945
+ * The hold is what paces the client. `useStatus.ts` sets `wait: 0` between
8946
+ * stats unless it has a WebSocket, with the comment "we are long polling so
8947
+ * no point in waiting": the server holding the request open IS the rate
8948
+ * limit. An earlier version of this method answered immediately, on the
8949
+ * reasoning that nothing can edit files behind Val's back in an isolate --
8950
+ * true, and beside the point. It turned a 20s poll into a request every
8951
+ * 6ms, which is worse than what it replaced.
8952
+ *
8953
+ * What was actually wrong with `fs` mode here is not the hold, it is the
8954
+ * WATCHING: it races a 250ms mtime poll and an `fs.watch`, neither of which
8955
+ * can observe anything in an isolate (the shim's `watch` never fires and
8956
+ * mtime is always 0), so it burns CPU for 20s to learn nothing. This owns
8957
+ * its store, so it is TOLD instead -- zero timers while parked, and a patch
8958
+ * written by another tab is seen at once rather than up to 250ms later.
8959
+ *
8960
+ * The timeout is the backstop, and it is not only for an idle branch: a
8961
+ * store shared between isolates (a Durable Object -- see ValPatchStore) can
8962
+ * change without this instance writing anything, and nothing would announce
8963
+ * that. So it re-reads on the way out rather than assuming `no-change`.
8964
+ */
8965
+ // Before the read, not after: anything that happens from here on must be
8966
+ // observed, either by `differs` below or by the recheck in parkUntilChange.
8967
+ const seenAt = this.changeCount;
8968
+ const before = await this.currentStat();
8969
+ const differs = now => params === null || params.baseSha !== now.baseSha || params.schemaSha !== now.schemaSha || (params.patches ?? []).join(",") !== now.patches.join(",");
8970
+
8971
+ // Already behind: answer now. This is the case that matters for latency --
8972
+ // the client has just written a patch and is asking what happened.
8973
+ if (differs(before)) {
8974
+ return {
8975
+ type: "did-change",
8976
+ ...before
8977
+ };
8978
+ }
8979
+ await this.parkUntilChange(seenAt);
8980
+ const after = await this.currentStat();
8981
+ return {
8982
+ type: differs(after) ? "did-change" : "no-change",
8983
+ ...after
8984
+ };
8985
+ }
8986
+
8987
+ /** Resolves on the next write here, or when the poll interval runs out. */
8988
+ parkUntilChange(seenAt) {
8989
+ var _this$options;
8990
+ const timeoutMs = ((_this$options = this.options) === null || _this$options === void 0 ? void 0 : _this$options.statPollingInterval) ?? 20_000;
8991
+ return new Promise(resolve => {
8992
+ let settled = false;
8993
+ const done = () => {
8994
+ if (settled) return;
8995
+ settled = true;
8996
+ // Cleared on BOTH paths: a change that wins the race leaves a 20s timer
8997
+ // behind otherwise, and in a Worker a pending timer is a reason to keep
8998
+ // the isolate alive.
8999
+ clearTimeout(timer);
9000
+ // Removed on BOTH paths too, for the same kind of reason. Only
9001
+ // `announceChange` emptied this list, so a timed-out waiter stayed on
9002
+ // it forever: one dead closure per idle poll, on a server that polls
9003
+ // every 20 seconds indefinitely, and a later write walked all of them.
9004
+ const at = this.statWaiters.indexOf(done);
9005
+ if (at !== -1) {
9006
+ this.statWaiters.splice(at, 1);
9007
+ }
9008
+ resolve();
9009
+ };
9010
+ const timer = setTimeout(done, timeoutMs);
9011
+ this.statWaiters.push(done);
9012
+ // Registered; now look again. A write in the gap announced to a list this
9013
+ // waiter was not on yet, and waiting 20s for news that already arrived is
9014
+ // the bug this closes.
9015
+ if (this.changeCount !== seenAt) {
9016
+ done();
9017
+ }
9018
+ });
9019
+ }
9020
+ async fetchPatches(filters) {
9021
+ // An empty `patchIds` means "no filter", not "none": both shipped
9022
+ // implementations read it that way and callers rely on it. See
9023
+ // `scopedModulePatches` in ValOps.ts, which says why at length.
9024
+ const requested = filters.patchIds && filters.patchIds.length > 0 ? new Set(filters.patchIds) : null;
9025
+ const order = await this.store.list();
9026
+ const patches = [];
9027
+ for (const patchId of order) {
9028
+ if (requested !== null && !requested.has(patchId)) {
9029
+ continue;
9030
+ }
9031
+ const stored = await this.store.get(patchId);
9032
+ if (!stored) {
9033
+ continue;
9034
+ }
9035
+ patches.push({
9036
+ patchId: stored.patchId,
9037
+ path: stored.path,
9038
+ patch: stored.patch,
9039
+ createdAt: stored.createdAt,
9040
+ authorId: stored.authorId,
9041
+ baseSha: stored.baseSha,
9042
+ // Nothing here is ever applied-at-a-commit: a publish in this mode
9043
+ // rebuilds the site and starts a new process, so a patch that has been
9044
+ // applied is a patch this store no longer holds.
9045
+ appliedAt: null
9046
+ });
9047
+ }
9048
+ // The cast is unavoidable: the return type is conditional on a generic
9049
+ // TypeScript cannot narrow from a value. `ValOpsFS` does the same.
9050
+ return {
9051
+ patches: filters.excludePatchOps ? patches.map(({
9052
+ patch: _patch,
9053
+ ...rest
9054
+ }) => ({
9055
+ ...rest,
9056
+ patch: undefined
9057
+ })) : patches
9058
+ };
9059
+ }
9060
+ async saveSourceFilePatch(path, patch, patchId,
9061
+ /*
9062
+ * Ignored, exactly as in `fs` mode, and named so that is visible.
9063
+ *
9064
+ * The store's order IS the chain, so the server decides where a patch goes
9065
+ * and it goes last. There is no parent to name, and so nothing that can
9066
+ * point at nothing. A patch computed against a different state is caught
9067
+ * where it shows -- applying it -- rather than by refusing the write.
9068
+ */
9069
+ _parentRef, authorId, _sessionId,
9070
+ /* No shared store and no second author yet, so nothing for a group to
9071
+ * separate. Named rather than omitted: TypeScript allows an implementation
9072
+ * to take fewer parameters, so dropping it would look identical to
9073
+ * handling it. */
9074
+ _patchGroup) {
9075
+ await this.store.append({
9076
+ patchId,
9077
+ path,
9078
+ patch,
9079
+ authorId,
9080
+ createdAt: new Date().toISOString(),
9081
+ baseSha: await this.getBaseSha()
9082
+ });
9083
+ // Any `getStat` parked on this instance answers now instead of waiting out
9084
+ // its timeout. This is the whole reason the hold can be free: the store is
9085
+ // ours, so there is nothing to poll for.
9086
+ this.announceChange();
9087
+ return fp.result.ok({
9088
+ patchId
9089
+ });
9090
+ }
9091
+
9092
+ /** One spelling for a path, whichever the caller used. See sourceFiles. */
9093
+ static key(path) {
9094
+ return path.replace(/^\//, "");
9095
+ }
9096
+
9097
+ /**
9098
+ * A save has rewritten these files; they are the committed source now.
9099
+ *
9100
+ * Without this every save after the first re-reads the source as it was when
9101
+ * this object was built, applies only its own patches to that, and parks a
9102
+ * file that reverts everything saved before it -- with no error, because
9103
+ * applying the patch to the ORIGINAL text succeeds. The Studio auto-saves, so
9104
+ * that is not an edge case: it is most of a session's work.
9105
+ *
9106
+ * `fs` mode gets this for free -- `saveOrUploadFiles` writes the disk that
9107
+ * `getSourceFile` reads. There is no disk here, so it is written down.
9108
+ */
9109
+ adoptPatchedSourceFiles(files) {
9110
+ for (const [path, text] of Object.entries(files)) {
9111
+ const key = ValOpsMemory.key(path);
9112
+ if (text === null) {
9113
+ delete this.sourceFiles[key];
9114
+ } else {
9115
+ this.sourceFiles[key] = text;
9116
+ }
9117
+ }
9118
+ }
9119
+ async getSourceFile(path) {
9120
+ const data = this.sourceFiles[ValOpsMemory.key(path)];
9121
+ if (data === undefined) {
9122
+ return {
9123
+ error: {
9124
+ 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.`
9125
+ }
9126
+ };
9127
+ }
9128
+ return {
9129
+ data
9130
+ };
9131
+ }
9132
+ async deletePatches(patchIds) {
9133
+ await this.store.delete(patchIds);
9134
+ this.announceChange();
9135
+ return {
9136
+ deleted: patchIds
9137
+ };
9138
+ }
9139
+
9140
+ /**
9141
+ * Push this commit's pending binary files to Val's content host.
9142
+ *
9143
+ * The half of publishing that `commitPrepared` cannot do. Val's remote files
9144
+ * upload at PUBLISH, not when the image is added: until then the bytes are a
9145
+ * pending change like any other, held by {@link ValPatchStore}. So a publish
9146
+ * has to walk the descriptors and push each one before the source that
9147
+ * references it goes live -- otherwise the new build ships a URL that 404s.
9148
+ *
9149
+ * `ValOpsFS.saveOrUploadFiles` does the same loop, alongside two things this
9150
+ * has no use for: copying LOCAL binaries into a working tree, and writing the
9151
+ * source files (which is `commitPrepared` here). Kept separate rather than
9152
+ * shared, because the shapes only look alike.
9153
+ *
9154
+ * Errors are collected rather than thrown. One image that will not upload
9155
+ * should name itself and leave the rest of the publish decidable, rather than
9156
+ * failing a save that has already applied its patches.
9157
+ */
9158
+ async uploadRemoteFiles(preparedCommit, auth) {
9159
+ var _this$options2;
9160
+ const uploaded = [];
9161
+ const errors = {};
9162
+ const project = (_this$options2 = this.options) === null || _this$options2 === void 0 ? void 0 : _this$options2.config.project;
9163
+ const descriptors = Object.entries(preparedCommit.patchedBinaryFilesDescriptors);
9164
+ const remote = descriptors.filter(([, descriptor]) => descriptor.remote);
9165
+
9166
+ /*
9167
+ * A LOCAL descriptor is refused, not ignored.
9168
+ *
9169
+ * This mode has no published local-file path -- there is no `/public` to
9170
+ * write into -- and the class docstring says so. But filtering to `remote`
9171
+ * meant a local one fell through silently, and the caller carries on:
9172
+ * source is adopted and the patch deleted, so `/save` answers 200 and the
9173
+ * uploaded bytes are gone with nothing anywhere saying why.
9174
+ *
9175
+ * Erroring here keeps the patch, because a save that cannot store what it
9176
+ * was given has not succeeded.
9177
+ */
9178
+ for (const [ref, descriptor] of descriptors) {
9179
+ if (descriptor.remote) continue;
9180
+ errors[ref] = {
9181
+ 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."
9182
+ };
9183
+ }
9184
+ if (remote.length === 0) {
9185
+ return {
9186
+ uploaded,
9187
+ errors
9188
+ };
9189
+ }
9190
+ if (!this.contentUrl || !project) {
9191
+ // Named separately from a failed upload: nothing was attempted, and the
9192
+ // fix is configuration rather than a retry.
9193
+ for (const [ref] of remote) {
9194
+ errors[ref] = {
9195
+ 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."
9196
+ };
9197
+ }
9198
+ return {
9199
+ uploaded,
9200
+ errors
9201
+ };
9202
+ }
9203
+ for (const [ref, {
9204
+ patchId
9205
+ }] of remote) {
9206
+ const split = core.Internal.remote.splitRemoteRef(ref);
9207
+ if (split.status === "error") {
9208
+ errors[ref] = {
9209
+ message: "Failed to split remote ref: " + ref
9210
+ };
9211
+ continue;
9212
+ }
9213
+ const bytes = await this.getBase64EncodedBinaryFileFromPatch(split.filePath, patchId);
9214
+ if (!bytes) {
9215
+ errors[ref] = {
9216
+ message: `No bytes held for ${ref} (patch ${patchId}). The upload either never arrived or was dropped with its patch.`
9217
+ };
9218
+ continue;
9219
+ }
9220
+ const res = await uploadRemoteFile(this.contentUrl, project, split.bucket, split.fileHash, getFileExt(split.filePath), bytes, auth);
9221
+ if (!res.success) {
9222
+ errors[ref] = {
9223
+ message: res.error
9224
+ };
9225
+ continue;
9226
+ }
9227
+ uploaded.push(ref);
9228
+ }
9229
+ return {
9230
+ uploaded,
9231
+ errors
9232
+ };
9233
+ }
9234
+
9235
+ // #endregion
9236
+ // #region published local files -- these refuse rather than pretend
9237
+
9238
+ remoteOnly(method) {
9239
+ 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.`);
9240
+ }
9241
+ async saveBase64EncodedBinaryFileFromPatch(filePath,
9242
+ /*
9243
+ * Ignored, as in `fs` mode: the file is keyed by the patch that carries it,
9244
+ * so there is no parent to resolve.
9245
+ */
9246
+ _parentRef, patchId, data, _type, metadata) {
9247
+ if (data === null) {
9248
+ // `null` records a DELETION. This is why the byte-taking sibling cannot
9249
+ // replace this method: there would be nothing to hand it.
9250
+ await this.store.deleteFile(patchId, filePath);
9251
+ return {
9252
+ patchId,
9253
+ filePath
9254
+ };
9255
+ }
9256
+ const buffer = bufferFromDataUrl(data);
9257
+ if (!buffer) {
9258
+ return {
9259
+ error: {
9260
+ message: "Could not create buffer from data url. Not a data url? First chars were: " + data.slice(0, 20)
9261
+ }
9262
+ };
9263
+ }
9264
+ await this.store.putFile({
9265
+ patchId,
9266
+ filePath,
9267
+ data: buffer,
9268
+ metadata
9269
+ });
9270
+ return {
9271
+ patchId,
9272
+ filePath
9273
+ };
9274
+ }
9275
+ async getBase64EncodedBinaryFileFromPatch(filePath, patchId,
9276
+ /*
9277
+ * Not consulted, and `fs` mode does not consult it either: a remote ref has
9278
+ * already been split by the caller, so what arrives here is the path inside
9279
+ * it and the pair (patchId, filePath) is the whole key.
9280
+ */
9281
+ _remote) {
9282
+ var _await$this$store$get;
9283
+ 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;
9284
+ }
9285
+ async getBase64EncodedBinaryFileMetadataFromPatch(filePath, type, patchId, _remote) {
9286
+ const file = await this.store.getFile(patchId, filePath);
9287
+ if (!file || file.metadata === undefined) {
9288
+ return {
9289
+ errors: [{
9290
+ message: "Metadata file not found",
9291
+ filePath
9292
+ }]
9293
+ };
9294
+ }
9295
+ /*
9296
+ * Checked, not trusted. The metadata is whatever the client sent with the
9297
+ * upload, and a missing `width` on an image is the difference between a
9298
+ * layout that reserves space and one that jumps -- reported here, where the
9299
+ * field is named, rather than surfacing as an undefined further on.
9300
+ */
9301
+ const fieldErrors = getFieldsForType(type).filter(field => !(field in file.metadata)).map(field => ({
9302
+ message: `Expected fields for type: ${type}. Field not found: '${field}'`,
9303
+ field
9304
+ }));
9305
+ if (fieldErrors.length > 0) {
9306
+ return {
9307
+ errors: fieldErrors
9308
+ };
9309
+ }
9310
+ return {
9311
+ metadata: file.metadata
9312
+ };
9313
+ }
9314
+ async getBinaryFile(_filePathOrRef) {
9315
+ // Null rather than a throw: callers treat this as "no such file", and a
9316
+ // read of a local file in a remote-files project is a miss, not a fault.
9317
+ return null;
9318
+ }
9319
+ async getBinaryFileMetadata(_filePath, _type) {
9320
+ return this.remoteOnly("getBinaryFileMetadata");
9321
+ }
9322
+
9323
+ // #endregion
9324
+ // #region no git here
9325
+
9326
+ // The same answer `ValOpsFS` gives, for the same reason and reusing its
9327
+ // error: history is a thing the content service holds, and there is no
9328
+ // repository behind this mode either. `not-supported-in-fs-mode` is the
9329
+ // closed union's member for exactly that, and the Studio already knows how
9330
+ // to show it -- a mode-specific member would be a new state to write UI for
9331
+ // that says the same sentence.
9332
+
9333
+ async listCommits() {
9334
+ return fp.result.err({
9335
+ kind: "not-supported-in-fs-mode"
9336
+ });
9337
+ }
9338
+ async getCommitPatches() {
9339
+ return fp.result.err({
9340
+ kind: "not-supported-in-fs-mode"
9341
+ });
9342
+ }
9343
+ async getCommitModules() {
9344
+ return fp.result.err({
9345
+ kind: "not-supported-in-fs-mode"
9346
+ });
9347
+ }
9348
+ async getCommitAffectedFiles() {
9349
+ return fp.result.err({
9350
+ kind: "not-supported-in-fs-mode"
9351
+ });
9352
+ }
9353
+ async getFileAtCommit() {
9354
+ return fp.result.err({
9355
+ kind: "not-supported-in-fs-mode"
9356
+ });
9357
+ }
9358
+ gitPathOfModule(
9359
+ // Unused: there is no repository for a module path to be relative to.
9360
+ _moduleFilePath) {
9361
+ return fp.result.err({
9362
+ kind: "not-supported-in-fs-mode"
9363
+ });
9364
+ }
9365
+ // #endregion history
9366
+ }
9367
+
9368
+ /** Kept exported so a host can name the error shape it may get back. */
9369
+
8662
9370
  /**
8663
9371
  * Everything that can go wrong reading, replaying or restoring history.
8664
9372
  *
@@ -9206,15 +9914,27 @@ moduleGitPath, valTsSource, key) {
9206
9914
  return fp.result.ok(path__namespace["default"].posix.join(moduleDir, entry.importPath));
9207
9915
  }
9208
9916
 
9209
- const host = process.env.VAL_CONTENT_URL || core.DEFAULT_CONTENT_HOST;
9917
+ /**
9918
+ * The fallback content host.
9919
+ *
9920
+ * Read at module scope, which is why {@link getSettings} takes an override: a
9921
+ * host that bundles its dependencies SEPARATELY from its own code cannot set
9922
+ * this. Such a build inlines env vars into the app and leaves dependency chunks
9923
+ * alone, so `process.env.VAL_CONTENT_URL` here is whatever it was when the
9924
+ * chunk was built -- usually nothing. A caller that knows the content url has
9925
+ * to be able to say so.
9926
+ */
9927
+ const defaultHost$1 = process.env.VAL_CONTENT_URL || core.DEFAULT_CONTENT_HOST;
9210
9928
  const SettingsSchema = z.z.object({
9211
9929
  publicProjectId: z.z.string(),
9212
9930
  remoteFileBuckets: z.z.array(z.z.object({
9213
9931
  bucket: z.z.string()
9214
9932
  }))
9215
9933
  });
9216
- async function getSettings(projectName, auth) {
9934
+ async function getSettings(projectName, auth, /** Overrides {@link defaultHost}. See the note there for why this exists. */
9935
+ contentUrl) {
9217
9936
  try {
9937
+ const host = contentUrl || defaultHost$1;
9218
9938
  const response = await fetch(`${host}/v1/${projectName}/settings`, {
9219
9939
  headers: "pat" in auth ? {
9220
9940
  "x-val-pat": auth.pat,
@@ -9341,6 +10061,73 @@ const DEFAULT_VAL_BUILD_URL = "https://admin.val.build";
9341
10061
  * var alone.
9342
10062
  */
9343
10063
  async function initHandlerOptions(route, opts, config) {
10064
+ /*
10065
+ * A host that handed us the source has settled the question.
10066
+ *
10067
+ * First, and without consulting the environment: the other two modes are
10068
+ * inferred (an api key in the env is enough to make a project "proxy"), and
10069
+ * this one cannot be, so an env var that happens to be set must not be able
10070
+ * to take a host that supplied its own source and point it at a content
10071
+ * service instead.
10072
+ */
10073
+ if (opts.sourceFiles !== undefined) {
10074
+ const valContentUrl = opts.valContentUrl || process.env.VAL_CONTENT_URL || core.DEFAULT_CONTENT_HOST;
10075
+ const valBuildUrl = opts.valBuildUrl || process.env.VAL_BUILD_URL || DEFAULT_VAL_BUILD_URL;
10076
+ /*
10077
+ * The same warning the other two modes get, and for the same reason.
10078
+ *
10079
+ * Returning early here skipped it, and the early return is about MODE
10080
+ * INFERENCE -- not about which URLs are safe. This mode still sends
10081
+ * `apiKey` to `valContentUrl` for remote-file settings and uploads, so a
10082
+ * host configured with a non-loopback `http://` content URL was putting a
10083
+ * credential on the wire with none of the warning fs and http modes give
10084
+ * for exactly that.
10085
+ */
10086
+ warnIfInsecureUrls({
10087
+ valBuildUrl,
10088
+ valContentUrl
10089
+ });
10090
+ return {
10091
+ mode: "memory",
10092
+ route,
10093
+ sourceFiles: opts.sourceFiles,
10094
+ patchStore: opts.patchStore,
10095
+ unsafelyAllowUnauthenticated: opts.unsafelyAllowUnauthenticated,
10096
+ valContentUrl,
10097
+ valBuildUrl,
10098
+ valEnableRedirectUrl: opts.valEnableRedirectUrl || process.env.VAL_ENABLE_REDIRECT_URL,
10099
+ valDisableRedirectUrl: opts.valDisableRedirectUrl || process.env.VAL_DISABLE_REDIRECT_URL,
10100
+ apiKey: opts.apiKey || process.env.VAL_API_KEY,
10101
+ valSecret: opts.valSecret || process.env.VAL_SECRET,
10102
+ project: opts.project || process.env.VAL_PROJECT,
10103
+ config
10104
+ };
10105
+ }
10106
+ /*
10107
+ * `VAL_MODE=memory` says the host MEANT to hold the source, and did not.
10108
+ *
10109
+ * It cannot SELECT memory mode -- nothing in the environment can supply
10110
+ * `sourceFiles`, and a mode turned on without them is a server with no
10111
+ * content in it. What it does is turn the fall-through into an error.
10112
+ *
10113
+ * Without it, a host that forgot to pass its source got `fs` mode, and `fs`
10114
+ * mode in a Worker isolate reaches for a working tree that is not there: the
10115
+ * failure is an `EPERM` on `.val/patches.lock`, several layers below the
10116
+ * mistake, naming a path rather than the decision that led to it. Every
10117
+ * environment that runs Val without a disk can set this once and get a
10118
+ * sentence instead.
10119
+ */
10120
+ const declaredMode = process.env.VAL_MODE;
10121
+ if (declaredMode === "memory") {
10122
+ throw new Error("VAL_MODE is 'memory', but no `sourceFiles` were given here, so there " + "is no source to serve. Memory mode cannot be turned on by the " + "environment: it needs the project's own source, and only the host " + "that holds it can hand it over. On TanStack Start that is the " + "`sourceFiles` option, passed to `initValServer` AND to " + "`initValContent`, which has a Val server of its own and is " + "configured separately. @valbuild/next has no memory mode yet, so " + "for a Next app this variable is set on an environment Val cannot " + "serve from. Unset " + "VAL_MODE to go back to the inferred mode instead ('http' when " + "VAL_API_KEY and VAL_SECRET are both set, 'fs' otherwise).");
10123
+ }
10124
+ // An empty value counts as unset, which is what `VAL_MODE=` in a shell or a
10125
+ // CI settings page means. Every other value is refused rather than ignored:
10126
+ // ignoring `VAL_MODE=memry` would leave the app in `fs` mode, which is the
10127
+ // exact failure this variable exists to catch.
10128
+ if (declaredMode !== undefined && declaredMode !== "") {
10129
+ throw new Error(`VAL_MODE is '${declaredMode}', which is not a mode Val knows. The only ` + "value it accepts is 'memory', which asserts that the host supplies " + "`sourceFiles`. 'fs' and 'http' are inferred rather than named: " + "'http' when VAL_API_KEY and VAL_SECRET are both set, 'fs' otherwise.");
10130
+ }
9344
10131
  const maybeApiKey = opts.apiKey || process.env.VAL_API_KEY;
9345
10132
  const maybeValSecret = opts.valSecret || process.env.VAL_SECRET;
9346
10133
  const isProxyMode = opts.mode === "proxy" || opts.mode === undefined && (maybeApiKey || maybeValSecret);
@@ -9454,6 +10241,29 @@ function createValOps(valModules, options) {
9454
10241
  config: options.config
9455
10242
  });
9456
10243
  }
10244
+ if (options.mode === "memory") {
10245
+ /*
10246
+ * No backend to authenticate AGAINST, which is not the same as nothing to
10247
+ * authenticate. That conflation is what made this mode serve every route to
10248
+ * anyone who could reach the port: fs mode skips auth because it is a
10249
+ * developer's own machine, and this one reuses its local-store flag while
10250
+ * running deployed. It requires a verified session unless the host says it
10251
+ * has its own boundary -- see `unsafelyAllowUnauthenticated`.
10252
+ *
10253
+ * The host still holds the source and decides what a publish means; that
10254
+ * part is `commitPrepared` on ValServerOptions.
10255
+ */
10256
+ return new ValOpsMemory(valModules, {
10257
+ formatter: options.formatter,
10258
+ config: options.config,
10259
+ sourceFiles: options.sourceFiles,
10260
+ patchStore: options.patchStore,
10261
+ unsafelyAllowUnauthenticated: options.unsafelyAllowUnauthenticated,
10262
+ // For pushing remote files at publish. A project with no `s.image()`
10263
+ // never reaches it, which is why nothing above requires it.
10264
+ contentUrl: options.valContentUrl
10265
+ });
10266
+ }
9457
10267
  throw new Error(
9458
10268
  // The union is exhausted above; this catches a config that came from
9459
10269
  // somewhere untyped.
@@ -9572,12 +10382,23 @@ async function resolveRemoteFileAuth(options) {
9572
10382
  };
9573
10383
  }
9574
10384
  if (options.mode !== "fs") {
9575
- // Unreachable through `initHandlerOptions`, which refuses to build a proxy
9576
- // config without an api key. Kept because this is exported.
10385
+ /*
10386
+ * `api-key-missing`, and the distinction matters to whoever reads it.
10387
+ *
10388
+ * The PAT below is read from a file in the server's own working directory,
10389
+ * which only `fs` mode has. Every other mode can be authenticated one way,
10390
+ * with an api key -- so the Studio must not offer `val login` here. It did,
10391
+ * because "local" used to mean "fs" and the third mode made that false: the
10392
+ * dialog told people to run a command, in a directory, that could not have
10393
+ * helped even if they found the right one.
10394
+ *
10395
+ * `project-not-configured` was also just wrong. The project may be
10396
+ * perfectly well configured; it is the credential that is absent.
10397
+ */
9577
10398
  return {
9578
10399
  status: "error",
9579
- errorCode: "project-not-configured",
9580
- message: "Remote file auth is not configured"
10400
+ errorCode: "api-key-missing",
10401
+ 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."
9581
10402
  };
9582
10403
  }
9583
10404
  // `options.cwd`, which `initHandlerOptions` sets from `process.cwd()`. The
@@ -9612,6 +10433,11 @@ async function resolveRemoteFileAuth(options) {
9612
10433
  }
9613
10434
 
9614
10435
  /* eslint-disable @typescript-eslint/no-unused-vars */
10436
+
10437
+ /** What {@link ValServerOptions.publishOverride} is given. */
10438
+
10439
+ /** What a publish reports back, in either shape. */
10440
+
9615
10441
  const ValServer = (valModules, options, callbacks) => {
9616
10442
  const AIContentBlock = z.z.union([z.z.object({
9617
10443
  type: z.z.literal("text"),
@@ -9706,8 +10532,17 @@ const ValServer = (valModules, options, callbacks) => {
9706
10532
  };
9707
10533
  const getAuth = cookies => {
9708
10534
  const cookie = cookies[internal.VAL_SESSION_COOKIE];
10535
+ /*
10536
+ * `requiresAuth`, not `patchesAreLocal`.
10537
+ *
10538
+ * These four exits return anonymous SUCCESS -- `{ error: null }` -- and all
10539
+ * 29 routes below treat that as authorised. That is right for fs mode,
10540
+ * which is a developer's own machine with no credential to require. It was
10541
+ * keyed on the wrong question: a local patch store is about publishing, not
10542
+ * about who may write, and memory mode is local AND deployed.
10543
+ */
9709
10544
  if (!options.valSecret) {
9710
- if (serverOps instanceof ValOpsFS) {
10545
+ if (!serverOps.requiresAuth) {
9711
10546
  return {
9712
10547
  error: null,
9713
10548
  id: null
@@ -9721,7 +10556,7 @@ const ValServer = (valModules, options, callbacks) => {
9721
10556
  if (typeof cookie === "string") {
9722
10557
  const verifiedToken = verifyJwt(cookie, options.valSecret);
9723
10558
  if (!verifiedToken.success) {
9724
- if (serverOps instanceof ValOpsFS) {
10559
+ if (!serverOps.requiresAuth) {
9725
10560
  return {
9726
10561
  error: null,
9727
10562
  id: null
@@ -9733,7 +10568,7 @@ const ValServer = (valModules, options, callbacks) => {
9733
10568
  }
9734
10569
  const verification = IntegratedServerJwtPayload.safeParse(verifiedToken.data);
9735
10570
  if (!verification.success) {
9736
- if (serverOps instanceof ValOpsFS) {
10571
+ if (!serverOps.requiresAuth) {
9737
10572
  return {
9738
10573
  error: null,
9739
10574
  id: null
@@ -9747,7 +10582,7 @@ const ValServer = (valModules, options, callbacks) => {
9747
10582
  id: verification.data.sub
9748
10583
  };
9749
10584
  } else {
9750
- if (serverOps instanceof ValOpsFS) {
10585
+ if (!serverOps.requiresAuth) {
9751
10586
  return {
9752
10587
  error: null,
9753
10588
  id: null
@@ -10149,7 +10984,10 @@ const ValServer = (valModules, options, callbacks) => {
10149
10984
  return remoteFileAuthRes;
10150
10985
  }
10151
10986
  const remoteFileAuth = remoteFileAuthRes.json.remoteFileAuth;
10152
- const settingsRes = await getSettings(options.project, remoteFileAuth);
10987
+
10988
+ // The content url this server was CONFIGURED with, not whatever was in
10989
+ // the environment when @valbuild/server was built. See getSettings.
10990
+ const settingsRes = await getSettings(options.project, remoteFileAuth, options.valContentUrl);
10153
10991
  if (!settingsRes.success) {
10154
10992
  console.warn("Could not get remote files settings: " + settingsRes.message);
10155
10993
  return {
@@ -10218,15 +11056,17 @@ const ValServer = (valModules, options, callbacks) => {
10218
11056
  json: currentStat.error
10219
11057
  };
10220
11058
  }
10221
- const mode = serverOps instanceof ValOpsFS ? "fs" : serverOps instanceof ValOpsHttp ? "http" : "unknown";
10222
- if (mode === "unknown") {
10223
- return {
10224
- status: 500,
10225
- json: {
10226
- message: "Server mode is neither fs nor http - this is an internal Val bug"
10227
- }
10228
- };
10229
- }
11059
+ /*
11060
+ * The wire value names a class and the question it answers does not.
11061
+ *
11062
+ * The client reads `mode` to decide whether it auto-saves and hides the
11063
+ * account panel, or whether it publishes and shows deployments -- which
11064
+ * is {@link ValOps.patchesAreLocal} and nothing else. It was derived by
11065
+ * `instanceof` while there were exactly two implementations, so a third
11066
+ * local store fell through to `"unknown"` and the Studio refused to
11067
+ * start against a server that was working.
11068
+ */
11069
+ const mode = serverOps.patchesAreLocal ? "fs" : "http";
10230
11070
  return {
10231
11071
  status: 200,
10232
11072
  json: {
@@ -10297,7 +11137,7 @@ const ValServer = (valModules, options, callbacks) => {
10297
11137
  }
10298
11138
  };
10299
11139
  }
10300
- if (serverOps instanceof ValOpsFS) {
11140
+ if (serverOps.patchesAreLocal) {
10301
11141
  // In FS mode patch-file uploads are buffered through this server (no remote round-trip),
10302
11142
  // so baseUrl points at /api/val/upload. AI image uploads, however, go straight to the
10303
11143
  // content host — we resolve a contentBaseUrl + a PAT-issued nonce here so the browser
@@ -10307,6 +11147,19 @@ const ValServer = (valModules, options, callbacks) => {
10307
11147
  let contentAuthNonce = null;
10308
11148
  if (!options.project) {
10309
11149
  console.warn("Direct content-host uploads (AI images) disabled: no `project` set in val.config (and VAL_PROJECT env var is not set).");
11150
+ } else if (!(serverOps instanceof ValOpsFS)) {
11151
+ /*
11152
+ * Presigning needs a content host to presign AGAINST, and a local
11153
+ * store does not necessarily have one configured: `fs` mode has the
11154
+ * developer's `valContentUrl`, and a host holding its own source has
11155
+ * no such setting.
11156
+ *
11157
+ * Warned and disabled, like the missing-project case above, rather
11158
+ * than failing the request: everything else this route answers --
11159
+ * the patch upload base url -- still works, and the only thing lost
11160
+ * is the browser posting AI images straight to the content host.
11161
+ */
11162
+ console.warn("Direct content-host uploads (AI images) disabled: this store has no content host to presign against.");
10310
11163
  } else {
10311
11164
  const authDataRes = await getRemoteFileAuth();
10312
11165
  if (authDataRes.status !== 200) {
@@ -10388,7 +11241,7 @@ const ValServer = (valModules, options, callbacks) => {
10388
11241
  patchIds
10389
11242
  } = req.body;
10390
11243
  const withPatchIds = req.body.withPatchIds ?? [];
10391
- if (serverOps instanceof ValOpsFS) {
11244
+ if (serverOps.patchesAreLocal) {
10392
11245
  return {
10393
11246
  status: 200,
10394
11247
  json: {
@@ -10451,7 +11304,7 @@ const ValServer = (valModules, options, callbacks) => {
10451
11304
  patchIds
10452
11305
  } = req.body;
10453
11306
  const withPatchIds = req.body.withPatchIds ?? [];
10454
- if (serverOps instanceof ValOpsFS) {
11307
+ if (serverOps.patchesAreLocal) {
10455
11308
  return {
10456
11309
  status: 200,
10457
11310
  json: {
@@ -10530,7 +11383,7 @@ const ValServer = (valModules, options, callbacks) => {
10530
11383
  } : {}),
10531
11384
  withPatchIds: requestedWith ?? []
10532
11385
  } : undefined;
10533
- if (patchGroup !== undefined && serverOps instanceof ValOpsFS) {
11386
+ if (patchGroup !== undefined && serverOps.patchesAreLocal) {
10534
11387
  /*
10535
11388
  * `fs` has no shared store and one author, so there is no group to
10536
11389
  * join. Refused rather than acknowledged: answering 200 would tell the
@@ -11301,7 +12154,7 @@ const ValServer = (valModules, options, callbacks) => {
11301
12154
  }
11302
12155
  const authDataRes = await getRemoteFileAuth();
11303
12156
  if (authDataRes.status !== 200) {
11304
- if (serverOps instanceof ValOpsFS && authDataRes.json.errorCode === "pat-error") {
12157
+ if (serverOps.patchesAreLocal && authDataRes.json.errorCode === "pat-error") {
11305
12158
  return {
11306
12159
  status: 200,
11307
12160
  json: {
@@ -11358,7 +12211,7 @@ const ValServer = (valModules, options, callbacks) => {
11358
12211
  };
11359
12212
  }
11360
12213
  };
11361
- if (serverOps instanceof ValOpsFS) {
12214
+ if (serverOps.patchesAreLocal) {
11362
12215
  return execFetch(getProfileAuthHeaders(authData, null, "application/json"));
11363
12216
  }
11364
12217
  if (!options.valSecret) {
@@ -11474,7 +12327,7 @@ const ValServer = (valModules, options, callbacks) => {
11474
12327
  * editing is told what was thrown away.
11475
12328
  */
11476
12329
  let removed = [];
11477
- if (preparedCommit.hasErrors && serverOps instanceof ValOpsFS) {
12330
+ if (preparedCommit.hasErrors && serverOps.patchesAreLocal) {
11478
12331
  removed = computePatchesToDrop(preparedCommit);
11479
12332
  /*
11480
12333
  * Nothing to drop means nothing a second `prepare` could do better.
@@ -11526,35 +12379,93 @@ const ValServer = (valModules, options, callbacks) => {
11526
12379
  }
11527
12380
  };
11528
12381
  }
11529
- if (serverOps instanceof ValOpsFS) {
11530
- var _remoteFileAuthRes;
11531
- const isRemoteRequired = getIsRemoteRequired(await serverOps.getSchemas());
11532
- let mode;
11533
- let remoteFileAuthRes;
11534
- if (isRemoteRequired) {
11535
- mode = "upload-remote";
11536
- remoteFileAuthRes = await getRemoteFileAuth();
11537
- } else {
11538
- mode = "skip-remote";
12382
+ if (serverOps.patchesAreLocal) {
12383
+ /*
12384
+ * Writing the files is the one step that is not shared.
12385
+ *
12386
+ * Everything around it is: applying the patches, telling the ops the
12387
+ * sources moved, handing the result to the host, dropping the patches
12388
+ * this request consumed. Only the destination differs -- `fs` mode has
12389
+ * a working tree to write into and remote binaries to push, and a host
12390
+ * that holds its own source has neither. So this branch is on the
12391
+ * store that CAN write files rather than on "is this local", which is
12392
+ * what the rest of the flow asks.
12393
+ */
12394
+ /*
12395
+ * Remote binary files, which publish separately from the source.
12396
+ *
12397
+ * Val uploads a remote file at PUBLISH rather than when it is added:
12398
+ * until then the bytes are a pending change like any other. So a
12399
+ * local store has to push them before the source that references them
12400
+ * goes live, or the new build ships a URL that 404s. `ValOpsFS` does
12401
+ * this inside `saveOrUploadFiles`, alongside writing its working
12402
+ * tree; a store with no working tree does only the push.
12403
+ */
12404
+ if (serverOps instanceof ValOpsMemory) {
12405
+ const isRemoteRequired = getIsRemoteRequired(await serverOps.getSchemas());
12406
+ if (isRemoteRequired) {
12407
+ const authRes = await getRemoteFileAuth();
12408
+ if (authRes.status !== 200) {
12409
+ return authRes;
12410
+ }
12411
+ const uploadRes = await serverOps.uploadRemoteFiles(preparedCommit, authRes.json.remoteFileAuth);
12412
+ if (Object.keys(uploadRes.errors).length > 0) {
12413
+ console.error("Val: Failed to upload remote files", uploadRes.errors);
12414
+ return {
12415
+ status: 400,
12416
+ json: {
12417
+ message: "Failed to save files",
12418
+ details: Object.entries(uploadRes.errors).map(([ref, error]) => ({
12419
+ message: `Got error: ${error.message} in ${ref}`
12420
+ }))
12421
+ }
12422
+ };
12423
+ }
12424
+ }
11539
12425
  }
11540
- if (remoteFileAuthRes && remoteFileAuthRes.status !== 200) {
11541
- return remoteFileAuthRes;
12426
+ if (serverOps instanceof ValOpsFS) {
12427
+ var _remoteFileAuthRes;
12428
+ const isRemoteRequired = getIsRemoteRequired(await serverOps.getSchemas());
12429
+ let mode;
12430
+ let remoteFileAuthRes;
12431
+ if (isRemoteRequired) {
12432
+ mode = "upload-remote";
12433
+ remoteFileAuthRes = await getRemoteFileAuth();
12434
+ } else {
12435
+ mode = "skip-remote";
12436
+ }
12437
+ if (remoteFileAuthRes && remoteFileAuthRes.status !== 200) {
12438
+ return remoteFileAuthRes;
12439
+ }
12440
+ const remoteFileAuth = (_remoteFileAuthRes = remoteFileAuthRes) === null || _remoteFileAuthRes === void 0 || (_remoteFileAuthRes = _remoteFileAuthRes.json) === null || _remoteFileAuthRes === void 0 ? void 0 : _remoteFileAuthRes.remoteFileAuth;
12441
+ const saveRes = await serverOps.saveOrUploadFiles(preparedCommit, mode, remoteFileAuth);
12442
+ if (Object.keys(saveRes.errors).length > 0) {
12443
+ console.error("Val: Failed to save files", saveRes.errors);
12444
+ return {
12445
+ status: 400,
12446
+ json: {
12447
+ message: "Failed to save files",
12448
+ details: Object.entries(saveRes.errors).map(([key, error]) => {
12449
+ return {
12450
+ message: `Got error: ${error} in ${key}`
12451
+ };
12452
+ })
12453
+ }
12454
+ };
12455
+ }
11542
12456
  }
11543
- const remoteFileAuth = (_remoteFileAuthRes = remoteFileAuthRes) === null || _remoteFileAuthRes === void 0 || (_remoteFileAuthRes = _remoteFileAuthRes.json) === null || _remoteFileAuthRes === void 0 ? void 0 : _remoteFileAuthRes.remoteFileAuth;
11544
- const saveRes = await serverOps.saveOrUploadFiles(preparedCommit, mode, remoteFileAuth);
11545
- if (Object.keys(saveRes.errors).length > 0) {
11546
- console.error("Val: Failed to save files", saveRes.errors);
11547
- return {
11548
- status: 400,
11549
- json: {
11550
- message: "Failed to save files",
11551
- details: Object.entries(saveRes.errors).map(([key, error]) => {
11552
- return {
11553
- message: `Got error: ${error} in ${key}`
11554
- };
11555
- })
11556
- }
11557
- };
12457
+ /*
12458
+ * The host's destination, after the store's own.
12459
+ *
12460
+ * Ordered this way so a store that writes has already succeeded by
12461
+ * the time the host is told: `commitPrepared` throwing fails the save,
12462
+ * and a host that publishes what it was handed should not be handed
12463
+ * files the store could not write.
12464
+ */
12465
+ if (options.commitPrepared) {
12466
+ await options.commitPrepared({
12467
+ patchedSourceFiles: preparedCommit.patchedSourceFiles
12468
+ });
11558
12469
  }
11559
12470
  /*
11560
12471
  * The files on disk are the committed content now, so say so here too.
@@ -11604,7 +12515,6 @@ const ValServer = (valModules, options, callbacks) => {
11604
12515
  };
11605
12516
  } else if (serverOps instanceof ValOpsHttp) {
11606
12517
  if (auth.error === undefined && auth.id) {
11607
- var _options$config$files;
11608
12518
  /*
11609
12519
  * The group this commit CLOSES has to be the caller's.
11610
12520
  *
@@ -11644,14 +12554,32 @@ const ValServer = (valModules, options, callbacks) => {
11644
12554
  }
11645
12555
  }
11646
12556
  const message = body.message || "Val CMS update (" + Object.keys(analysis.patchesByModule).length + " files changed)";
11647
- 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,
12557
+ const commitToGit = () => {
12558
+ var _options$config$files;
12559
+ 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,
12560
+ /*
12561
+ * Forwarded verbatim, and only the client can decide it: the
12562
+ * content API closes the group it is named without checking
12563
+ * that the commit shipped all of it, and whether it did needs
12564
+ * the patch sets, which live in the browser.
12565
+ */
12566
+ body.patchGroupId);
12567
+ };
11648
12568
  /*
11649
- * Forwarded verbatim, and only the client can decide it: the
11650
- * content API closes the group it is named without checking that
11651
- * the commit shipped all of it, and whether it did needs the
11652
- * patch sets, which live in the browser.
12569
+ * A host may publish somewhere other than the repository.
12570
+ *
12571
+ * See `publishOverride`. It is handed `commitToGit` rather than
12572
+ * having it skipped, so it can replace the commit or add to it --
12573
+ * and it is the host, not this route, that knows which.
11653
12574
  */
11654
- body.patchGroupId);
12575
+ const commitRes = options.publishOverride ? await options.publishOverride({
12576
+ patchedSourceFiles: preparedCommit.patchedSourceFiles,
12577
+ preparedCommit,
12578
+ message,
12579
+ authorId: auth.id,
12580
+ patchGroupId: body.patchGroupId,
12581
+ commitToGit
12582
+ }) : await commitToGit();
11655
12583
  if (commitRes.error) {
11656
12584
  console.error("Failed to commit", commitRes.error);
11657
12585
  if ("isNotFastForward" in commitRes && commitRes.isNotFastForward) {
@@ -11798,7 +12726,7 @@ const ValServer = (valModules, options, callbacks) => {
11798
12726
  };
11799
12727
  }
11800
12728
  };
11801
- if (serverOps instanceof ValOpsFS) {
12729
+ if (serverOps.patchesAreLocal) {
11802
12730
  return execFetch(getProfileAuthHeaders(authData, null, "application/json"));
11803
12731
  }
11804
12732
  if (!options.valSecret) {
@@ -11902,7 +12830,7 @@ const ValServer = (valModules, options, callbacks) => {
11902
12830
  };
11903
12831
  }
11904
12832
  };
11905
- if (serverOps instanceof ValOpsFS) {
12833
+ if (serverOps.patchesAreLocal) {
11906
12834
  return execFetch(getProfileAuthHeaders(authData, null, "application/json"));
11907
12835
  }
11908
12836
  if (!options.valSecret) {
@@ -11988,7 +12916,7 @@ const ValServer = (valModules, options, callbacks) => {
11988
12916
  };
11989
12917
  }
11990
12918
  };
11991
- if (serverOps instanceof ValOpsFS) {
12919
+ if (serverOps.patchesAreLocal) {
11992
12920
  return execFetch(getProfileAuthHeaders(authData, null, "application/json"));
11993
12921
  }
11994
12922
  if (!options.valSecret) {
@@ -12096,7 +13024,7 @@ const ValServer = (valModules, options, callbacks) => {
12096
13024
  };
12097
13025
  }
12098
13026
  };
12099
- if (serverOps instanceof ValOpsFS) {
13027
+ if (serverOps.patchesAreLocal) {
12100
13028
  return execFetch(getProfileAuthHeaders(authData, null, "application/json"));
12101
13029
  }
12102
13030
  if (!options.valSecret) {
@@ -12143,7 +13071,7 @@ const ValServer = (valModules, options, callbacks) => {
12143
13071
  }
12144
13072
  const authData = authDataRes.json.remoteFileAuth;
12145
13073
  let headers;
12146
- if (serverOps instanceof ValOpsFS) {
13074
+ if (serverOps.patchesAreLocal) {
12147
13075
  headers = getProfileAuthHeaders(authData, null, "application/json");
12148
13076
  } else {
12149
13077
  if (!("id" in auth) || !auth.id) {
@@ -12230,6 +13158,11 @@ const ValServer = (valModules, options, callbacks) => {
12230
13158
  }
12231
13159
  };
12232
13160
  }
13161
+ // Deliberately `ValOpsFS` and not `patchesAreLocal`: this writes
13162
+ // BYTES into a patch directory, which is a thing only the fs store
13163
+ // has. A local store without local binary files (ValOpsMemory, which
13164
+ // uses Val's remote files) skips it, and the draft image is served
13165
+ // from the content host rather than mirrored.
12233
13166
  if (serverOps instanceof ValOpsFS) {
12234
13167
  // Mirror the binaries from the content host into local patch
12235
13168
  // storage so /files?patch_id=... can serve them. Match upstream
@@ -12334,7 +13267,7 @@ const ValServer = (valModules, options, callbacks) => {
12334
13267
  }
12335
13268
  const authData = authDataRes.json.remoteFileAuth;
12336
13269
  let headers;
12337
- if (serverOps instanceof ValOpsFS) {
13270
+ if (serverOps.patchesAreLocal) {
12338
13271
  headers = getProfileAuthHeaders(authData, null, "application/json");
12339
13272
  } else {
12340
13273
  if (!("id" in auth) || !auth.id) {
@@ -12746,7 +13679,15 @@ chain, deleted, requested) {
12746
13679
  * `undefined` means "apply everything", which is what every caller that does
12747
13680
  * not ask for scoping gets and must keep getting.
12748
13681
  */
12749
- async function resolveOwnPatchScope(serverOps, opts) {
13682
+ async function resolveOwnPatchScope(
13683
+ /*
13684
+ * `ValOps`, not the two concrete stores: the one thing this needs is whether
13685
+ * a content service holds the groups, and it asks that below with an
13686
+ * `instanceof ValOpsHttp`. Naming the implementations here meant every new
13687
+ * store had to be added to a list to be allowed to call a function that does
13688
+ * not use it.
13689
+ */
13690
+ serverOps, opts) {
12750
13691
  let ownPatchIds;
12751
13692
  /** See where this is set: committed work is nobody's to hold back. */
12752
13693
  let scopeAlsoIncludesApplied = false;
@@ -13249,18 +14190,47 @@ function getIsRemoteRequired(schemas) {
13249
14190
  return false;
13250
14191
  }
13251
14192
 
13252
- async function createValServer(valModules, route, opts, config, callbacks, formatter) {
14193
+ async function createValServer(valModules, route, opts, config, callbacks, formatter,
14194
+ /**
14195
+ * Called after a save has applied its patches. EXPERIMENTAL — see
14196
+ * `ValServerOptions.commitPrepared`.
14197
+ */
14198
+ commitPrepared,
14199
+ /**
14200
+ * What a publish does in http mode. EXPERIMENTAL — see
14201
+ * `ValServerOptions.publishOverride`.
14202
+ */
14203
+ publishOverride) {
13253
14204
  const valServerConfig = await initHandlerOptions(route, opts, config);
13254
14205
  return ValServer(valModules, {
13255
14206
  formatter,
14207
+ commitPrepared,
14208
+ publishOverride,
13256
14209
  ...valServerConfig
13257
14210
  }, callbacks);
13258
14211
  }
13259
14212
 
13260
14213
  // TODO: remove
14214
+ /**
14215
+ * `fs` and `path` are imported INSIDE this function, not at the top of the file.
14216
+ *
14217
+ * This is the only thing in this module that touches either, and it is a local
14218
+ * development convenience: scanning upwards for a `.git` to guess the commit and
14219
+ * branch. A static import put `fs` in the module graph of everything reaching
14220
+ * `createValApiRouter` -- which is every server integration, including ones that
14221
+ * run where there is no filesystem. Workerd provides no `fs`, so such a build
14222
+ * could not be bundled at all without stubbing it.
14223
+ *
14224
+ * The `await import` costs nothing here: the only caller is the CLI, on a
14225
+ * machine that has both.
14226
+ */
13261
14227
  async function safeReadGit(cwd) {
14228
+ const {
14229
+ promises: fs
14230
+ } = await Promise.resolve().then(function () { return /*#__PURE__*/_interopNamespace(require('fs')); });
14231
+ const path = await Promise.resolve().then(function () { return /*#__PURE__*/_interopNamespace(require('path')); });
13262
14232
  async function findGitHead(currentDir, depth) {
13263
- const gitHeadPath = path__namespace.join(currentDir, ".git", "HEAD");
14233
+ const gitHeadPath = path.join(currentDir, ".git", "HEAD");
13264
14234
  if (depth > 1000) {
13265
14235
  console.error(`Reached max depth while scanning for .git folder. Current working dir: ${cwd}.`);
13266
14236
  return {
@@ -13269,7 +14239,7 @@ async function safeReadGit(cwd) {
13269
14239
  };
13270
14240
  }
13271
14241
  try {
13272
- const headContents = await fs.promises.readFile(gitHeadPath, "utf-8");
14242
+ const headContents = await fs.readFile(gitHeadPath, "utf-8");
13273
14243
  const match = headContents.match(/^ref: refs\/heads\/(.+)/);
13274
14244
  if (match) {
13275
14245
  const branchName = match[1];
@@ -13284,7 +14254,7 @@ async function safeReadGit(cwd) {
13284
14254
  };
13285
14255
  }
13286
14256
  } catch {
13287
- const parentDir = path__namespace.dirname(currentDir);
14257
+ const parentDir = path.dirname(currentDir);
13288
14258
 
13289
14259
  // We've reached the root directory
13290
14260
  if (parentDir === currentDir) {
@@ -13306,9 +14276,15 @@ async function safeReadGit(cwd) {
13306
14276
  };
13307
14277
  }
13308
14278
  }
14279
+
14280
+ /** Only reached from {@link safeReadGit}; same reason for the local imports. */
13309
14281
  async function readCommit(gitDir, branchName) {
14282
+ const {
14283
+ promises: fs
14284
+ } = await Promise.resolve().then(function () { return /*#__PURE__*/_interopNamespace(require('fs')); });
14285
+ const path = await Promise.resolve().then(function () { return /*#__PURE__*/_interopNamespace(require('path')); });
13310
14286
  try {
13311
- return (await fs.promises.readFile(path__namespace.join(gitDir, ".git", "refs", "heads", branchName), "utf-8")).trim();
14287
+ return (await fs.readFile(path.join(gitDir, ".git", "refs", "heads", branchName), "utf-8")).trim();
13312
14288
  } catch {
13313
14289
  return undefined;
13314
14290
  }
@@ -15945,12 +16921,14 @@ exports.DEFAULT_LOGIN_EXPIRES_IN_SECONDS = DEFAULT_LOGIN_EXPIRES_IN_SECONDS;
15945
16921
  exports.DEFAULT_LOGIN_HOST = DEFAULT_LOGIN_HOST;
15946
16922
  exports.DEFAULT_LOGIN_POLL_INTERVAL_SECONDS = DEFAULT_LOGIN_POLL_INTERVAL_SECONDS;
15947
16923
  exports.EXTERNAL_RESULT = EXTERNAL_RESULT;
16924
+ exports.InMemoryPatchStore = InMemoryPatchStore;
15948
16925
  exports.Service = Service;
15949
16926
  exports.ValFSHost = ValFSHost;
15950
16927
  exports.ValLoginError = ValLoginError;
15951
16928
  exports.ValModuleLoader = ValModuleLoader;
15952
16929
  exports.ValOpsFS = ValOpsFS;
15953
16930
  exports.ValOpsHttp = ValOpsHttp;
16931
+ exports.ValOpsMemory = ValOpsMemory;
15954
16932
  exports.ValSourceFileHandler = ValSourceFileHandler;
15955
16933
  exports.analyzeValModule = analyzeValModule;
15956
16934
  exports.awaitValLoginConfirmation = awaitValLoginConfirmation;