@valbuild/server 0.135.0 → 0.136.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.
@@ -973,18 +973,29 @@ function createValModuleFileInspector(projectRoot, host = ts.sys) {
973
973
  }
974
974
 
975
975
  /**
976
- * Whether the file exports a RUNTIME value as `default`, without evaluating it.
976
+ * The statement that exports a RUNTIME value as `default`, without evaluating
977
+ * it — or `undefined` when the file has no such export.
978
+ *
979
+ * This is the one rule that separates a Val module from a `*.val.ts` that
980
+ * merely wears the naming convention: a shared schema or helper is exported by
981
+ * name, a module is exported by default. Every caller that needs to tell those
982
+ * apart goes through this — `val validate` to decide whether an unregistered
983
+ * file is worth reporting, and the language server to decide the same thing and
984
+ * to put its diagnostic on the export rather than on line 1.
977
985
  *
978
986
  * Two things deliberately do not count, because neither exists once the file is
979
987
  * transpiled — and treating either as a default export would send a pure helper
980
988
  * off to be evaluated and reported:
981
989
  *
982
990
  * - `export * from "./x"`, since a star re-export never carries the default;
983
- * - a type-only export, in either of its spellings
984
- * (`export type { T as default }` and `export { type T as default }`).
991
+ * - a type-only export, in any of its three spellings
992
+ * (`export type { T as default }`, `export { type T as default }` and
993
+ * `export default interface T {}` — the last one parses as a declaration
994
+ * carrying a `default` modifier, exactly like `export default class`, and is
995
+ * the one that looks like a runtime export and is not).
985
996
  */
986
- function hasDefaultExport(sourceFile) {
987
- return sourceFile.statements.some(statement => {
997
+ function findDefaultExport(sourceFile) {
998
+ return sourceFile.statements.find(statement => {
988
999
  // `export default <expr>` — but not `export = x`, which shares this node.
989
1000
  if (ts.isExportAssignment(statement)) {
990
1001
  return !statement.isExportEquals;
@@ -995,10 +1006,17 @@ function hasDefaultExport(sourceFile) {
995
1006
  }
996
1007
  // `export default function f() {}` / `export default class C {}`, which are
997
1008
  // declarations carrying a `default` modifier rather than export assignments.
998
- return ts.canHaveModifiers(statement) && (ts.getModifiers(statement) ?? []).some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword);
1009
+ // `export default interface T {}` is spelled the same way and is a type, so
1010
+ // it is excluded here rather than by the `isTypeOnly` checks above.
1011
+ return !ts.isInterfaceDeclaration(statement) && ts.canHaveModifiers(statement) && (ts.getModifiers(statement) ?? []).some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword);
999
1012
  });
1000
1013
  }
1001
1014
 
1015
+ /** Whether the file exports a runtime value as `default`. */
1016
+ function hasDefaultExport(sourceFile) {
1017
+ return findDefaultExport(sourceFile) !== undefined;
1018
+ }
1019
+
1002
1020
  /** A short, human-readable "what you exported instead" for the error message. */
1003
1021
  function describeDefaultExport(value) {
1004
1022
  if (value === null) {
@@ -4524,6 +4542,52 @@ class ValOps {
4524
4542
  return null;
4525
4543
  }
4526
4544
 
4545
+ /**
4546
+ * How this project's SOURCE is kept, or `null` when it is nobody's question.
4547
+ *
4548
+ * `"managed"` -- the content service is the store of record, there is no
4549
+ * repository, and nothing outside the browser will ever pick a commit up.
4550
+ * `"connected"` -- commits are mirrored into a repository a host watches.
4551
+ *
4552
+ * `null` is the honest answer for `fs` and memory mode, where publishing is
4553
+ * writing to disk and there is no project to have a mode, and for an `http`
4554
+ * project whose content service predates the field. The Studio treats it as
4555
+ * "the story I have always told", which is the connected one -- the feed, and
4556
+ * a `building` state that something outside resolves. Guessing `managed`
4557
+ * instead would take the deploy feed away from every project running against
4558
+ * an older service.
4559
+ */
4560
+ sourceMode() {
4561
+ return null;
4562
+ }
4563
+
4564
+ /**
4565
+ * Forward one call to the content service's publish API, as this project.
4566
+ *
4567
+ * The Studio builds a managed project in the tab and then has to publish what
4568
+ * it built -- but a browser cannot talk to content directly. It holds a
4569
+ * session cookie for THIS origin and no credential content would accept, so
4570
+ * the conversation goes through here, exactly as patches already do.
4571
+ *
4572
+ * Refused by default, and that is the honest answer rather than a gap: `fs`
4573
+ * and memory mode have no content service to forward to, and publishing there
4574
+ * is writing to disk.
4575
+ *
4576
+ * The path is content's, minus the `/v1` prefix -- `/publish`,
4577
+ * `/publish/{id}/artifacts`, `/build-target`. The ALLOW LIST lives in the
4578
+ * implementation rather than here, because it is what keeps this from being
4579
+ * a way to reach the rest of content with the project's credential.
4580
+ */
4581
+ async publishApi(_path, _init) {
4582
+ return {
4583
+ status: 501,
4584
+ contentType: "application/json",
4585
+ body: JSON.stringify({
4586
+ message: "This Val server has no content service to publish through. " + "Publishing from the browser is for a project whose content is " + "served over HTTP."
4587
+ })
4588
+ };
4589
+ }
4590
+
4527
4591
  /**
4528
4592
  * Whether a commit here produces `.val.ts` TEXT as well as data.
4529
4593
  *
@@ -7883,6 +7947,202 @@ class ValOpsHttp extends ValOps {
7883
7947
  message: `This project mirrors its content into a git repository (branch ` + `'${this.projectExpectation.branch}'), but this deployment was not ` + "built from one, so it does not know which commit to write that " + "mirror against. Publishing would save the content and silently " + "leave the repository behind. Deploy this project again from its " + "repository, and publishing will work from that build on."
7884
7948
  };
7885
7949
  }
7950
+
7951
+ /**
7952
+ * What the content service last said this project's source mode is.
7953
+ *
7954
+ * Read off the same remembered expectation {@link publishRefusal} uses. It is
7955
+ * CURRENT wherever it matters rather than a poll behind, and by construction
7956
+ * rather than by luck: the only caller is `/stat`, which awaits `getStat`
7957
+ * first, and that fetches the patches -- which is the response the expectation
7958
+ * is recorded from.
7959
+ *
7960
+ * `null` before anything has fetched patches, which is the same "not
7961
+ * reported" the wire field means, and reads as connected. The alternative
7962
+ * would be to ask for it separately, which is a round trip for a field that
7963
+ * has just arrived.
7964
+ */
7965
+ sourceMode() {
7966
+ var _this$projectExpectat2;
7967
+ return ((_this$projectExpectat2 = this.projectExpectation) === null || _this$projectExpectat2 === void 0 ? void 0 : _this$projectExpectat2.sourceMode) ?? null;
7968
+ }
7969
+
7970
+ /**
7971
+ * The short-lived publish token, and when it stops being usable.
7972
+ *
7973
+ * `null` until something publishes, which for most deployments is never.
7974
+ */
7975
+ publishToken = null;
7976
+
7977
+ /**
7978
+ * Trade this deployment's credential for one that can do one thing.
7979
+ *
7980
+ * Content's publish API takes a PROJECT TOKEN and nothing else --
7981
+ * `authenticateProjectToken` refuses anything that is not one, deliberately,
7982
+ * because a personal access token is a person's credential and does not name
7983
+ * a project. What this deployment holds is the project's api key, which is
7984
+ * neither.
7985
+ *
7986
+ * `POST /v1/{org}/{project}/publish-token` is the exchange, and it was built
7987
+ * for exactly this: its own docblock names the case where "the caller was the
7988
+ * project's api key ... a machine exchanging one machine credential for a
7989
+ * narrower one". What comes back can publish one project for ten minutes.
7990
+ *
7991
+ * So the api key never leaves this process and the browser never sees any
7992
+ * credential at all. That is the point of routing the publish through here
7993
+ * rather than letting the tab talk to content.
7994
+ */
7995
+ async mintPublishToken() {
7996
+ const res = await fetch(`${this.contentUrl}/v1/${this.project}/publish-token`, {
7997
+ method: "POST",
7998
+ headers: this.authHeaders
7999
+ });
8000
+ const text = await res.text();
8001
+ if (!res.ok) {
8002
+ return {
8003
+ status: res.status,
8004
+ error: `Could not get a publish token for '${this.project}': ` + `${res.status} ${text.slice(0, 300)}`
8005
+ };
8006
+ }
8007
+ let parsed;
8008
+ try {
8009
+ parsed = JSON.parse(text);
8010
+ } catch {
8011
+ return {
8012
+ status: 502,
8013
+ error: "The publish token exchange did not answer with JSON."
8014
+ };
8015
+ }
8016
+ const token = typeof parsed === "object" && parsed !== null && "token" in parsed && typeof parsed.token === "string" ? parsed.token : null;
8017
+ if (token === null) {
8018
+ return {
8019
+ status: 502,
8020
+ error: "The publish token exchange sent no token."
8021
+ };
8022
+ }
8023
+ const expiresAtRaw = typeof parsed === "object" && parsed !== null && "expiresAt" in parsed ? parsed.expiresAt : null;
8024
+ const expiresAt = typeof expiresAtRaw === "string" ? Date.parse(expiresAtRaw) : NaN;
8025
+ return {
8026
+ token,
8027
+ /*
8028
+ * A token with no expiry, or one we cannot read, is treated as expiring
8029
+ * NOW -- so it is used for this call and minted again for the next.
8030
+ * Caching one we cannot reason about is how a publish starts failing
8031
+ * halfway through, days later, for no reason anyone can see.
8032
+ */
8033
+ expiresAt: Number.isFinite(expiresAt) ? expiresAt : 0
8034
+ };
8035
+ }
8036
+
8037
+ /**
8038
+ * A usable publish token, minting one when what we have will not last.
8039
+ *
8040
+ * The margin is what stops a token that is valid when the publish starts from
8041
+ * expiring in the middle of it: a publish is five calls and an upload of
8042
+ * every artifact, and the upload is the slow one.
8043
+ */
8044
+ async currentPublishToken() {
8045
+ const margin = 60_000;
8046
+ if (this.publishToken !== null && this.publishToken.expiresAt - margin > Date.now()) {
8047
+ return {
8048
+ token: this.publishToken.token
8049
+ };
8050
+ }
8051
+ const minted = await this.mintPublishToken();
8052
+ if ("error" in minted) {
8053
+ this.publishToken = null;
8054
+ return minted;
8055
+ }
8056
+ this.publishToken = minted;
8057
+ return {
8058
+ token: minted.token
8059
+ };
8060
+ }
8061
+
8062
+ /**
8063
+ * The content paths this may reach, and nothing else.
8064
+ *
8065
+ * An allow list rather than a prefix check, because this method holds a
8066
+ * credential and the browser chooses the path. `/publish/{id}` and its three
8067
+ * steps are the publish conversation; `/build-target` is what a build needs
8068
+ * to know before it starts; `/project-source` is what it builds.
8069
+ *
8070
+ * `/project-source` is here rather than on a route of its own because it is
8071
+ * one of the three things a publish asks for and none of them are useful
8072
+ * apart -- and because the credential is the same one. The Studio cannot get
8073
+ * the project's files any other way: it runs inside the deployment, which
8074
+ * holds a session for its own origin and an api key for content, and content
8075
+ * is the only thing it is allowed to talk to at all.
8076
+ *
8077
+ * A publish id is opaque and content-generated, so it is matched rather than
8078
+ * parsed -- what matters is that nothing with a `..`, a query or another
8079
+ * segment gets through.
8080
+ */
8081
+ static publishApiPathAllowed(path) {
8082
+ if (path === "/build-target" || path === "/project-source" || path === "/publish") {
8083
+ return true;
8084
+ }
8085
+ return /^\/publish\/[A-Za-z0-9_-]+(\/(artifacts|verify|promote))?$/.test(path);
8086
+ }
8087
+ async publishApi(path, init) {
8088
+ const json = "application/json";
8089
+ if (!ValOpsHttp.publishApiPathAllowed(path)) {
8090
+ return {
8091
+ status: 403,
8092
+ contentType: json,
8093
+ body: JSON.stringify({
8094
+ message: `'${path}' is not part of the publish API.`
8095
+ })
8096
+ };
8097
+ }
8098
+ const send = async token => fetch(`${this.contentUrl}/v1${path}`, {
8099
+ method: init.method,
8100
+ headers: {
8101
+ Authorization: `Bearer ${token}`,
8102
+ ...(init.body === undefined ? {} : {
8103
+ "Content-Type": json
8104
+ })
8105
+ },
8106
+ ...(init.body === undefined ? {} : {
8107
+ body: init.body
8108
+ })
8109
+ });
8110
+ const credential = await this.currentPublishToken();
8111
+ if ("error" in credential) {
8112
+ return {
8113
+ status: credential.status,
8114
+ contentType: json,
8115
+ body: JSON.stringify({
8116
+ message: credential.error
8117
+ })
8118
+ };
8119
+ }
8120
+ let res = await send(credential.token);
8121
+ if (res.status === 401) {
8122
+ /*
8123
+ * Revoked, or expired sooner than it said. One retry with a fresh token,
8124
+ * because the alternative is a publish that fails for a reason the editor
8125
+ * cannot act on and a retry that fails the same way.
8126
+ */
8127
+ this.publishToken = null;
8128
+ const second = await this.currentPublishToken();
8129
+ if ("error" in second) {
8130
+ return {
8131
+ status: second.status,
8132
+ contentType: json,
8133
+ body: JSON.stringify({
8134
+ message: second.error
8135
+ })
8136
+ };
8137
+ }
8138
+ res = await send(second.token);
8139
+ }
8140
+ return {
8141
+ status: res.status,
8142
+ contentType: res.headers.get("content-type") ?? json,
8143
+ body: await res.text()
8144
+ };
8145
+ }
7886
8146
  async onInit() {
7887
8147
  // TODO: unused for now. Implement or remove
7888
8148
  }
@@ -11441,6 +11701,38 @@ const ValServer = (valModules, options, callbacks) => {
11441
11701
  }
11442
11702
  };
11443
11703
  };
11704
+
11705
+ /**
11706
+ * Hand one publish-API call to content and carry the answer back.
11707
+ *
11708
+ * `JSON.parse` and nothing more. That is not validation -- there is no schema
11709
+ * here and no knowledge of content's shapes -- it is the round trip a JSON
11710
+ * body has to make to travel as `json` rather than as a string. A body that
11711
+ * does not parse is a gateway's error page rather than content answering, so
11712
+ * it comes back as a message with the original status instead of throwing:
11713
+ * a publish that failed is something the editor has to be told in words.
11714
+ */
11715
+ const proxyPublishApi = async (path, method, body) => {
11716
+ const answer = await serverOps.publishApi(path ?? "", {
11717
+ method,
11718
+ ...(body === undefined ? {} : {
11719
+ body
11720
+ })
11721
+ });
11722
+ try {
11723
+ return {
11724
+ status: answer.status,
11725
+ json: JSON.parse(answer.body)
11726
+ };
11727
+ } catch {
11728
+ return {
11729
+ status: answer.status,
11730
+ json: {
11731
+ message: answer.body.trim() === "" ? `The publish API answered ${answer.status} with no body.` : `The publish API answered ${answer.status}: ${answer.body.slice(0, 300)}`
11732
+ }
11733
+ };
11734
+ }
11735
+ };
11444
11736
  return {
11445
11737
  "/draft/enable": {
11446
11738
  GET: async req => {
@@ -11864,6 +12156,16 @@ const ValServer = (valModules, options, callbacks) => {
11864
12156
  * it failed to compute.
11865
12157
  */
11866
12158
  const publishRefusal = serverOps.publishRefusal();
12159
+ /*
12160
+ * How this project's source is kept, when the store knows.
12161
+ *
12162
+ * Spread for the same reason `publishRefusal` is: absent has to keep
12163
+ * meaning "not reported" rather than becoming a mode. The Studio
12164
+ * narrates a publish differently for a managed project -- there is
12165
+ * nothing outside the browser to finish it -- so a wrong default here
12166
+ * shows one story to a project that lives by the other.
12167
+ */
12168
+ const sourceMode = serverOps.sourceMode();
11867
12169
  return {
11868
12170
  status: 200,
11869
12171
  json: {
@@ -11873,11 +12175,55 @@ const ValServer = (valModules, options, callbacks) => {
11873
12175
  ...(publishRefusal ? {
11874
12176
  publishRefusal
11875
12177
  } : {}),
12178
+ ...(sourceMode ? {
12179
+ sourceMode
12180
+ } : {}),
11876
12181
  config: options.config
11877
12182
  }
11878
12183
  };
11879
12184
  }
11880
12185
  },
12186
+ /**
12187
+ * The content service's publish API, reached through this deployment.
12188
+ *
12189
+ * The Studio builds a managed project in its own tab and then publishes
12190
+ * what it built. It cannot talk to content directly -- it holds a session
12191
+ * cookie for this origin and no credential content would accept -- so the
12192
+ * whole conversation comes through here.
12193
+ *
12194
+ * Three things happen, and nothing else: the session is checked, the
12195
+ * credential is swapped for a publish-scoped one (see
12196
+ * `ValOpsHttp.publishApi`), and content's answer is carried back with its
12197
+ * status intact. The Studio parses that answer with the same module
12198
+ * `val publish` uses, so there is no copy of content's shapes here to
12199
+ * fall out of step.
12200
+ */
12201
+ "/publish-api": {
12202
+ GET: async req => {
12203
+ const auth = getAuth(req.cookies);
12204
+ if (auth.error) {
12205
+ return {
12206
+ status: 401,
12207
+ json: {
12208
+ message: auth.error
12209
+ }
12210
+ };
12211
+ }
12212
+ return proxyPublishApi(req.path, "GET");
12213
+ },
12214
+ POST: async req => {
12215
+ const auth = getAuth(req.cookies);
12216
+ if (auth.error) {
12217
+ return {
12218
+ status: 401,
12219
+ json: {
12220
+ message: auth.error
12221
+ }
12222
+ };
12223
+ }
12224
+ return proxyPublishApi(req.path, "POST", req.body === undefined ? undefined : JSON.stringify(req.body));
12225
+ }
12226
+ },
11881
12227
  "/upload/patches": {
11882
12228
  POST: async req => {
11883
12229
  if (serverOps instanceof ValOpsHttp) {
@@ -13435,7 +13781,15 @@ const ValServer = (valModules, options, callbacks) => {
13435
13781
  return {
13436
13782
  status: 200,
13437
13783
  json: {
13438
- commitSha: commitRes.commit
13784
+ commitSha: commitRes.commit,
13785
+ /*
13786
+ * For the Studio to BUILD, when it is the deployer. See
13787
+ * `sourceFiles` on the route: the build must contain the text
13788
+ * this commit wrote, and only this handler has it.
13789
+ */
13790
+ ...(serverOps.sourceMode() === "managed" ? {
13791
+ sourceFiles: preparedCommit.patchedSourceFiles
13792
+ } : {})
13439
13793
  }
13440
13794
  };
13441
13795
  }
@@ -17961,4 +18315,4 @@ function readCapturedReport(snapshotDir) {
17961
18315
  return JSON.parse(fs.readFileSync(reportPath, "utf-8"));
17962
18316
  }
17963
18317
 
17964
- 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, createPrettierFormatter, 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 };
18318
+ 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, createPrettierFormatter, createService, createValApiRouter, createValModuleFileInspector, createValOps, createValServer, currentFixHandlers, decodeJwtWithoutVerifying, defineExternal, describePatchStoreProblems, downloadFileFromRemote, encodeJwt, err, evalValConfigFile, extractFileMetadata, extractImageMetadata, extractJsonValuesEntry, findAndEvalValConfigFile, findDefaultExport, 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 };
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.135.0",
19
+ "version": "0.136.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.134.0",
34
- "@valbuild/shared": "0.134.1",
35
- "@valbuild/ui": "0.134.0"
33
+ "@valbuild/core": "0.136.1",
34
+ "@valbuild/shared": "0.136.1",
35
+ "@valbuild/ui": "0.136.1"
36
36
  },
37
37
  "engines": {
38
38
  "node": "^20.19.0 || >=22"