@valbuild/server 0.137.0 → 0.138.1

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.
@@ -8,7 +8,7 @@ import path__default from 'path';
8
8
  import fs from 'fs';
9
9
  import vm from 'node:vm';
10
10
  import { Module } from 'node:module';
11
- import { isSchemaSourceFixError, resolveSchemaSourceFixForError, Patch, getErrorMessageFromUnknownJson, PatchId as PatchId$1, JSONValue, PatchGroup, newestCommitSha, SerializedSchema, VAL_ENABLE_COOKIE_NAME, VAL_STATE_COOKIE, VAL_SESSION_COOKIE, Api } from '@valbuild/shared/internal';
11
+ import { isSchemaSourceFixError, resolveSchemaSourceFixForError, Patch, getErrorMessageFromUnknownJson, PatchId as PatchId$1, JSONValue, PatchGroup, newestCommitSha, SerializedSchema, VAL_ENABLE_COOKIE_NAME, VAL_STATE_COOKIE, VAL_SESSION_COOKIE, parseJob, Api } from '@valbuild/shared/internal';
12
12
  import { createUIRequestHandler } from '@valbuild/ui/server';
13
13
  import crypto$1 from 'crypto';
14
14
  import z$1, { z } from 'zod';
@@ -5133,6 +5133,15 @@ class ValOps {
5133
5133
  return null;
5134
5134
  }
5135
5135
 
5136
+ /**
5137
+ * Does a press of Publish run as a publish job (valbuild/home,
5138
+ * docs/app-mode.md, "Publishing is a queued job")? Never for a project
5139
+ * with no content service.
5140
+ */
5141
+ publishesAsJobs() {
5142
+ return false;
5143
+ }
5144
+
5136
5145
  /**
5137
5146
  * The branch the content service keeps this project's commits on, or `null`
5138
5147
  * where there is no such service, or it has not said yet.
@@ -8108,6 +8117,59 @@ async function readCommittedBinaryFiles(ops, descriptors) {
8108
8117
  };
8109
8118
  }
8110
8119
 
8120
+ /**
8121
+ * A publish job's prepare, split into what the tab builds and what content
8122
+ * archives.
8123
+ *
8124
+ * A job's content is its base plus every change it took (valbuild/home,
8125
+ * docs/app-mode.md, "Publishing is a queued job"). The server has two
8126
+ * `prepare`s to hand: `chainOnly` -- this build's embedded source with every
8127
+ * commit since applied, which is the branch's head -- and `withJob`, the same
8128
+ * with the job's pending changes on top. Anything applied since the job
8129
+ * started came out of the job's own change set, so `withJob` IS the job's
8130
+ * content; were that ever not so, the seal's containment check would restart
8131
+ * the job rather than publish it.
8132
+ *
8133
+ * - The BUILD needs all of `withJob`'s source files: it is a whole site.
8134
+ * - The ARCHIVE is what the commit publishes, so content is sent only what the
8135
+ * job changes: the files whose text differs from the head's, and the modules
8136
+ * and binary files its changes touch. Everything else is the head's, and an
8137
+ * archive that listed it would say this commit changed it.
8138
+ *
8139
+ * Files are compared rather than derived from module paths because a change to
8140
+ * a module can write a file under another path -- a `.val.json` entry of a
8141
+ * record -- which only the prepared output knows about.
8142
+ */
8143
+ function splitJobPrepare(input) {
8144
+ const job = new Set(input.jobPatchIds);
8145
+ const before = input.chainOnly.patchedSourceFiles;
8146
+ const after = input.withJob.patchedSourceFiles;
8147
+ const patchedSourceFiles = {};
8148
+ for (const [path, text] of Object.entries(after)) {
8149
+ if (!(path in before) || before[path] !== text) {
8150
+ patchedSourceFiles[path] = text;
8151
+ }
8152
+ }
8153
+ const patchedBinaryFilesDescriptors = {};
8154
+ for (const [path, descriptor] of Object.entries(input.withJob.patchedBinaryFilesDescriptors)) {
8155
+ if (job.has(descriptor.patchId)) {
8156
+ patchedBinaryFilesDescriptors[path] = descriptor;
8157
+ }
8158
+ }
8159
+
8160
+ // The modules any of the job's changes were applied to.
8161
+ const touched = new Set(Object.entries(input.withJob.appliedPatches).filter(([, applied]) => applied.some(id => job.has(id))).map(([path]) => path));
8162
+ const modules = Object.fromEntries(Object.entries(input.withJob.moduleVersions).filter(([path]) => touched.has(path)));
8163
+ return {
8164
+ buildSourceFiles: after,
8165
+ archive: {
8166
+ patchedSourceFiles,
8167
+ patchedBinaryFilesDescriptors,
8168
+ modules
8169
+ }
8170
+ };
8171
+ }
8172
+
8111
8173
  /**
8112
8174
  * Which unpublished changes a failed save has to throw away to make progress.
8113
8175
  *
@@ -8292,7 +8354,13 @@ const GetApplicablePatches = z.object({
8292
8354
  */
8293
8355
  project: z.object({
8294
8356
  sourceMode: z.union([z.literal("managed"), z.literal("connected")]),
8295
- branch: z.string()
8357
+ branch: z.string(),
8358
+ /**
8359
+ * Whether a press of Publish is a publish JOB. Absent from a content
8360
+ * service that predates connected jobs: then managed projects publish
8361
+ * as jobs, and connected ones by commit, as they did.
8362
+ */
8363
+ publishJobs: z.boolean().optional()
8296
8364
  }).optional()
8297
8365
  });
8298
8366
  const FilesResponse = z.object({
@@ -8649,6 +8717,17 @@ class ValOpsHttp extends ValOps {
8649
8717
  return ((_this$projectExpectat3 = this.projectExpectation) === null || _this$projectExpectat3 === void 0 ? void 0 : _this$projectExpectat3.branch) ?? null;
8650
8718
  }
8651
8719
 
8720
+ /**
8721
+ * Does a press of Publish run as a publish job? Every managed project, and
8722
+ * a connected one content says is hosted on the platform (its CI publishes
8723
+ * through content). `false` before anything has been heard.
8724
+ */
8725
+ publishesAsJobs() {
8726
+ const expected = this.projectExpectation;
8727
+ if (expected === null) return false;
8728
+ return expected.publishJobs ?? expected.sourceMode === "managed";
8729
+ }
8730
+
8652
8731
  /**
8653
8732
  * The short-lived publish token, and when it stops being usable.
8654
8733
  *
@@ -8763,22 +8842,66 @@ class ValOpsHttp extends ValOps {
8763
8842
  * segment gets through.
8764
8843
  */
8765
8844
  static publishApiPathAllowed(path) {
8766
- if (path === "/build-target" || path === "/project-source" || path === "/update-target" || path === "/publish") {
8845
+ if (path === "/build-target" || path === "/project-source" || path === "/update-target" || path === "/publish" ||
8846
+ // Where "View run" goes, for a connected build CI reported failed.
8847
+ // Read only: reporting a run is CI's, with its own token.
8848
+ path === "/ci-runs/newest") {
8849
+ return true;
8850
+ }
8851
+ /*
8852
+ * Publishing as a queued job: a press, where it is, Try again, and the
8853
+ * tab's reports about the job it runs. NOT `/publish-jobs/{id}/prepare`:
8854
+ * its body is the job's source files and modules, which this server
8855
+ * computes (`prepareJob`) -- a browser allowed to send it could publish
8856
+ * any text at all as a commit's archive.
8857
+ */
8858
+ if (/^\/publish-requests(\/[A-Za-z0-9_-]{1,100})?$/.test(path)) {
8859
+ return true;
8860
+ }
8861
+ if (/^\/publish-jobs\/(next|[A-Za-z0-9_-]{1,100}\/(steps|renew|cancel|discard))$/.test(path)) {
8767
8862
  return true;
8768
8863
  }
8769
8864
  return /^\/publish\/[A-Za-z0-9_-]+(\/(artifacts|verify|promote))?$/.test(path);
8770
8865
  }
8771
8866
  async publishApi(path, init) {
8772
- const json = "application/json";
8773
8867
  if (!ValOpsHttp.publishApiPathAllowed(path)) {
8774
8868
  return {
8775
8869
  status: 403,
8776
- contentType: json,
8870
+ contentType: "application/json",
8777
8871
  body: JSON.stringify({
8778
8872
  message: `'${path}' is not part of the publish API.`
8779
8873
  })
8780
8874
  };
8781
8875
  }
8876
+ return this.callPublishApi(path, init);
8877
+ }
8878
+
8879
+ /**
8880
+ * A publish job's prepare, sent to content: the job's sources, archived
8881
+ * there before the job is built (valbuild/home, docs/app-mode.md, "The
8882
+ * archive, before the seal"). With this server's publish token, as every
8883
+ * publish call is, and never through `publishApi` -- see the allowlist.
8884
+ */
8885
+ async prepareJob(jobId, body) {
8886
+ if (!/^[A-Za-z0-9_-]{1,100}$/.test(jobId)) {
8887
+ return {
8888
+ status: 400,
8889
+ contentType: "application/json",
8890
+ body: JSON.stringify({
8891
+ message: `'${jobId}' is not a job id.`
8892
+ })
8893
+ };
8894
+ }
8895
+ return this.callPublishApi(`/publish-jobs/${jobId}/prepare`, {
8896
+ method: "POST",
8897
+ body: JSON.stringify({
8898
+ ...body,
8899
+ root: this.root
8900
+ })
8901
+ });
8902
+ }
8903
+ async callPublishApi(path, init) {
8904
+ const json = "application/json";
8782
8905
  const send = async token => fetch(`${this.contentUrl}/v1${path}`, {
8783
8906
  method: init.method,
8784
8907
  headers: {
@@ -12930,6 +13053,9 @@ const ValServer = (valModules, options, callbacks) => {
12930
13053
  ...(sourceMode ? {
12931
13054
  sourceMode
12932
13055
  } : {}),
13056
+ ...(sourceMode ? {
13057
+ publishJobs: serverOps.publishesAsJobs()
13058
+ } : {}),
12933
13059
  // Not `options.config` verbatim: in proxy mode the branch the
12934
13060
  // server resolved is filled in where the file did not name one.
12935
13061
  // See `clientConfig`.
@@ -14095,8 +14221,9 @@ const ValServer = (valModules, options, callbacks) => {
14095
14221
  return res;
14096
14222
  }
14097
14223
  },
14098
- "/built-source": {
14099
- GET: async req => {
14224
+ "/publish-job-prepare": {
14225
+ POST: async req => {
14226
+ var _options$config$files2;
14100
14227
  const auth = getAuth(req.cookies);
14101
14228
  if (auth.error) {
14102
14229
  return {
@@ -14106,14 +14233,93 @@ const ValServer = (valModules, options, callbacks) => {
14106
14233
  }
14107
14234
  };
14108
14235
  }
14109
- if (!(serverOps instanceof ValOpsHttp) || !serverOps.embedsSource()) {
14236
+ if (!(serverOps instanceof ValOpsHttp) || !serverOps.publishesAsJobs() || serverOps.sourceMode() === "managed" && !serverOps.embedsSource()) {
14237
+ return {
14238
+ status: 409,
14239
+ json: {
14240
+ message: "This deployment cannot prepare a publish job for this project."
14241
+ }
14242
+ };
14243
+ }
14244
+ const refusal = serverOps.publishRefusal();
14245
+ if (refusal) {
14110
14246
  return {
14111
14247
  status: 409,
14112
14248
  json: {
14113
- message: "This server has no source of its own to render commits into."
14249
+ message: refusal.message
14114
14250
  }
14115
14251
  };
14116
14252
  }
14253
+ const {
14254
+ jobId,
14255
+ tab,
14256
+ patchIds
14257
+ } = req.body;
14258
+ /*
14259
+ * CONNECTED: the job's sources are what `/save` would commit -- its
14260
+ * own changes, applied as `/save` applies them -- and content pushes
14261
+ * them at the seal. Nothing is built here: CI builds after the push,
14262
+ * so the answer carries the job and no sources.
14263
+ */
14264
+ if (serverOps.sourceMode() === "connected") {
14265
+ var _options$config$files;
14266
+ const jobPatches = await serverOps.fetchPatches({
14267
+ patchIds,
14268
+ excludePatchOps: false
14269
+ });
14270
+ if (jobPatches.error) {
14271
+ return {
14272
+ status: 500,
14273
+ json: {
14274
+ message: jobPatches.error.message
14275
+ }
14276
+ };
14277
+ }
14278
+ const found = new Set(jobPatches.patches.map(patch => patch.patchId));
14279
+ const gone = patchIds.filter(id => !found.has(id));
14280
+ if (gone.length > 0) {
14281
+ return {
14282
+ status: 409,
14283
+ json: {
14284
+ message: `This publish's changes are no longer all there (${gone.length} discarded).`
14285
+ }
14286
+ };
14287
+ }
14288
+ const prepared = await serverOps.prepare({
14289
+ ...serverOps.analyzePatches(jobPatches.patches, jobPatches.commits, commit),
14290
+ ...jobPatches
14291
+ });
14292
+ if (prepared.hasErrors) {
14293
+ return {
14294
+ status: 500,
14295
+ json: {
14296
+ message: "This publish's changes could not be rendered into source: " + JSON.stringify(prepared.sourceFilePatchErrors).slice(0, 500)
14297
+ }
14298
+ };
14299
+ }
14300
+ const split = splitJobPrepare({
14301
+ // Every file the job's changes wrote is what the push writes.
14302
+ chainOnly: {
14303
+ patchedSourceFiles: {}
14304
+ },
14305
+ withJob: prepared,
14306
+ jobPatchIds: patchIds
14307
+ });
14308
+ const sent = await serverOps.prepareJob(jobId, {
14309
+ tab,
14310
+ filesDirectory: ((_options$config$files = options.config.files) === null || _options$config$files === void 0 ? void 0 : _options$config$files.directory) || "/public/val",
14311
+ ...split.archive,
14312
+ ...(commit !== undefined ? {
14313
+ gitCommit: commit
14314
+ } : {})
14315
+ });
14316
+ return jobPrepareAnswer(sent, {
14317
+ sourceFiles: {},
14318
+ binaryFiles: {},
14319
+ binaryFilesUnread: [],
14320
+ branch: serverOps.projectBranch()
14321
+ });
14322
+ }
14117
14323
  const chain = await serverOps.fetchPatches({
14118
14324
  patchIds: undefined,
14119
14325
  excludePatchOps: false
@@ -14127,37 +14333,63 @@ const ValServer = (valModules, options, callbacks) => {
14127
14333
  };
14128
14334
  }
14129
14335
  /*
14130
- * The patches committed SINCE this build, as though pending.
14336
+ * The job's content, in two steps from this build's embedded source.
14131
14337
  *
14132
- * `applicable/patches` answers with the ones applied at a commit after
14133
- * the one this build was made from, which is exactly what the text
14134
- * embedded in it is missing. `analyzePatches` skips applied patches --
14135
- * for a save they are already in the file it reads -- so they are
14136
- * handed over unapplied: here the file predates them.
14338
+ * The commits since this build, as though pending, are the branch's
14339
+ * head. The job's pending changes on top of them are its content: whatever was applied since the job started came
14340
+ * out of its own changes (docs/app-mode.md, "Publishing is a queued
14341
+ * job"), and were that ever not so, the seal would restart the job
14342
+ * rather than publish it. Chain order, both times.
14137
14343
  */
14344
+ const job = new Set(patchIds);
14138
14345
  const committed = chain.patches.filter(patch => patch.appliedAt !== null).map(patch => ({
14139
14346
  ...patch,
14140
14347
  appliedAt: null
14141
14348
  }));
14142
- const prepared = await serverOps.prepare({
14143
- ...serverOps.analyzePatches(committed, chain.commits, commit),
14349
+ const pending = chain.patches.filter(patch => patch.appliedAt === null && job.has(patch.patchId));
14350
+ const known = new Set(chain.patches.map(patch => patch.patchId));
14351
+ const gone = patchIds.filter(id => !known.has(id));
14352
+ if (gone.length > 0) {
14353
+ return {
14354
+ status: 409,
14355
+ json: {
14356
+ message: `This publish's changes are no longer all there (${gone.length} discarded).`
14357
+ }
14358
+ };
14359
+ }
14360
+ const prepareOf = patches => serverOps.prepare({
14361
+ ...serverOps.analyzePatches(patches, chain.commits, commit),
14144
14362
  ...chain,
14145
- patches: committed
14363
+ patches
14146
14364
  });
14147
- if (prepared.hasErrors) {
14365
+ const chainOnly = await prepareOf(committed);
14366
+ const withJob = await prepareOf([...committed, ...pending]);
14367
+ if (chainOnly.hasErrors || withJob.hasErrors) {
14148
14368
  return {
14149
14369
  status: 500,
14150
14370
  json: {
14151
- message: "The commits since this build could not be rendered into source: " + JSON.stringify(prepared.sourceFilePatchErrors).slice(0, 500)
14371
+ message: "This publish's changes could not be rendered into source: " + JSON.stringify((withJob.hasErrors ? withJob : chainOnly).sourceFilePatchErrors).slice(0, 500)
14152
14372
  }
14153
14373
  };
14154
14374
  }
14155
- return {
14156
- status: 200,
14157
- json: {
14158
- files: prepared.patchedSourceFiles
14159
- }
14160
- };
14375
+ const split = splitJobPrepare({
14376
+ chainOnly,
14377
+ withJob,
14378
+ jobPatchIds: patchIds
14379
+ });
14380
+ // Read now, while the job's changes still hold their files.
14381
+ const binaries = await readCommittedBinaryFiles(serverOps, split.archive.patchedBinaryFilesDescriptors);
14382
+ const sent = await serverOps.prepareJob(jobId, {
14383
+ tab,
14384
+ filesDirectory: ((_options$config$files2 = options.config.files) === null || _options$config$files2 === void 0 ? void 0 : _options$config$files2.directory) || "/public/val",
14385
+ ...split.archive
14386
+ });
14387
+ return jobPrepareAnswer(sent, {
14388
+ sourceFiles: split.buildSourceFiles,
14389
+ binaryFiles: binaries.files,
14390
+ binaryFilesUnread: binaries.unread,
14391
+ branch: serverOps.projectBranch()
14392
+ });
14161
14393
  }
14162
14394
  },
14163
14395
  "/profiles": {
@@ -14612,8 +14844,8 @@ const ValServer = (valModules, options, callbacks) => {
14612
14844
  const managedBranch = serverOps.sourceMode() === "managed" ? serverOps.projectBranch() : null;
14613
14845
  const committedBinaries = serverOps.sourceMode() === "managed" ? await readCommittedBinaryFiles(serverOps, preparedCommit.patchedBinaryFilesDescriptors) : null;
14614
14846
  const commitToGit = () => {
14615
- var _options$config$files;
14616
- 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,
14847
+ var _options$config$files3;
14848
+ return serverOps.commit(preparedCommit, message, auth.id, ((_options$config$files3 = options.config.files) === null || _options$config$files3 === void 0 ? void 0 : _options$config$files3.directory) || "/public/val", undefined,
14617
14849
  /*
14618
14850
  * Forwarded verbatim, and only the client can decide it: the
14619
14851
  * content API closes the group it is named without checking
@@ -16267,6 +16499,49 @@ function getIsRemoteRequired(schemas) {
16267
16499
  return false;
16268
16500
  }
16269
16501
 
16502
+ /**
16503
+ * `/publish-job-prepare`'s answer from content's: the job as content has it
16504
+ * now, and what the tab builds from. Content's refusal is passed on -- 409
16505
+ * as 409, anything else as 502, which the tab reads as "content answered,
16506
+ * and has counted the attempt" (see `runStudioJob`).
16507
+ */
16508
+ function jobPrepareAnswer(sent, build) {
16509
+ if (sent.status !== 200) {
16510
+ let message = `The content service did not prepare the publish (${sent.status}).`;
16511
+ try {
16512
+ const parsed = JSON.parse(sent.body);
16513
+ if (typeof parsed === "object" && parsed !== null && "message" in parsed && typeof parsed.message === "string") {
16514
+ message = parsed.message;
16515
+ }
16516
+ } catch {
16517
+ // not JSON: the status says enough
16518
+ }
16519
+ return {
16520
+ status: sent.status === 409 ? 409 : 502,
16521
+ json: {
16522
+ message
16523
+ }
16524
+ };
16525
+ }
16526
+ try {
16527
+ const prepared = parseJob(JSON.parse(sent.body), "POST /v1/publish-jobs/{id}/prepare");
16528
+ return {
16529
+ status: 200,
16530
+ json: {
16531
+ job: prepared.job,
16532
+ ...build
16533
+ }
16534
+ };
16535
+ } catch (e) {
16536
+ return {
16537
+ status: 502,
16538
+ json: {
16539
+ message: e instanceof Error ? e.message : String(e)
16540
+ }
16541
+ };
16542
+ }
16543
+ }
16544
+
16270
16545
  async function createValServer(valModules, route, opts, config, callbacks, formatter,
16271
16546
  /**
16272
16547
  * Called after a save has applied its patches. EXPERIMENTAL — see
package/package.json CHANGED
@@ -16,7 +16,7 @@
16
16
  "./package.json": "./package.json"
17
17
  },
18
18
  "types": "dist/valbuild-server.cjs.d.ts",
19
- "version": "0.137.0",
19
+ "version": "0.138.1",
20
20
  "devDependencies": {
21
21
  "@prettier/sync": "^0.6.1",
22
22
  "@types/jest": "^30.0.0",
@@ -30,9 +30,9 @@
30
30
  "typescript": "^6.0.3",
31
31
  "zod": "^4.4.3",
32
32
  "zod-validation-error": "^5.0.0",
33
- "@valbuild/core": "0.137.0",
34
- "@valbuild/shared": "0.137.0",
35
- "@valbuild/ui": "0.137.0"
33
+ "@valbuild/core": "0.138.0",
34
+ "@valbuild/shared": "0.138.0",
35
+ "@valbuild/ui": "0.138.1"
36
36
  },
37
37
  "engines": {
38
38
  "node": "^20.19.0 || >=22"