@tokenoftrust/storefront-runner 4.3.1 → 4.3.2
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "4.3.
|
|
3
|
+
"version": "4.3.2",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Child commands, each a fixed argv and never a shell: git, lsof, /usr/bin/python3, node, pnpm, wrangler.
|
|
3
|
+
/** Pinned-ref refresh for the local Wrangler preview. Not the Astro dev server. */
|
|
4
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
5
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
6
|
+
import { closeSync, cpSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join, resolve } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { nonceFindings, tenantAssetRefs } from "../diag/check-preview-serving-core.mjs";
|
|
11
|
+
import { runCatalogSmoke } from "../diag/catalog-local-smoke.mjs";
|
|
12
|
+
import { isMainModule } from "../lib/is-main-module.mjs";
|
|
13
|
+
import { isPortFree } from "./port-check.mjs";
|
|
14
|
+
|
|
15
|
+
export const CATALOG_PORT = 5176;
|
|
16
|
+
export const PREVIEW_PORT = 8787;
|
|
17
|
+
const ASTRO_DEV_PORT = 4321;
|
|
18
|
+
const SHA = /^[a-f0-9]{40}$/;
|
|
19
|
+
const PIN_REL = "apps/storefront/src/lib/catalog/catalog-setup/contract/pin.json";
|
|
20
|
+
function e2eCatalogFixtures(preview) {
|
|
21
|
+
const root = join(preview, "tenants/e2e");
|
|
22
|
+
if (!existsSync(root)) return [];
|
|
23
|
+
return readdirSync(root, { withFileTypes: true })
|
|
24
|
+
.filter((entry) => entry.isDirectory() && entry.name.endsWith(".e2e.test"))
|
|
25
|
+
.map((entry) => `tenants/e2e/${entry.name}/content/catalog`);
|
|
26
|
+
}
|
|
27
|
+
const STOREFRONT_BRANCH = "origin/feature/8500-storefront-integration";
|
|
28
|
+
const CATALOG_BRANCH = "origin/feature/1154-product-catalog";
|
|
29
|
+
const PROCESS_READER = [
|
|
30
|
+
"import ctypes, json, os, struct, sys",
|
|
31
|
+
"from ctypes import c_int, c_uint32, c_uint64, c_char, c_size_t, c_void_p",
|
|
32
|
+
"mode, pid_text = sys.argv[1], sys.argv[2]",
|
|
33
|
+
"pid = int(pid_text)",
|
|
34
|
+
"libc = ctypes.CDLL('/usr/lib/libSystem.B.dylib', use_errno=True)",
|
|
35
|
+
"def argv_of(target):",
|
|
36
|
+
" mib = (c_int * 3)(1, 49, target)",
|
|
37
|
+
" size = c_size_t()",
|
|
38
|
+
" if libc.sysctl(mib, 3, None, ctypes.byref(size), None, 0):",
|
|
39
|
+
" raise SystemExit('process_gone')",
|
|
40
|
+
" buf = ctypes.create_string_buffer(size.value)",
|
|
41
|
+
" if libc.sysctl(mib, 3, buf, ctypes.byref(size), None, 0):",
|
|
42
|
+
" raise SystemExit('process_gone')",
|
|
43
|
+
" raw = buf.raw[:size.value]",
|
|
44
|
+
" if len(raw) < 4:",
|
|
45
|
+
" raise SystemExit('process_gone')",
|
|
46
|
+
" argc = struct.unpack_from('i', raw)[0]",
|
|
47
|
+
" if argc < 1 or argc > 4096:",
|
|
48
|
+
" raise SystemExit('process_gone')",
|
|
49
|
+
" start = raw.index(b'\\0', 4) + 1",
|
|
50
|
+
" while start < len(raw) and raw[start] == 0:",
|
|
51
|
+
" start += 1",
|
|
52
|
+
" parts = [os.fsdecode(part) for part in raw[start:].split(b'\\0')[:argc]]",
|
|
53
|
+
" if len(parts) != argc:",
|
|
54
|
+
" raise SystemExit('process_gone')",
|
|
55
|
+
" return parts",
|
|
56
|
+
"if mode == 'children':",
|
|
57
|
+
" libc.proc_listchildpids.argtypes = [c_int, c_void_p, c_int]",
|
|
58
|
+
" libc.proc_listchildpids.restype = c_int",
|
|
59
|
+
" cap = 4096",
|
|
60
|
+
" buf = (c_int * cap)()",
|
|
61
|
+
" count = libc.proc_listchildpids(pid, ctypes.byref(buf), ctypes.sizeof(buf))",
|
|
62
|
+
" if count < 0 or count >= cap:",
|
|
63
|
+
" raise SystemExit('process_children_unbounded')",
|
|
64
|
+
" print(json.dumps([int(buf[index]) for index in range(count)]))",
|
|
65
|
+
"elif mode == 'identity':",
|
|
66
|
+
" class ProcBsdInfo(ctypes.Structure):",
|
|
67
|
+
" _fields_ = [('pbi_flags', c_uint32), ('pbi_status', c_uint32), ('pbi_xstatus', c_uint32), ('pbi_pid', c_uint32), ('pbi_ppid', c_uint32), ('pbi_uid', c_uint32), ('pbi_gid', c_uint32), ('pbi_ruid', c_uint32), ('pbi_rgid', c_uint32), ('pbi_svuid', c_uint32), ('pbi_svgid', c_uint32), ('reserved', c_uint32), ('pbi_comm', c_char * 16), ('pbi_name', c_char * 32), ('pbi_nfiles', c_uint32), ('pbi_pgid', c_uint32), ('pbi_pjobc', c_uint32), ('e_tdev', c_uint32), ('e_tpgid', c_uint32), ('pbi_nice', c_int), ('pbi_start_tvsec', c_uint64), ('pbi_start_tvusec', c_uint64)]",
|
|
68
|
+
" info = ProcBsdInfo()",
|
|
69
|
+
" libc.proc_pidinfo.argtypes = [c_int, c_int, c_uint64, c_void_p, c_int]",
|
|
70
|
+
" libc.proc_pidinfo.restype = c_int",
|
|
71
|
+
" size = libc.proc_pidinfo(pid, 3, 0, ctypes.byref(info), ctypes.sizeof(info))",
|
|
72
|
+
" if size != ctypes.sizeof(info) or int(info.pbi_pid) != pid:",
|
|
73
|
+
" raise SystemExit('process_gone')",
|
|
74
|
+
" print(json.dumps({'uid': int(info.pbi_uid), 'start': '%d.%d' % (int(info.pbi_start_tvsec), int(info.pbi_start_tvusec)), 'argv': argv_of(pid)}))",
|
|
75
|
+
"else:",
|
|
76
|
+
" raise SystemExit('process_reader_mode')",
|
|
77
|
+
].join("\n");
|
|
78
|
+
|
|
79
|
+
const delay = (ms) => new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
|
|
80
|
+
const fixedError = (code) => new Error(code);
|
|
81
|
+
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
82
|
+
const git = (root, ...args) => execFileSync("git", ["-C", root, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 30000 }).trim();
|
|
83
|
+
const gitBytes = (root, ref) => execFileSync("git", ["-C", root, "show", ref], { stdio: ["ignore", "pipe", "pipe"], timeout: 30000, maxBuffer: 8 * 1024 * 1024 });
|
|
84
|
+
|
|
85
|
+
export function assertServicePorts(ports = { catalog: CATALOG_PORT, preview: PREVIEW_PORT }) {
|
|
86
|
+
if (ports.catalog === ASTRO_DEV_PORT || ports.preview === ASTRO_DEV_PORT) throw fixedError("refresh_astro_port_refused");
|
|
87
|
+
if (ports.catalog !== 5176 || ports.preview !== 8787) throw fixedError("refresh_port_mismatch");
|
|
88
|
+
}
|
|
89
|
+
export function catalogBase() {
|
|
90
|
+
assertServicePorts();
|
|
91
|
+
return `http://127.0.0.1:${CATALOG_PORT}`;
|
|
92
|
+
}
|
|
93
|
+
export function previewBase() {
|
|
94
|
+
assertServicePorts();
|
|
95
|
+
return `http://127.0.0.1:${PREVIEW_PORT}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function validateRequest(options) {
|
|
99
|
+
assertServicePorts();
|
|
100
|
+
if (!SHA.test(options.storefrontSha ?? "") || !SHA.test(options.catalogSha ?? "")) throw fixedError("refresh_full_refs_required");
|
|
101
|
+
for (const key of ["preview", "catalogRepo", "sentinelPath", "sentinelMarker"]) if (!options[key]) throw fixedError("refresh_configuration_missing");
|
|
102
|
+
if (!/^\/(?!\/)[a-zA-Z0-9/._-]*$/.test(options.sentinelPath) || options.sentinelPath.split("/").includes("..")
|
|
103
|
+
|| options.sentinelMarker.length > 160 || /[\x00-\x1f]/.test(options.sentinelMarker)) throw fixedError("refresh_sentinel_invalid");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The effect order is explicit so failures before admission cannot stop the old process. */
|
|
107
|
+
export async function refreshPreview(options, effects = realEffects) {
|
|
108
|
+
validateRequest(options);
|
|
109
|
+
const prior = await effects.inspect(options);
|
|
110
|
+
await effects.prove(prior, options, prior.storefrontSha);
|
|
111
|
+
const prepared = await effects.prepare(options, prior);
|
|
112
|
+
try {
|
|
113
|
+
await effects.prove(prior, options, prior.storefrontSha);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
await effects.discard(prepared);
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
if (prior.storefrontSha === options.storefrontSha) {
|
|
119
|
+
await effects.discard(prepared);
|
|
120
|
+
return { status: "unchanged", storefrontSha: options.storefrontSha, catalogSha: options.catalogSha, restarts: 0, identities: prior.identities };
|
|
121
|
+
}
|
|
122
|
+
let stopped = false;
|
|
123
|
+
let started;
|
|
124
|
+
try {
|
|
125
|
+
await effects.assertUnchanged(prior);
|
|
126
|
+
stopped = true;
|
|
127
|
+
await effects.stop(prior.process);
|
|
128
|
+
started = await effects.start(prepared, prior, options);
|
|
129
|
+
await effects.prove({ ...prior, process: started }, options, options.storefrontSha);
|
|
130
|
+
await effects.assertPrivateState(prior);
|
|
131
|
+
await effects.record(prior, prepared, started, options);
|
|
132
|
+
return { status: "refreshed", storefrontSha: options.storefrontSha, catalogSha: options.catalogSha, restarts: 1, identities: prior.identities };
|
|
133
|
+
} catch {
|
|
134
|
+
if (!stopped) {
|
|
135
|
+
await effects.discard(prepared);
|
|
136
|
+
throw fixedError("refresh_failed_before_stop");
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
if (started) await effects.stop(started);
|
|
140
|
+
const recovered = await effects.start({ checkout: prior.checkout }, prior, options);
|
|
141
|
+
await effects.prove({ ...prior, process: recovered }, options, prior.storefrontSha);
|
|
142
|
+
await effects.assertPrivateState(prior);
|
|
143
|
+
await effects.record(prior, { checkout: prior.checkout }, recovered, { ...options, storefrontSha: prior.storefrontSha });
|
|
144
|
+
await effects.discard(prepared);
|
|
145
|
+
} catch {
|
|
146
|
+
throw fixedError("refresh_failed_recovery_failed");
|
|
147
|
+
}
|
|
148
|
+
throw fixedError("refresh_failed_prior_restored");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function pythonJson(mode, pid) {
|
|
153
|
+
try {
|
|
154
|
+
return JSON.parse(execFileSync("/usr/bin/python3", ["-c", PROCESS_READER, mode, String(pid)], {
|
|
155
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 3000,
|
|
156
|
+
}));
|
|
157
|
+
} catch (error) {
|
|
158
|
+
const detail = `${error.stderr ?? ""}${error.message ?? ""}`;
|
|
159
|
+
if (detail.includes("process_children_unbounded")) throw fixedError("refresh_process_children_unbounded");
|
|
160
|
+
if (detail.includes("process_gone")) return null;
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function linuxIdentity(pid) {
|
|
166
|
+
let argv;
|
|
167
|
+
let status;
|
|
168
|
+
let stat;
|
|
169
|
+
try {
|
|
170
|
+
argv = readFileSync(`/proc/${pid}/cmdline`).toString("utf8").split("\0").filter(Boolean);
|
|
171
|
+
status = readFileSync(`/proc/${pid}/status`, "utf8");
|
|
172
|
+
stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if (error?.code !== "ENOENT") throw error;
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
const uid = Number(/^Uid:\s+(\d+)/m.exec(status)?.[1]);
|
|
178
|
+
const after = stat.slice(stat.lastIndexOf(")") + 1).trim().split(/\s+/);
|
|
179
|
+
if (!argv.length || !Number.isInteger(uid) || !after[19]) return null;
|
|
180
|
+
return { uid, start: after[19], argv };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function childPids(pid) {
|
|
184
|
+
if (process.platform === "darwin") {
|
|
185
|
+
const listed = pythonJson("children", pid);
|
|
186
|
+
if (!Array.isArray(listed) || listed.some((child) => !Number.isInteger(child))) throw fixedError("refresh_process_children_unbounded");
|
|
187
|
+
return listed;
|
|
188
|
+
}
|
|
189
|
+
const children = [];
|
|
190
|
+
for (const name of readdirSync("/proc")) {
|
|
191
|
+
if (!/^\d+$/.test(name)) continue;
|
|
192
|
+
let stat;
|
|
193
|
+
try { stat = readFileSync(`/proc/${name}/stat`, "utf8"); } catch { continue; }
|
|
194
|
+
const parent = Number(stat.slice(stat.lastIndexOf(")") + 1).trim().split(/\s+/)[1]);
|
|
195
|
+
if (parent === pid) children.push(Number(name));
|
|
196
|
+
}
|
|
197
|
+
return children;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function processIdentity(pid) {
|
|
201
|
+
if (!Number.isSafeInteger(pid) || pid < 2) throw fixedError("refresh_process_identity_invalid");
|
|
202
|
+
const info = process.platform === "darwin" ? pythonJson("identity", pid) : linuxIdentity(pid);
|
|
203
|
+
if (!info || !Array.isArray(info.argv) || info.argv.some((part) => typeof part !== "string")) throw fixedError("refresh_process_identity_invalid");
|
|
204
|
+
if (info.uid !== process.getuid?.()) throw fixedError("refresh_process_owner_mismatch");
|
|
205
|
+
let cwd;
|
|
206
|
+
if (process.platform === "darwin") {
|
|
207
|
+
const listing = execFileSync("/usr/sbin/lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 3000 });
|
|
208
|
+
cwd = listing.split("\n").find((line) => line.startsWith("n"))?.slice(1);
|
|
209
|
+
} else cwd = realpathSync(`/proc/${pid}/cwd`);
|
|
210
|
+
if (!cwd) throw fixedError("refresh_process_identity_invalid");
|
|
211
|
+
return { pid, uid: info.uid, start: String(info.start), command: info.argv.join("\0"), argv: info.argv, cwd: realpathSync(cwd) };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function sameProcess(current, identity) {
|
|
215
|
+
return current.pid === identity.pid && current.uid === identity.uid && current.start === identity.start
|
|
216
|
+
&& current.command === identity.command && current.cwd === identity.cwd
|
|
217
|
+
&& JSON.stringify(current.argv) === JSON.stringify(identity.argv);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Re-read pid, cwd, command, and process start time. A swapped pid does not match. */
|
|
221
|
+
export function recheckIdentity(identity, read = processIdentity) {
|
|
222
|
+
const current = read(identity.pid);
|
|
223
|
+
if (!current || !sameProcess(current, identity)) throw fixedError("refresh_process_changed");
|
|
224
|
+
return current;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function assertIdentity(identity) {
|
|
228
|
+
recheckIdentity(identity);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Signal only after a fresh read. `beforeSignal` exists so a test can swap the pid
|
|
233
|
+
* after the first check; the re-read immediately before the signal must refuse it.
|
|
234
|
+
*/
|
|
235
|
+
export function signalOwned(identity, options = {}) {
|
|
236
|
+
const read = options.read ?? processIdentity;
|
|
237
|
+
recheckIdentity(identity, read);
|
|
238
|
+
options.beforeSignal?.(identity);
|
|
239
|
+
recheckIdentity(identity, read);
|
|
240
|
+
process.kill(identity.pid, "SIGTERM");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function privateIdentity(path, file = false) {
|
|
244
|
+
const info = statSync(path);
|
|
245
|
+
if (info.uid !== process.getuid?.()) throw fixedError("refresh_private_owner_mismatch");
|
|
246
|
+
if (file && (info.mode & 0o077)) throw fixedError("refresh_environment_permissions");
|
|
247
|
+
return { path: realpathSync(path), dev: info.dev, ino: info.ino, uid: info.uid, mode: info.mode & 0o777, ...(file ? { digest: digest(readFileSync(path)) } : {}) };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function headersForOrigin(target, configuredOrigin, capability) {
|
|
251
|
+
const headers = { accept: "application/json" };
|
|
252
|
+
if (target.origin === configuredOrigin.origin && capability) headers.authorization = `Bearer ${capability}`;
|
|
253
|
+
return headers;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function fetchLocal(path, base, options = {}) {
|
|
257
|
+
const origin = new URL(base);
|
|
258
|
+
const target = new URL(path, origin);
|
|
259
|
+
if (origin.protocol !== "http:" || origin.hostname !== "127.0.0.1" || origin.username || origin.password
|
|
260
|
+
|| target.origin !== origin.origin || target.username || target.password) throw fixedError("refresh_local_origin_refused");
|
|
261
|
+
const headers = headersForOrigin(target, origin, options.capability);
|
|
262
|
+
if (target.host !== origin.host && (headers.authorization || headers["x-tot-capability"])) throw fixedError("refresh_capability_off_origin");
|
|
263
|
+
const response = await (options.fetchImpl ?? fetch)(target, { redirect: "manual", headers, signal: AbortSignal.timeout(3000) });
|
|
264
|
+
if (response.redirected || (response.status >= 300 && response.status < 400) || !response.ok) {
|
|
265
|
+
await response.body?.cancel();
|
|
266
|
+
throw fixedError("refresh_http_failure");
|
|
267
|
+
}
|
|
268
|
+
return response;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function assertFixtureTree(path) {
|
|
272
|
+
const info = lstatSync(path);
|
|
273
|
+
if (info.isSymbolicLink() || (!info.isDirectory() && !info.isFile())) throw fixedError("refresh_fixture_link_refused");
|
|
274
|
+
if (info.isDirectory()) for (const name of readdirSync(path)) assertFixtureTree(join(path, name));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function embedsSha(path, sha) {
|
|
278
|
+
for (const entry of readdirSync(path, { withFileTypes: true })) {
|
|
279
|
+
const file = join(path, entry.name);
|
|
280
|
+
if (entry.isSymbolicLink()) continue;
|
|
281
|
+
if (entry.isDirectory() && embedsSha(file, sha)) return true;
|
|
282
|
+
if (entry.isFile() && /\.(?:mjs|js|json)$/.test(entry.name) && readFileSync(file, "utf8").includes(sha)) return true;
|
|
283
|
+
}
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function scratch(root, sha) {
|
|
288
|
+
const checkout = join(tmpdir(), `catalog-preview-${randomUUID()}`);
|
|
289
|
+
git(root, "worktree", "add", "--detach", checkout, sha);
|
|
290
|
+
return checkout;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function assertLocalWrangler(owned, app, volumePath) {
|
|
294
|
+
const argv = owned.argv;
|
|
295
|
+
if (!Array.isArray(argv) || !argv.every((value) => typeof value === "string") || owned.cwd !== app) throw fixedError("refresh_not_owned_local_wrangler");
|
|
296
|
+
const dev = argv.findIndex((value, index) => /(?:^|\/)wrangler(?:\.js)?$/.test(value) && argv[index + 1] === "dev");
|
|
297
|
+
if (dev < 0) throw fixedError("refresh_not_owned_local_wrangler");
|
|
298
|
+
const args = argv.slice(dev + 2);
|
|
299
|
+
const values = {};
|
|
300
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
301
|
+
const argument = args[index];
|
|
302
|
+
if (!argument.startsWith("--")) throw fixedError("refresh_not_owned_local_wrangler");
|
|
303
|
+
const equal = argument.indexOf("=");
|
|
304
|
+
const key = equal < 0 ? argument : argument.slice(0, equal);
|
|
305
|
+
if (Object.hasOwn(values, key)) throw fixedError("refresh_not_owned_local_wrangler");
|
|
306
|
+
const value = equal >= 0 ? argument.slice(equal + 1) : key === "--local" ? true : args[index += 1];
|
|
307
|
+
if (value === undefined || (typeof value === "string" && value.startsWith("--"))) throw fixedError("refresh_not_owned_local_wrangler");
|
|
308
|
+
values[key] = value;
|
|
309
|
+
}
|
|
310
|
+
if (values["--local"] !== true || Object.hasOwn(values, "--remote") || values["--port"] !== String(PREVIEW_PORT) || values["--ip"] !== "127.0.0.1"
|
|
311
|
+
|| typeof values["--persist-to"] !== "string") throw fixedError("refresh_not_owned_local_wrangler");
|
|
312
|
+
let persisted;
|
|
313
|
+
try { persisted = realpathSync(resolve(app, values["--persist-to"])); } catch { throw fixedError("refresh_not_owned_local_wrangler"); }
|
|
314
|
+
if (persisted !== volumePath) throw fixedError("refresh_not_owned_local_wrangler");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function descendantsOf(pid) {
|
|
318
|
+
const out = [];
|
|
319
|
+
const visit = (parent) => {
|
|
320
|
+
for (const child of childPids(parent)) {
|
|
321
|
+
visit(child);
|
|
322
|
+
try {
|
|
323
|
+
out.push(processIdentity(child));
|
|
324
|
+
} catch (error) {
|
|
325
|
+
if (error?.message !== "refresh_process_identity_invalid") throw error;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
visit(pid);
|
|
330
|
+
return out;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export const realEffects = {
|
|
334
|
+
async inspect(options) {
|
|
335
|
+
assertServicePorts();
|
|
336
|
+
const preview = realpathSync(options.preview);
|
|
337
|
+
const receiptPath = join(preview, "apps/storefront/.wrangler/catalog-preview-refresh.json");
|
|
338
|
+
const receipt = existsSync(receiptPath) ? JSON.parse(readFileSync(receiptPath, "utf8")) : null;
|
|
339
|
+
const checkout = receipt?.checkout ?? preview;
|
|
340
|
+
const storefrontSha = git(checkout, "rev-parse", "HEAD");
|
|
341
|
+
if (git(checkout, "status", "--porcelain", "--untracked-files=no")) throw fixedError("refresh_preview_tracked_dirty");
|
|
342
|
+
const pid = Number(receipt?.pid ?? options.pid);
|
|
343
|
+
const owned = processIdentity(pid);
|
|
344
|
+
const app = realpathSync(join(checkout, "apps/storefront"));
|
|
345
|
+
const volume = privateIdentity(join(preview, "local-state"));
|
|
346
|
+
const environment = privateIdentity(join(preview, "apps/storefront/.dev.vars"), true);
|
|
347
|
+
assertLocalWrangler(owned, app, volume.path);
|
|
348
|
+
if (checkout !== preview && realpathSync(join(checkout, "apps/storefront/.dev.vars")) !== environment.path) throw fixedError("refresh_serving_environment_mismatch");
|
|
349
|
+
if (receipt?.process && JSON.stringify(owned) !== JSON.stringify(receipt.process)) throw fixedError("refresh_saved_process_mismatch");
|
|
350
|
+
return {
|
|
351
|
+
checkout, storefrontSha, process: owned, preview, receiptPath, volume, environment,
|
|
352
|
+
identities: {
|
|
353
|
+
volume: { dev: volume.dev, ino: volume.ino, uid: volume.uid },
|
|
354
|
+
environment: { dev: environment.dev, ino: environment.ino, uid: environment.uid, mode: environment.mode },
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
},
|
|
358
|
+
async prepare(options, prior) {
|
|
359
|
+
const catalog = realpathSync(options.catalogRepo);
|
|
360
|
+
if (git(catalog, "status", "--porcelain", "--untracked-files=no")) throw fixedError("refresh_catalog_tracked_dirty");
|
|
361
|
+
for (const [root, sha, branch] of [[prior.preview, options.storefrontSha, STOREFRONT_BRANCH], [catalog, options.catalogSha, CATALOG_BRANCH]]) {
|
|
362
|
+
if (git(root, "rev-parse", `${sha}^{commit}`) !== sha) throw fixedError("refresh_ref_missing");
|
|
363
|
+
try { git(root, "merge-base", "--is-ancestor", sha, branch); } catch { throw fixedError("refresh_ref_not_integrated"); }
|
|
364
|
+
}
|
|
365
|
+
if (!gitBytes(prior.preview, `${options.storefrontSha}:pnpm-lock.yaml`).equals(gitBytes(prior.checkout, "HEAD:pnpm-lock.yaml"))
|
|
366
|
+
|| !gitBytes(catalog, `${options.catalogSha}:package-lock.json`).equals(gitBytes(catalog, "HEAD:package-lock.json"))) throw fixedError("refresh_lock_changed");
|
|
367
|
+
const pin = JSON.parse(git(prior.preview, "show", `${options.storefrontSha}:${PIN_REL}`));
|
|
368
|
+
if (pin.repo !== "tokenoftrust/product-catalog" || pin.commit !== options.catalogSha) throw fixedError("refresh_consumer_pin_mismatch");
|
|
369
|
+
for (const [path, hash] of Object.entries(pin.files)) {
|
|
370
|
+
if (digest(execFileSync("git", ["-C", catalog, "show", `${options.catalogSha}:${path}`], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: 8 * 1024 * 1024 })) !== hash) throw fixedError("refresh_contract_hash_mismatch");
|
|
371
|
+
}
|
|
372
|
+
if (prior.storefrontSha === options.storefrontSha) return {};
|
|
373
|
+
for (const rel of e2eCatalogFixtures(prior.preview)) {
|
|
374
|
+
if (existsSync(join(prior.preview, rel)) && git(prior.preview, "ls-tree", "-r", "--name-only", options.storefrontSha, "--", rel)) throw fixedError("refresh_fixture_tracked_collision");
|
|
375
|
+
}
|
|
376
|
+
let checkout;
|
|
377
|
+
let catalogCheckout;
|
|
378
|
+
try {
|
|
379
|
+
checkout = scratch(prior.preview, options.storefrontSha);
|
|
380
|
+
catalogCheckout = scratch(catalog, options.catalogSha);
|
|
381
|
+
for (const rel of e2eCatalogFixtures(prior.preview)) if (existsSync(join(prior.preview, rel))) {
|
|
382
|
+
if (git(checkout, "ls-files", "--", rel)) throw fixedError("refresh_fixture_tracked_collision");
|
|
383
|
+
assertFixtureTree(join(prior.preview, rel));
|
|
384
|
+
cpSync(join(prior.preview, rel), join(checkout, rel), { recursive: true, dereference: false, errorOnExist: true, force: false });
|
|
385
|
+
}
|
|
386
|
+
const dependencies = ["node_modules"];
|
|
387
|
+
for (const workspaceRoot of ["apps", "packages", "agency-kit"]) {
|
|
388
|
+
const sourceRoot = join(prior.checkout, workspaceRoot);
|
|
389
|
+
if (!existsSync(sourceRoot)) continue;
|
|
390
|
+
if (existsSync(join(sourceRoot, "node_modules"))) dependencies.push(`${workspaceRoot}/node_modules`);
|
|
391
|
+
for (const entry of readdirSync(sourceRoot, { withFileTypes: true })) {
|
|
392
|
+
if (entry.isDirectory() && entry.name !== "node_modules" && existsSync(join(sourceRoot, entry.name, "node_modules"))) dependencies.push(`${workspaceRoot}/${entry.name}/node_modules`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
for (const rel of dependencies) {
|
|
396
|
+
if (!existsSync(join(prior.checkout, rel))) throw fixedError("refresh_dependencies_missing");
|
|
397
|
+
cpSync(join(prior.checkout, rel), join(checkout, rel), { recursive: true, dereference: false, preserveTimestamps: true });
|
|
398
|
+
}
|
|
399
|
+
symlinkSync(prior.environment.path, join(checkout, "apps/storefront/.dev.vars"));
|
|
400
|
+
runCatalogSmoke(checkout);
|
|
401
|
+
execFileSync(process.execPath, ["scripts/build/copy-tenant-assets.mjs"], { cwd: checkout, stdio: ["ignore", "pipe", "pipe"], timeout: 45000 });
|
|
402
|
+
execFileSync("pnpm", ["--config.verify-deps-before-run=false", "--filter", "@tot/storefront", "run", "build"], {
|
|
403
|
+
cwd: checkout, env: { ...process.env, CLOUDFLARE_ENV: "preview" }, stdio: ["ignore", "pipe", "pipe"], timeout: 180000, maxBuffer: 4 * 1024 * 1024,
|
|
404
|
+
});
|
|
405
|
+
if (git(checkout, "rev-parse", "HEAD") !== options.storefrontSha || git(checkout, "status", "--porcelain", "--untracked-files=no")) throw fixedError("refresh_build_source_changed");
|
|
406
|
+
const config = JSON.parse(readFileSync(join(checkout, "apps/storefront/dist/server/wrangler.json"), "utf8"));
|
|
407
|
+
if (!embedsSha(join(checkout, "apps/storefront/dist/server"), options.storefrontSha)) throw fixedError("refresh_build_ref_missing");
|
|
408
|
+
if (config.no_bundle !== true) throw fixedError("refresh_build_config_invalid");
|
|
409
|
+
const priorConfig = JSON.parse(readFileSync(join(prior.checkout, "apps/storefront/dist/server/wrangler.json"), "utf8"));
|
|
410
|
+
for (const key of ["d1_databases", "kv_namespaces", "r2_buckets", "durable_objects", "migrations"]) {
|
|
411
|
+
if (JSON.stringify(config[key] ?? null) !== JSON.stringify(priorConfig[key] ?? null)) throw fixedError("refresh_state_bindings_changed");
|
|
412
|
+
}
|
|
413
|
+
git(catalog, "worktree", "remove", catalogCheckout);
|
|
414
|
+
catalogCheckout = undefined;
|
|
415
|
+
return { checkout, storefrontRoot: prior.preview };
|
|
416
|
+
} catch (error) {
|
|
417
|
+
for (const [root, worktree] of [[prior.preview, checkout], [catalog, catalogCheckout]]) {
|
|
418
|
+
if (worktree) try { git(root, "worktree", "remove", worktree); } catch { /* Keep unclean scratch evidence. Never force. */ }
|
|
419
|
+
}
|
|
420
|
+
if (String(error?.message ?? "").startsWith("refresh_")) throw error;
|
|
421
|
+
throw fixedError("refresh_prepare_failed");
|
|
422
|
+
}
|
|
423
|
+
},
|
|
424
|
+
async prove(prior, options, storefrontSha) {
|
|
425
|
+
assertIdentity(prior.process);
|
|
426
|
+
const storefront = await (await fetchLocal("/health", previewBase())).json();
|
|
427
|
+
const catalog = await (await fetchLocal("/health", catalogBase())).json();
|
|
428
|
+
if (storefront.sha !== storefrontSha || catalog.sourceCommit !== options.catalogSha) throw fixedError("refresh_served_ref_mismatch");
|
|
429
|
+
const response = await fetchLocal(options.sentinelPath, previewBase());
|
|
430
|
+
const sentinel = await response.text();
|
|
431
|
+
if (nonceFindings({ csp: response.headers.get("content-security-policy"), html: sentinel }).length) throw fixedError("refresh_sentinel_nonce_mismatch");
|
|
432
|
+
const assets = tenantAssetRefs(sentinel);
|
|
433
|
+
if (assets.length > 32) throw fixedError("refresh_sentinel_asset_limit");
|
|
434
|
+
for (const asset of assets) {
|
|
435
|
+
const fetched = await fetchLocal(asset, previewBase());
|
|
436
|
+
await fetched.body?.cancel();
|
|
437
|
+
}
|
|
438
|
+
if (!sentinel.includes(options.sentinelMarker)) throw fixedError("refresh_sentinel_mismatch");
|
|
439
|
+
},
|
|
440
|
+
async assertUnchanged(prior) {
|
|
441
|
+
assertIdentity(prior.process);
|
|
442
|
+
if (git(prior.checkout, "rev-parse", "HEAD") !== prior.storefrontSha || git(prior.checkout, "status", "--porcelain", "--untracked-files=no")) throw fixedError("refresh_preview_changed");
|
|
443
|
+
await this.assertPrivateState(prior);
|
|
444
|
+
},
|
|
445
|
+
async assertPrivateState(prior) {
|
|
446
|
+
const volume = privateIdentity(prior.volume.path);
|
|
447
|
+
const environment = privateIdentity(prior.environment.path, true);
|
|
448
|
+
if (volume.dev !== prior.volume.dev || volume.ino !== prior.volume.ino || volume.uid !== prior.volume.uid
|
|
449
|
+
|| environment.dev !== prior.environment.dev || environment.ino !== prior.environment.ino || environment.uid !== prior.environment.uid
|
|
450
|
+
|| environment.mode !== prior.environment.mode || environment.digest !== prior.environment.digest) throw fixedError("refresh_private_state_changed");
|
|
451
|
+
},
|
|
452
|
+
async stop(owned) {
|
|
453
|
+
const descendants = descendantsOf(owned.pid);
|
|
454
|
+
for (const identity of [...descendants, owned]) signalOwned(identity);
|
|
455
|
+
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
456
|
+
let live = false;
|
|
457
|
+
for (const identity of [...descendants, owned]) {
|
|
458
|
+
try {
|
|
459
|
+
recheckIdentity(identity);
|
|
460
|
+
live = true;
|
|
461
|
+
} catch { /* Gone or changed: never signal the replacement. */ }
|
|
462
|
+
}
|
|
463
|
+
if (!live) return;
|
|
464
|
+
await delay(100);
|
|
465
|
+
}
|
|
466
|
+
throw fixedError("refresh_owned_stop_timeout");
|
|
467
|
+
},
|
|
468
|
+
async start(prepared, prior, _options) {
|
|
469
|
+
if (!(await isPortFree(PREVIEW_PORT))) throw fixedError("refresh_preview_port_not_free");
|
|
470
|
+
const app = join(prepared.checkout, "apps/storefront");
|
|
471
|
+
const logDir = join(prior.preview, "apps/storefront/.wrangler/catalog-refresh-logs");
|
|
472
|
+
mkdirSync(logDir, { recursive: true, mode: 0o700 });
|
|
473
|
+
const log = openSync(join(logDir, `${randomUUID()}.log`), "wx", 0o600);
|
|
474
|
+
let child;
|
|
475
|
+
try {
|
|
476
|
+
child = spawn(process.execPath, [
|
|
477
|
+
join(app, "node_modules/wrangler/bin/wrangler.js"), "dev", "--config", "dist/server/wrangler.json",
|
|
478
|
+
"--local", "--ip", "127.0.0.1", "--port", String(PREVIEW_PORT), "--persist-to", prior.volume.path, "--log-level", "error",
|
|
479
|
+
], { cwd: app, env: { ...process.env }, detached: true, stdio: ["ignore", log, log] });
|
|
480
|
+
} finally { closeSync(log); }
|
|
481
|
+
let failed = false;
|
|
482
|
+
child.once("error", () => { failed = true; });
|
|
483
|
+
child.once("exit", () => { failed = true; });
|
|
484
|
+
child.unref();
|
|
485
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
486
|
+
if (failed) {
|
|
487
|
+
try { if (child.pid) await this.stop(processIdentity(child.pid)); } catch { /* An exited root cannot be signalled. */ }
|
|
488
|
+
throw fixedError("refresh_start_failed");
|
|
489
|
+
}
|
|
490
|
+
try {
|
|
491
|
+
const identity = processIdentity(child.pid);
|
|
492
|
+
await fetchLocal("/health", previewBase());
|
|
493
|
+
return identity;
|
|
494
|
+
} catch { await delay(200); }
|
|
495
|
+
}
|
|
496
|
+
if (child.pid) {
|
|
497
|
+
try { await this.stop(processIdentity(child.pid)); } catch { throw fixedError("refresh_start_cleanup_failed"); }
|
|
498
|
+
}
|
|
499
|
+
throw fixedError("refresh_start_timeout");
|
|
500
|
+
},
|
|
501
|
+
async record(prior, prepared, processInfo, options) {
|
|
502
|
+
mkdirSync(join(prior.receiptPath, ".."), { recursive: true, mode: 0o700 });
|
|
503
|
+
writeFileSync(prior.receiptPath, `${JSON.stringify({ checkout: prepared.checkout, pid: processInfo.pid, process: processInfo, storefrontSha: options.storefrontSha, catalogSha: options.catalogSha })}\n`, { mode: 0o600 });
|
|
504
|
+
},
|
|
505
|
+
async discard(prepared) {
|
|
506
|
+
if (prepared?.checkout && prepared.storefrontRoot) {
|
|
507
|
+
try { git(prepared.storefrontRoot, "worktree", "remove", prepared.checkout); } catch { /* Keep private scratch evidence when removal is not clean. */ }
|
|
508
|
+
}
|
|
509
|
+
},
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
if (isMainModule(import.meta.url)) {
|
|
513
|
+
if (process.argv.includes("--help")) {
|
|
514
|
+
console.log("Usage: node scripts/dev/catalog-preview-refresh.mjs --preview=<preserved-checkout> --catalog-repo=<catalog-checkout> --storefront-sha=<integrated-full-sha> --catalog-sha=<integrated-full-sha> --pid=<owned-Wrangler-pid-on-first-run> --sentinel-path=/<local-fixture> --sentinel-marker=<stable-fixture-label>\nCatalog must already serve the requested ref on 5176. Preview stays on 8787. Private state is never reset.");
|
|
515
|
+
} else {
|
|
516
|
+
const options = {};
|
|
517
|
+
const keys = { preview: "preview", "catalog-repo": "catalogRepo", "storefront-sha": "storefrontSha", "catalog-sha": "catalogSha", pid: "pid", "sentinel-path": "sentinelPath", "sentinel-marker": "sentinelMarker" };
|
|
518
|
+
try {
|
|
519
|
+
for (const arg of process.argv.slice(2)) {
|
|
520
|
+
const match = /^--([a-z-]+)=(.*)$/.exec(arg);
|
|
521
|
+
if (!match || !keys[match[1]] || Object.hasOwn(options, keys[match[1]])) throw fixedError("refresh_arguments_invalid");
|
|
522
|
+
options[keys[match[1]]] = match[2];
|
|
523
|
+
}
|
|
524
|
+
console.log(JSON.stringify(await refreshPreview(options)));
|
|
525
|
+
} catch (error) {
|
|
526
|
+
console.error(/^refresh_[a-z0-9_]+$/.test(error.message) ? error.message : "catalog_preview_refresh_failed");
|
|
527
|
+
process.exitCode = 1;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|