@valbuild/server 0.139.1 → 0.140.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.
- package/CHANGELOG.md +65 -0
- package/dist/declarations/src/ValOps.d.ts +38 -3
- package/dist/declarations/src/patch/jsonValuesPatch.d.ts +25 -0
- package/dist/declarations/src/valServerConfig.d.ts +9 -0
- package/dist/valbuild-server.cjs.dev.js +471 -18
- package/dist/valbuild-server.cjs.prod.js +471 -18
- package/dist/valbuild-server.esm.js +472 -19
- package/package.json +4 -4
|
@@ -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, parseJob, 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, VAL_STUDIO_HEADER, 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';
|
|
@@ -1451,17 +1451,22 @@ function applyJsonValuesEntryPatches(args) {
|
|
|
1451
1451
|
serializedSchema,
|
|
1452
1452
|
entryKey,
|
|
1453
1453
|
baseContent,
|
|
1454
|
-
patches
|
|
1454
|
+
patches,
|
|
1455
|
+
baseContentOf,
|
|
1456
|
+
stopBefore
|
|
1455
1457
|
} = args;
|
|
1456
1458
|
let content = baseContent;
|
|
1457
1459
|
let deleted = false;
|
|
1458
1460
|
const appliedPatchIds = [];
|
|
1459
|
-
for (const {
|
|
1461
|
+
replay: for (const [patchIndex, {
|
|
1460
1462
|
patchId,
|
|
1461
1463
|
patch
|
|
1462
|
-
} of patches) {
|
|
1464
|
+
}] of patches.entries()) {
|
|
1463
1465
|
let touched = false;
|
|
1464
|
-
for (const rawOp of patch) {
|
|
1466
|
+
for (const [opIndex, rawOp] of patch.entries()) {
|
|
1467
|
+
if (stopBefore && (patchIndex > stopBefore.patch || patchIndex === stopBefore.patch && opIndex >= stopBefore.op)) {
|
|
1468
|
+
break replay;
|
|
1469
|
+
}
|
|
1465
1470
|
/**
|
|
1466
1471
|
* A write of the WHOLE record becomes what it says about THIS entry.
|
|
1467
1472
|
*
|
|
@@ -1486,6 +1491,13 @@ function applyJsonValuesEntryPatches(args) {
|
|
|
1486
1491
|
const cls = serializedSchema ? classifyJsonValuesOp(serializedSchema, op.path) : {
|
|
1487
1492
|
kind: "normal"
|
|
1488
1493
|
};
|
|
1494
|
+
if (op.op === "move" && wholeEntryKey(serializedSchema, op.from) === entryKey && wholeEntryKey(serializedSchema, op.path) !== entryKey) {
|
|
1495
|
+
// Moved AWAY: under its old key, the entry is gone.
|
|
1496
|
+
touched = true;
|
|
1497
|
+
content = undefined;
|
|
1498
|
+
deleted = true;
|
|
1499
|
+
continue;
|
|
1500
|
+
}
|
|
1489
1501
|
if (cls.kind !== "entry" || cls.recordPath.length > 0 || cls.entryKey !== entryKey) {
|
|
1490
1502
|
continue;
|
|
1491
1503
|
}
|
|
@@ -1531,7 +1543,30 @@ function applyJsonValuesEntryPatches(args) {
|
|
|
1531
1543
|
continue;
|
|
1532
1544
|
} else {
|
|
1533
1545
|
// move/copy INTO this key: the content comes from the source entry,
|
|
1534
|
-
//
|
|
1546
|
+
// as it stood at this op (it is a different `*.val.json`).
|
|
1547
|
+
const fromKey = wholeEntryKey(serializedSchema, op.from);
|
|
1548
|
+
const fromBase = fromKey === undefined ? undefined : baseContentOf === null || baseContentOf === void 0 ? void 0 : baseContentOf(fromKey);
|
|
1549
|
+
if (fromKey !== undefined && fromBase !== undefined) {
|
|
1550
|
+
const source = applyJsonValuesEntryPatches({
|
|
1551
|
+
serializedSchema,
|
|
1552
|
+
entryKey: fromKey,
|
|
1553
|
+
baseContent: fromBase.content,
|
|
1554
|
+
patches,
|
|
1555
|
+
baseContentOf,
|
|
1556
|
+
stopBefore: {
|
|
1557
|
+
patch: patchIndex,
|
|
1558
|
+
op: opIndex
|
|
1559
|
+
}
|
|
1560
|
+
});
|
|
1561
|
+
if (source.kind === "error") {
|
|
1562
|
+
return source;
|
|
1563
|
+
}
|
|
1564
|
+
if (source.kind === "content") {
|
|
1565
|
+
content = deepClone(source.content);
|
|
1566
|
+
deleted = false;
|
|
1567
|
+
continue;
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1535
1570
|
return {
|
|
1536
1571
|
kind: "error",
|
|
1537
1572
|
message: `Cannot resolve '${op.op}' of jsonValues entry '${entryKey}' from its own content`,
|
|
@@ -1583,6 +1618,18 @@ function applyJsonValuesEntryPatches(args) {
|
|
|
1583
1618
|
};
|
|
1584
1619
|
}
|
|
1585
1620
|
|
|
1621
|
+
/**
|
|
1622
|
+
* The entry a path names when it names a WHOLE entry of the root
|
|
1623
|
+
* `.jsonValues()` record, and `undefined` for anything else.
|
|
1624
|
+
*/
|
|
1625
|
+
function wholeEntryKey(serializedSchema, path) {
|
|
1626
|
+
if (!serializedSchema) {
|
|
1627
|
+
return undefined;
|
|
1628
|
+
}
|
|
1629
|
+
const cls = classifyJsonValuesOp(serializedSchema, path);
|
|
1630
|
+
return cls.kind === "entry" && cls.recordPath.length === 0 && cls.subPath.length === 0 ? cls.entryKey : undefined;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1586
1633
|
/**
|
|
1587
1634
|
* Resolves an EXISTING entry's `*.val.json` path (relative to rootDir) from the
|
|
1588
1635
|
* `import(...)` path recorded in the `.val.ts` thunk (from
|
|
@@ -2808,7 +2855,7 @@ function v4Of(high, low) {
|
|
|
2808
2855
|
* each other by the API route's zod schema, which is the only place they meet.
|
|
2809
2856
|
*/
|
|
2810
2857
|
|
|
2811
|
-
const DEFAULT_TIMEOUT_MS$
|
|
2858
|
+
const DEFAULT_TIMEOUT_MS$2 = 5000;
|
|
2812
2859
|
const DEFAULT_MAX_REDIRECTS = 5;
|
|
2813
2860
|
|
|
2814
2861
|
/**
|
|
@@ -2906,7 +2953,7 @@ function needsGetFallback(code) {
|
|
|
2906
2953
|
return code === 405 || code === 501;
|
|
2907
2954
|
}
|
|
2908
2955
|
async function probeUrl(rawUrl, options = {}) {
|
|
2909
|
-
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS$
|
|
2956
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS$2;
|
|
2910
2957
|
const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
|
|
2911
2958
|
const now = options.now ?? (() => Date.now());
|
|
2912
2959
|
const started = now();
|
|
@@ -3554,7 +3601,7 @@ class ValOps {
|
|
|
3554
3601
|
message: `Could not fetch patches: ${JSON.stringify(patchOps.errors)}`
|
|
3555
3602
|
};
|
|
3556
3603
|
}
|
|
3557
|
-
modulePatches = scopedModulePatches(patchOps.patches, moduleFilePath, opts === null || opts === void 0 ? void 0 : opts.patchIds).map(p => ({
|
|
3604
|
+
modulePatches = scopedModulePatches(patchOps.patches, moduleFilePath, opts === null || opts === void 0 ? void 0 : opts.patchIds, patchOps.commits).map(p => ({
|
|
3558
3605
|
patchId: p.patchId,
|
|
3559
3606
|
patch: p.patch
|
|
3560
3607
|
}));
|
|
@@ -3568,7 +3615,25 @@ class ValOps {
|
|
|
3568
3615
|
const entries = [];
|
|
3569
3616
|
const missing = [];
|
|
3570
3617
|
const errors = [];
|
|
3571
|
-
|
|
3618
|
+
/*
|
|
3619
|
+
* The entries a whole-entry `move` or `copy` reads from. A renamed or
|
|
3620
|
+
* duplicated entry's content is theirs, so they are loaded beside the
|
|
3621
|
+
* ones asked for, and not reported.
|
|
3622
|
+
*/
|
|
3623
|
+
const sourceKeys = new Set();
|
|
3624
|
+
for (const {
|
|
3625
|
+
patch
|
|
3626
|
+
} of modulePatches) {
|
|
3627
|
+
for (const op of patch) {
|
|
3628
|
+
if (op.op === "move" || op.op === "copy") {
|
|
3629
|
+
const fromKey = wholeEntryKey(serializedSchema, op.from);
|
|
3630
|
+
if (fromKey !== undefined && !requestedKeys.includes(fromKey)) {
|
|
3631
|
+
sourceKeys.add(fromKey);
|
|
3632
|
+
}
|
|
3633
|
+
}
|
|
3634
|
+
}
|
|
3635
|
+
}
|
|
3636
|
+
const loaded = await Promise.all([...requestedKeys, ...sourceKeys].map(async entryKey => {
|
|
3572
3637
|
const marker = record[entryKey];
|
|
3573
3638
|
if (marker !== undefined && !Internal.isJson(marker)) {
|
|
3574
3639
|
// Not a jsonValues entry — return the inlined value as-is (defensive).
|
|
@@ -3630,6 +3695,15 @@ class ValOps {
|
|
|
3630
3695
|
};
|
|
3631
3696
|
}
|
|
3632
3697
|
}));
|
|
3698
|
+
const resolved = loaded.slice(0, requestedKeys.length);
|
|
3699
|
+
const bases = new Map();
|
|
3700
|
+
for (const entry of loaded) {
|
|
3701
|
+
if (!("message" in entry) && !("inline" in entry)) {
|
|
3702
|
+
bases.set(entry.entryKey, {
|
|
3703
|
+
content: entry.baseContent
|
|
3704
|
+
});
|
|
3705
|
+
}
|
|
3706
|
+
}
|
|
3633
3707
|
for (const result of resolved) {
|
|
3634
3708
|
const {
|
|
3635
3709
|
entryKey
|
|
@@ -3659,7 +3733,8 @@ class ValOps {
|
|
|
3659
3733
|
serializedSchema,
|
|
3660
3734
|
entryKey,
|
|
3661
3735
|
baseContent,
|
|
3662
|
-
patches: modulePatches
|
|
3736
|
+
patches: modulePatches,
|
|
3737
|
+
baseContentOf: key => bases.get(key)
|
|
3663
3738
|
});
|
|
3664
3739
|
if (res.kind === "error") {
|
|
3665
3740
|
errors.push({
|
|
@@ -5392,8 +5467,10 @@ function formatPatchSourceError(error) {
|
|
|
5392
5467
|
* 1. this module's, since the chain is branch-wide;
|
|
5393
5468
|
* 2. this caller's, when they asked to be scoped. `undefined` is "everything",
|
|
5394
5469
|
* which is what every unscoped caller gets and must keep getting;
|
|
5395
|
-
* 3.
|
|
5396
|
-
* and
|
|
5470
|
+
* 3. a patch published after this build is kept whatever the scope: it is
|
|
5471
|
+
* nobody's to hold back, and this build does not have it. One already in
|
|
5472
|
+
* the build is dropped. `draftOverlay` decides which is which, from
|
|
5473
|
+
* `commits`.
|
|
5397
5474
|
*
|
|
5398
5475
|
* Filtered here rather than by asking `fetchPatches` for a list, and that is
|
|
5399
5476
|
* load-bearing: both implementations read an empty `patchIds` as "no filter"
|
|
@@ -5403,9 +5480,52 @@ function formatPatchSourceError(error) {
|
|
|
5403
5480
|
* instead of base. It costs no round trip either: the whole chain is what the
|
|
5404
5481
|
* unscoped path fetches anyway.
|
|
5405
5482
|
*/
|
|
5406
|
-
function scopedModulePatches(patches, moduleFilePath, patchIds
|
|
5483
|
+
function scopedModulePatches(patches, moduleFilePath, patchIds, /** The commits content says came after this build: see `draftOverlay`. */
|
|
5484
|
+
commits) {
|
|
5407
5485
|
const scope = patchIds && new Set(patchIds);
|
|
5408
|
-
return patches.filter(patch => patch.path === moduleFilePath &&
|
|
5486
|
+
return draftOverlay(patches.filter(patch => patch.path === moduleFilePath && (patch.appliedAt !== null || scope === undefined || scope.has(patch.patchId))), commits);
|
|
5487
|
+
}
|
|
5488
|
+
|
|
5489
|
+
/**
|
|
5490
|
+
* The patches a DRAFT applies on top of this build's own source: the pending
|
|
5491
|
+
* ones, and the ones published AFTER this build, in chain order -- with
|
|
5492
|
+
* `appliedAt` cleared on the latter, because this build has none of them.
|
|
5493
|
+
*
|
|
5494
|
+
* Content places every caller at its own build (`getApplicablePatchesAndCommits`
|
|
5495
|
+
* in valbuild/home: "the earliest the caller has not seen") and returns the
|
|
5496
|
+
* commits after it as `commits`. A patch applied at one of those was
|
|
5497
|
+
* published after this build and is not in it, so a draft has to apply it --
|
|
5498
|
+
* skipping it showed the build's old value from the publish until the next
|
|
5499
|
+
* build went live. `/sources/~` already applies them (`getSources` walks every
|
|
5500
|
+
* patch it is given); the `.jsonValues()` entry path skipped them.
|
|
5501
|
+
*
|
|
5502
|
+
* A patch applied at a commit NOT in that list is already in this build --
|
|
5503
|
+
* content returns one only because it was asked for by id -- and is dropped:
|
|
5504
|
+
* applying it again would apply it twice. Without `commits` (a store that
|
|
5505
|
+
* does not report them) every applied patch is dropped, as before. Only
|
|
5506
|
+
* `ValOpsHttp` ever reports `appliedAt`; `fs` and memory stores report `null`.
|
|
5507
|
+
*
|
|
5508
|
+
* Chain order, not published-first, because that is the order the Studio
|
|
5509
|
+
* applies them in, and a draft page starts from the server's answer and is
|
|
5510
|
+
* then kept up to date by the Studio's: the two must agree.
|
|
5511
|
+
*
|
|
5512
|
+
* For the reads that render a draft only. What to COMMIT still keys on
|
|
5513
|
+
* `appliedAt`: a published patch must never be published again.
|
|
5514
|
+
*/
|
|
5515
|
+
function draftOverlay(patches, commits) {
|
|
5516
|
+
const afterThisBuild = new Set((commits ?? []).map(commit => commit.commitSha));
|
|
5517
|
+
const overlay = [];
|
|
5518
|
+
for (const patch of patches) {
|
|
5519
|
+
if (patch.appliedAt === null) {
|
|
5520
|
+
overlay.push(patch);
|
|
5521
|
+
} else if (afterThisBuild.has(patch.appliedAt.commitSha)) {
|
|
5522
|
+
overlay.push({
|
|
5523
|
+
...patch,
|
|
5524
|
+
appliedAt: null
|
|
5525
|
+
});
|
|
5526
|
+
}
|
|
5527
|
+
}
|
|
5528
|
+
return overlay;
|
|
5409
5529
|
}
|
|
5410
5530
|
function getFieldsForType(type) {
|
|
5411
5531
|
if (type === "file") {
|
|
@@ -6542,7 +6662,7 @@ function describeLogProblem(problem) {
|
|
|
6542
6662
|
*/
|
|
6543
6663
|
|
|
6544
6664
|
const PATCH_LOCK_FILE_NAME = "patches.lock";
|
|
6545
|
-
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
6665
|
+
const DEFAULT_TIMEOUT_MS$1 = 10_000;
|
|
6546
6666
|
const DEFAULT_POLL_MS = 25;
|
|
6547
6667
|
|
|
6548
6668
|
/**
|
|
@@ -6661,7 +6781,7 @@ function tryCreate(lockFilePath, info) {
|
|
|
6661
6781
|
}
|
|
6662
6782
|
async function acquirePatchLock(lockFilePath, options) {
|
|
6663
6783
|
const now = options.now ?? Date.now;
|
|
6664
|
-
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
6784
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS$1;
|
|
6665
6785
|
const pollMs = options.pollMs ?? DEFAULT_POLL_MS;
|
|
6666
6786
|
const deadline = now() + timeoutMs;
|
|
6667
6787
|
try {
|
|
@@ -11932,6 +12052,135 @@ contentUrl) {
|
|
|
11932
12052
|
}
|
|
11933
12053
|
}
|
|
11934
12054
|
|
|
12055
|
+
/**
|
|
12056
|
+
* Forwarding for `/admin/proxy/*`: Val Build's Studio API, `/v1/studio/*` on
|
|
12057
|
+
* the content server, called on the editor's behalf with the token from their
|
|
12058
|
+
* session.
|
|
12059
|
+
*
|
|
12060
|
+
* The web components the Studio mounts from content.val.build cannot call Val
|
|
12061
|
+
* Build themselves. The editor's credential is in this app's httpOnly
|
|
12062
|
+
* `val_session` cookie, and a cookie of admin.val.build's own would be
|
|
12063
|
+
* third-party on this page, which Safari and Firefox block. So they call this
|
|
12064
|
+
* app, and this is where the token is attached.
|
|
12065
|
+
*
|
|
12066
|
+
* It is a pipe for the path, the query and a JSON body, so a new component or
|
|
12067
|
+
* endpoint needs no Val release. It is NOT a pipe for where the request goes:
|
|
12068
|
+
* the URL is built from `contentUrl` (the server's `valContentUrl`) and must
|
|
12069
|
+
* still be under `/v1/studio/` after the URL parser has resolved `..`,
|
|
12070
|
+
* `%2e%2e` and backslashes. The editor's token can reach the endpoints written
|
|
12071
|
+
* for the Studio and nothing else on Val Build.
|
|
12072
|
+
*/
|
|
12073
|
+
|
|
12074
|
+
const STUDIO_API_PREFIX = "/v1/studio/";
|
|
12075
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
12076
|
+
async function forwardToStudioApi({
|
|
12077
|
+
method,
|
|
12078
|
+
path,
|
|
12079
|
+
rawQuery,
|
|
12080
|
+
body,
|
|
12081
|
+
credential,
|
|
12082
|
+
contentUrl,
|
|
12083
|
+
fetchImpl = fetch,
|
|
12084
|
+
timeoutMs = DEFAULT_TIMEOUT_MS
|
|
12085
|
+
}) {
|
|
12086
|
+
const url = studioApiUrl(contentUrl, path, rawQuery);
|
|
12087
|
+
if (url === null) {
|
|
12088
|
+
return {
|
|
12089
|
+
status: 404,
|
|
12090
|
+
json: {
|
|
12091
|
+
code: "not-found",
|
|
12092
|
+
message: "No such Val Build endpoint."
|
|
12093
|
+
}
|
|
12094
|
+
};
|
|
12095
|
+
}
|
|
12096
|
+
// Every method the route accepts may carry a JSON body: a DELETE that
|
|
12097
|
+
// names what it removes in its body is as valid as a PUT.
|
|
12098
|
+
const sendsBody = method !== "GET" && body !== undefined;
|
|
12099
|
+
let response;
|
|
12100
|
+
try {
|
|
12101
|
+
response = await fetchImpl(url, {
|
|
12102
|
+
method,
|
|
12103
|
+
headers: {
|
|
12104
|
+
// `x-val-pat` is the header the rest of Val Build reads a PAT from.
|
|
12105
|
+
...("pat" in credential ? {
|
|
12106
|
+
"x-val-pat": credential.pat
|
|
12107
|
+
} : {
|
|
12108
|
+
authorization: `Bearer ${credential.bearer}`
|
|
12109
|
+
}),
|
|
12110
|
+
accept: "application/json",
|
|
12111
|
+
...(sendsBody ? {
|
|
12112
|
+
"content-type": "application/json"
|
|
12113
|
+
} : {})
|
|
12114
|
+
},
|
|
12115
|
+
body: sendsBody ? JSON.stringify(body) : undefined,
|
|
12116
|
+
// Never follow a redirect with the token attached.
|
|
12117
|
+
redirect: "manual",
|
|
12118
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
12119
|
+
});
|
|
12120
|
+
} catch {
|
|
12121
|
+
return {
|
|
12122
|
+
status: 500,
|
|
12123
|
+
json: {
|
|
12124
|
+
code: "unreachable",
|
|
12125
|
+
message: "Could not reach Val Build."
|
|
12126
|
+
}
|
|
12127
|
+
};
|
|
12128
|
+
}
|
|
12129
|
+
let json;
|
|
12130
|
+
try {
|
|
12131
|
+
json = await response.json();
|
|
12132
|
+
} catch {
|
|
12133
|
+
json = {
|
|
12134
|
+
code: "bad-gateway",
|
|
12135
|
+
message: `Val Build answered ${response.status} without JSON.`
|
|
12136
|
+
};
|
|
12137
|
+
}
|
|
12138
|
+
return {
|
|
12139
|
+
status: mapStatus(response.status),
|
|
12140
|
+
json
|
|
12141
|
+
};
|
|
12142
|
+
}
|
|
12143
|
+
|
|
12144
|
+
/**
|
|
12145
|
+
* `{contentUrl}/v1/studio{path}{rawQuery}`, or null when the result is not
|
|
12146
|
+
* under that prefix on that origin — the check that keeps the proxy from being
|
|
12147
|
+
* pointed anywhere else with the editor's token.
|
|
12148
|
+
*/
|
|
12149
|
+
function studioApiUrl(contentUrl, path, rawQuery) {
|
|
12150
|
+
if (!path.startsWith("/") || rawQuery !== "" && !rawQuery.startsWith("?")) {
|
|
12151
|
+
return null;
|
|
12152
|
+
}
|
|
12153
|
+
let base;
|
|
12154
|
+
let url;
|
|
12155
|
+
try {
|
|
12156
|
+
base = new URL(contentUrl);
|
|
12157
|
+
url = new URL(`${STUDIO_API_PREFIX}${path.slice(1)}${rawQuery}`, base);
|
|
12158
|
+
} catch {
|
|
12159
|
+
return null;
|
|
12160
|
+
}
|
|
12161
|
+
if (url.origin !== base.origin || !url.pathname.startsWith(STUDIO_API_PREFIX)) {
|
|
12162
|
+
return null;
|
|
12163
|
+
}
|
|
12164
|
+
return url.toString();
|
|
12165
|
+
}
|
|
12166
|
+
|
|
12167
|
+
/**
|
|
12168
|
+
* Onto the statuses this server speaks. The JSON is passed through untouched,
|
|
12169
|
+
* so a `code` from Val Build still says what happened.
|
|
12170
|
+
*/
|
|
12171
|
+
function mapStatus(status) {
|
|
12172
|
+
if (status >= 200 && status < 300) {
|
|
12173
|
+
return 200;
|
|
12174
|
+
}
|
|
12175
|
+
if (status === 400 || status === 401 || status === 403 || status === 404) {
|
|
12176
|
+
return status;
|
|
12177
|
+
}
|
|
12178
|
+
if (status >= 400 && status < 500) {
|
|
12179
|
+
return 400;
|
|
12180
|
+
}
|
|
12181
|
+
return 500;
|
|
12182
|
+
}
|
|
12183
|
+
|
|
11935
12184
|
function getPersonalAccessTokenPath(root) {
|
|
11936
12185
|
return path__default.join(path__default.resolve(root), ".val", "pat.json");
|
|
11937
12186
|
}
|
|
@@ -12475,6 +12724,27 @@ function warnIfInsecureUrls(urls) {
|
|
|
12475
12724
|
* genuinely confusing afternoon.
|
|
12476
12725
|
*/
|
|
12477
12726
|
|
|
12727
|
+
/**
|
|
12728
|
+
* The personal access token `val login` wrote for this project, or null when
|
|
12729
|
+
* there is none (or it does not parse). Only `fs` mode has a working directory
|
|
12730
|
+
* to read it from, so every other mode answers null.
|
|
12731
|
+
*
|
|
12732
|
+
* Unlike `resolveRemoteFileAuth` this never answers with the api key: the
|
|
12733
|
+
* caller wants to act as the DEVELOPER, and an api key is the project.
|
|
12734
|
+
*/
|
|
12735
|
+
async function readValLoginToken(options) {
|
|
12736
|
+
if (options.mode !== "fs") {
|
|
12737
|
+
return null;
|
|
12738
|
+
}
|
|
12739
|
+
const fs = await import('fs');
|
|
12740
|
+
try {
|
|
12741
|
+
const file = await fs.promises.readFile(getPersonalAccessTokenPath(options.cwd), "utf-8");
|
|
12742
|
+
const parsed = parsePersonalAccessTokenFile(file);
|
|
12743
|
+
return parsed.success ? parsed.data.pat : null;
|
|
12744
|
+
} catch {
|
|
12745
|
+
return null;
|
|
12746
|
+
}
|
|
12747
|
+
}
|
|
12478
12748
|
async function resolveRemoteFileAuth(options) {
|
|
12479
12749
|
if (options.apiKey) {
|
|
12480
12750
|
return {
|
|
@@ -12634,6 +12904,129 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
12634
12904
|
return null;
|
|
12635
12905
|
});
|
|
12636
12906
|
};
|
|
12907
|
+
|
|
12908
|
+
/**
|
|
12909
|
+
* `/admin/proxy/*`, for Val Build's web components in the Studio: forwarded
|
|
12910
|
+
* to the Studio API on the content server. See `adminProxy.ts` for what is
|
|
12911
|
+
* forwarded and why.
|
|
12912
|
+
*
|
|
12913
|
+
* Three refusals before anything leaves this server, and the `code`s are
|
|
12914
|
+
* the ones the components act on:
|
|
12915
|
+
* - no `x-val-studio` header: 403, since a cross-site request cannot set it;
|
|
12916
|
+
* - no Val Build behind this Studio (no `valBuildUrl`, or fs mode without
|
|
12917
|
+
* a `val login`): 404 `not-connected`, which the component shows as such
|
|
12918
|
+
* rather than as an outage — though `/admin/status` keeps the Studio from
|
|
12919
|
+
* mounting it at all in that case;
|
|
12920
|
+
* - a deployed Studio without a valid session: 401 `unauthenticated`, which
|
|
12921
|
+
* the component answers by asking the Studio to sign in.
|
|
12922
|
+
*/
|
|
12923
|
+
const adminProxy = async (method, req) => {
|
|
12924
|
+
if (req.headers[VAL_STUDIO_HEADER] !== "1") {
|
|
12925
|
+
return {
|
|
12926
|
+
status: 403,
|
|
12927
|
+
json: {
|
|
12928
|
+
code: "forbidden",
|
|
12929
|
+
message: `Requests to the Val Build proxy need the ${VAL_STUDIO_HEADER} header.`
|
|
12930
|
+
}
|
|
12931
|
+
};
|
|
12932
|
+
}
|
|
12933
|
+
const credential = await getValBuildCredential(req.cookies);
|
|
12934
|
+
if (credential.status !== "ok" || !options.valBuildUrl) {
|
|
12935
|
+
if (!serverOps.requiresAuth || !options.valBuildUrl) {
|
|
12936
|
+
return {
|
|
12937
|
+
status: 404,
|
|
12938
|
+
json: {
|
|
12939
|
+
code: "not-connected",
|
|
12940
|
+
message: "This Studio is not connected to Val Build."
|
|
12941
|
+
}
|
|
12942
|
+
};
|
|
12943
|
+
}
|
|
12944
|
+
return {
|
|
12945
|
+
status: 401,
|
|
12946
|
+
json: {
|
|
12947
|
+
code: "unauthenticated",
|
|
12948
|
+
message: credential.status === "invalid" ? credential.message : "Sign in to Val Build."
|
|
12949
|
+
}
|
|
12950
|
+
};
|
|
12951
|
+
}
|
|
12952
|
+
// To the content server, which serves the Studio API: Val Build's sign-in
|
|
12953
|
+
// (`valBuildUrl`) is what makes the credential, the content server is
|
|
12954
|
+
// what the components' data comes from.
|
|
12955
|
+
return forwardToStudioApi({
|
|
12956
|
+
method,
|
|
12957
|
+
path: req.path,
|
|
12958
|
+
rawQuery: req.rawQuery,
|
|
12959
|
+
body: req.body,
|
|
12960
|
+
credential: credential.credential,
|
|
12961
|
+
contentUrl: options.valContentUrl
|
|
12962
|
+
});
|
|
12963
|
+
};
|
|
12964
|
+
|
|
12965
|
+
/**
|
|
12966
|
+
* What this Studio can act on Val Build with, for the editor in front of it:
|
|
12967
|
+
*
|
|
12968
|
+
* - the session's token, in a deployed Studio (and in a local one where
|
|
12969
|
+
* someone signed in through the Studio anyway);
|
|
12970
|
+
* - otherwise, in fs mode, the developer's own `val login` token from
|
|
12971
|
+
* `.val/pat.json` — the same file `val validate --fix` reads.
|
|
12972
|
+
*
|
|
12973
|
+
* An api key is never one of them: it is the project, not a person, and
|
|
12974
|
+
* these requests are answered with a person's projects.
|
|
12975
|
+
*/
|
|
12976
|
+
const getValBuildCredential = async cookies => {
|
|
12977
|
+
const session = getSessionToken(cookies);
|
|
12978
|
+
if (session.status === "ok") {
|
|
12979
|
+
return {
|
|
12980
|
+
status: "ok",
|
|
12981
|
+
credential: {
|
|
12982
|
+
bearer: session.token
|
|
12983
|
+
}
|
|
12984
|
+
};
|
|
12985
|
+
}
|
|
12986
|
+
if (options.mode === "fs") {
|
|
12987
|
+
const pat = await readValLoginToken(options);
|
|
12988
|
+
if (pat !== null) {
|
|
12989
|
+
return {
|
|
12990
|
+
status: "ok",
|
|
12991
|
+
credential: {
|
|
12992
|
+
pat
|
|
12993
|
+
}
|
|
12994
|
+
};
|
|
12995
|
+
}
|
|
12996
|
+
return {
|
|
12997
|
+
status: "none"
|
|
12998
|
+
};
|
|
12999
|
+
}
|
|
13000
|
+
return session;
|
|
13001
|
+
};
|
|
13002
|
+
|
|
13003
|
+
/** The editor's Val Build token, from inside their session cookie. */
|
|
13004
|
+
const getSessionToken = cookies => {
|
|
13005
|
+
const cookie = cookies[VAL_SESSION_COOKIE];
|
|
13006
|
+
if (typeof cookie !== "string" || !options.valSecret) {
|
|
13007
|
+
return {
|
|
13008
|
+
status: "none"
|
|
13009
|
+
};
|
|
13010
|
+
}
|
|
13011
|
+
const verified = verifyJwt(cookie, options.valSecret);
|
|
13012
|
+
if (!verified.success) {
|
|
13013
|
+
return {
|
|
13014
|
+
status: "invalid",
|
|
13015
|
+
message: sessionErrorMessage(verified.reason)
|
|
13016
|
+
};
|
|
13017
|
+
}
|
|
13018
|
+
const payload = IntegratedServerJwtPayload.safeParse(verified.data);
|
|
13019
|
+
if (!payload.success) {
|
|
13020
|
+
return {
|
|
13021
|
+
status: "invalid",
|
|
13022
|
+
message: "Session invalid. You will need to login again."
|
|
13023
|
+
};
|
|
13024
|
+
}
|
|
13025
|
+
return {
|
|
13026
|
+
status: "ok",
|
|
13027
|
+
token: payload.data.token
|
|
13028
|
+
};
|
|
13029
|
+
};
|
|
12637
13030
|
const getAuth = cookies => {
|
|
12638
13031
|
const cookie = cookies[VAL_SESSION_COOKIE];
|
|
12639
13032
|
/*
|
|
@@ -14313,6 +14706,20 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
14313
14706
|
}
|
|
14314
14707
|
};
|
|
14315
14708
|
}
|
|
14709
|
+
/*
|
|
14710
|
+
* What this build's draft applies: the pending patches and the ones
|
|
14711
|
+
* published AFTER this build, which it does not have -- see
|
|
14712
|
+
* `draftOverlay`. `getSources` already applied both, but
|
|
14713
|
+
* `analyzePatches` skips every patch with `appliedAt`, so a module
|
|
14714
|
+
* changed only by a post-build publish was reported with no
|
|
14715
|
+
* `patches`, and a reader that sends only changed modules (TanStack's
|
|
14716
|
+
* `fetchValDraft`) left it out: the page was rendered with this
|
|
14717
|
+
* build's older value. Chain order, which is what the Studio applies.
|
|
14718
|
+
*/
|
|
14719
|
+
patchOps = {
|
|
14720
|
+
...patchOps,
|
|
14721
|
+
patches: draftOverlay(patchOps.patches, patchOps.commits)
|
|
14722
|
+
};
|
|
14316
14723
|
const patchAnalysis = serverOps.analyzePatches(patchOps.patches);
|
|
14317
14724
|
const schemasRes = await serverOps.getSchemas();
|
|
14318
14725
|
let sourcesRes = await serverOps.getSources();
|
|
@@ -14561,6 +14968,31 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
14561
14968
|
});
|
|
14562
14969
|
}
|
|
14563
14970
|
},
|
|
14971
|
+
"/admin/status": {
|
|
14972
|
+
GET: async req => {
|
|
14973
|
+
const credential = await getValBuildCredential(req.cookies);
|
|
14974
|
+
return {
|
|
14975
|
+
status: 200,
|
|
14976
|
+
json: {
|
|
14977
|
+
connected: Boolean(options.valBuildUrl) && credential.status === "ok",
|
|
14978
|
+
/*
|
|
14979
|
+
* How an editor signs in to Val Build here, which only the server
|
|
14980
|
+
* knows. NOT the stat `mode`: that says whether patches are local,
|
|
14981
|
+
* and a deployed memory-mode host reports `fs` while having no
|
|
14982
|
+
* working directory to run `val login` in. Only a developer's own
|
|
14983
|
+
* checkout (fs mode) signs in with `val login`.
|
|
14984
|
+
*/
|
|
14985
|
+
signIn: options.mode === "fs" ? "val-login" : "studio"
|
|
14986
|
+
}
|
|
14987
|
+
};
|
|
14988
|
+
}
|
|
14989
|
+
},
|
|
14990
|
+
"/admin/proxy": {
|
|
14991
|
+
GET: req => adminProxy("GET", req),
|
|
14992
|
+
POST: req => adminProxy("POST", req),
|
|
14993
|
+
PUT: req => adminProxy("PUT", req),
|
|
14994
|
+
DELETE: req => adminProxy("DELETE", req)
|
|
14995
|
+
},
|
|
14564
14996
|
"/profiles": {
|
|
14565
14997
|
GET: async req => {
|
|
14566
14998
|
const cookies = req.cookies;
|
|
@@ -17041,6 +17473,13 @@ function createValApiRouter(route, valServerPromise, convert) {
|
|
|
17041
17473
|
if (!cookiesRes.success) {
|
|
17042
17474
|
return zodErrorResult(cookiesRes.error, "invalid cookies");
|
|
17043
17475
|
}
|
|
17476
|
+
const headersRes = reqDefinition.headers ? getHeaders(req, reqDefinition.headers) : {
|
|
17477
|
+
success: true,
|
|
17478
|
+
data: {}
|
|
17479
|
+
};
|
|
17480
|
+
if (!headersRes.success) {
|
|
17481
|
+
return zodErrorResult(headersRes.error, "invalid headers");
|
|
17482
|
+
}
|
|
17044
17483
|
const actualQueryParams = groupQueryParams(Array.from(url.searchParams.entries()));
|
|
17045
17484
|
let query = {};
|
|
17046
17485
|
if (reqDefinition.query) {
|
|
@@ -17089,6 +17528,8 @@ function createValApiRouter(route, valServerPromise, convert) {
|
|
|
17089
17528
|
res = await endpointImpl({
|
|
17090
17529
|
body: bodyRes.data,
|
|
17091
17530
|
cookies: cookiesRes.data,
|
|
17531
|
+
headers: headersRes.data,
|
|
17532
|
+
rawQuery: reqDefinition.rawQuery ? url.search : undefined,
|
|
17092
17533
|
query,
|
|
17093
17534
|
path
|
|
17094
17535
|
});
|
|
@@ -17185,10 +17626,22 @@ function zodErrorResult(error, message) {
|
|
|
17185
17626
|
}
|
|
17186
17627
|
|
|
17187
17628
|
// TODO: is this naive implementation is too naive?
|
|
17629
|
+
/** The declared headers, by name; `Headers.get` is case-insensitive. */
|
|
17630
|
+
function getHeaders(req, headersDef) {
|
|
17631
|
+
const input = {};
|
|
17632
|
+
for (const name of Object.keys(headersDef)) {
|
|
17633
|
+
var _req$headers;
|
|
17634
|
+
const value = (_req$headers = req.headers) === null || _req$headers === void 0 ? void 0 : _req$headers.get(name);
|
|
17635
|
+
if (value !== null && value !== undefined) {
|
|
17636
|
+
input[name] = value;
|
|
17637
|
+
}
|
|
17638
|
+
}
|
|
17639
|
+
return z.object(headersDef).safeParse(input);
|
|
17640
|
+
}
|
|
17188
17641
|
function getCookies(req, cookiesDef) {
|
|
17189
|
-
var _req$
|
|
17642
|
+
var _req$headers2;
|
|
17190
17643
|
const input = {};
|
|
17191
|
-
const cookieParts = (_req$
|
|
17644
|
+
const cookieParts = (_req$headers2 = req.headers) === null || _req$headers2 === void 0 || (_req$headers2 = _req$headers2.get("Cookie")) === null || _req$headers2 === void 0 ? void 0 : _req$headers2.split("; ");
|
|
17192
17645
|
for (const name of Object.keys(cookiesDef)) {
|
|
17193
17646
|
const cookie = cookieParts === null || cookieParts === void 0 ? void 0 : cookieParts.find(cookie => cookie.startsWith(`${name}=`));
|
|
17194
17647
|
const value = cookie ? decodeURIComponent(cookie === null || cookie === void 0 ? void 0 : cookie.split("=")[1]) : undefined;
|
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.
|
|
19
|
+
"version": "0.140.0",
|
|
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/
|
|
34
|
-
"@valbuild/shared": "0.
|
|
35
|
-
"@valbuild/
|
|
33
|
+
"@valbuild/ui": "0.140.0",
|
|
34
|
+
"@valbuild/shared": "0.140.0",
|
|
35
|
+
"@valbuild/core": "0.139.1"
|
|
36
36
|
},
|
|
37
37
|
"engines": {
|
|
38
38
|
"node": "^20.19.0 || >=22"
|