@camstack/server 1.2.275 → 1.2.276
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.
|
@@ -52,6 +52,14 @@ function enrichInputWithUserAgent(input, userAgent) {
|
|
|
52
52
|
* candidate LEAK in the forced-relay SDP — and does nothing for relay
|
|
53
53
|
* media-forward, so the connected-but-black regression remains unproven-fixed.
|
|
54
54
|
*
|
|
55
|
+
* And until 2026-09-12 that patch was not even RUNNING on a live node: it is
|
|
56
|
+
* applied by `patch-package` in a dev tree only, and the closure installed
|
|
57
|
+
* werift from npm unpatched (D478). So the leak this gate's re-enable path
|
|
58
|
+
* assumes is fixed was, in fact, present in production the whole time — do not
|
|
59
|
+
* run the one-real-remote-relay-session validation against a closure that
|
|
60
|
+
* predates the closure-postinstall applier, or it will fail for that reason
|
|
61
|
+
* rather than for the media-forward one it is meant to test.
|
|
62
|
+
*
|
|
55
63
|
* Until an operator confirms that ONE real remote relay session (4G /
|
|
56
64
|
* Cloudflare-tunnel) actually delivers media, remote viewers stay on the same
|
|
57
65
|
* `relayOnly:false` path as everyone else. Set `CAMSTACK_FORCE_RELAY_REMOTE=1`
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema-version": 1,
|
|
3
|
+
"$why": [
|
|
4
|
+
"A patch under `patches/` is applied by `patch-package` on the repo root's",
|
|
5
|
+
"`postinstall` — which runs HERE, in a developer's or a CI runner's",
|
|
6
|
+
"`node_modules`, and NOWHERE ELSE. Whether the patched behaviour exists in",
|
|
7
|
+
"production is a separate question with a different answer per package, and",
|
|
8
|
+
"for eight weeks nothing in this repository asked it (D478).",
|
|
9
|
+
"",
|
|
10
|
+
"This file is the answer, per patch, and it is the single authority for:",
|
|
11
|
+
" - `scripts/check-patches-reach-production.ts` (CI: is the claim TRUE?),",
|
|
12
|
+
" - `server/backend/scripts/closure-postinstall.mjs` (applies the",
|
|
13
|
+
" `closure-postinstall` patches inside the installed framework closure),",
|
|
14
|
+
" - `findUnappliedClosurePatches` in `@camstack/node-root` (refuses to swap",
|
|
15
|
+
" into a closure where one did not land).",
|
|
16
|
+
"",
|
|
17
|
+
"Adding a patch to `patches/` without an entry here FAILS CI. There is no",
|
|
18
|
+
"default reach: a patch nobody can say ships is a patch that does not."
|
|
19
|
+
],
|
|
20
|
+
"patches": [
|
|
21
|
+
{
|
|
22
|
+
"patch": "moleculer+0.15.0.patch",
|
|
23
|
+
"package": "moleculer",
|
|
24
|
+
"version": "0.15.0",
|
|
25
|
+
"reach": "bundled",
|
|
26
|
+
"bundledInto": ["packages/system/dist/index.js", "packages/system/dist/index.mjs"],
|
|
27
|
+
"markers": ["rest.buffer.byteLength"],
|
|
28
|
+
"why": [
|
|
29
|
+
"`moleculer` is NOT in the vite preset's externals, so rolldown inlines",
|
|
30
|
+
"its whole tree — parser included — into `@camstack/system/dist`, and the",
|
|
31
|
+
"patch travels inside the published tarball. Verified 2026-09-12 in the",
|
|
32
|
+
"`@camstack/system@1.2.224` tarball and in the live closure on both the",
|
|
33
|
+
"hub and the little-unraid agent.",
|
|
34
|
+
"",
|
|
35
|
+
"The closure ALSO carries an unpatched `node_modules/moleculer` — npm",
|
|
36
|
+
"installs it for `@camstack/system`'s dependency entry — and exactly one",
|
|
37
|
+
"file requires it: `@camstack/server/dist/agent/agent-service.js`, for",
|
|
38
|
+
"`Errors`. Nothing constructs a `ServiceBroker` from it; every real broker",
|
|
39
|
+
"on hub and agent comes from `createBroker` in `@camstack/system`, so the",
|
|
40
|
+
"TCP `Parser` that runs is the bundled, patched one. That unused copy is",
|
|
41
|
+
"why a grep of the closure's `node_modules/moleculer` reads as unpatched",
|
|
42
|
+
"and means nothing.",
|
|
43
|
+
"",
|
|
44
|
+
"The marker is `rest.buffer.byteLength`, NOT the `pinsTooMuch` identifier",
|
|
45
|
+
"the patch spells: rolldown inlines a single-use const, so `pinsTooMuch`",
|
|
46
|
+
"is absent from every bundle including the correct ones. A marker that a",
|
|
47
|
+
"bundler can erase cannot answer this question."
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"patch": "werift+0.22.9.patch",
|
|
52
|
+
"package": "werift",
|
|
53
|
+
"version": "0.22.9",
|
|
54
|
+
"reach": "closure-postinstall",
|
|
55
|
+
"markers": {
|
|
56
|
+
"lib/ice/src/ice.js": ["camstack: relay-only SDP under forceTurn"],
|
|
57
|
+
"lib/index.mjs": ["camstack: relay-only SDP under forceTurn", "RTP_HISTORY_SIZE = 4096"],
|
|
58
|
+
"lib/webrtc/src/media/rtpSender.js": ["RTP_HISTORY_SIZE = 4096"]
|
|
59
|
+
},
|
|
60
|
+
"declaredBy": [
|
|
61
|
+
"packages/addon-pipeline",
|
|
62
|
+
"packages/addon-provider-reolink",
|
|
63
|
+
"packages/addon-provider-hikvision"
|
|
64
|
+
],
|
|
65
|
+
"why": [
|
|
66
|
+
"`werift` is the opposite case, and it is the one that was broken. It is",
|
|
67
|
+
"deliberately NOT bundled: the three addons that use it declare it and",
|
|
68
|
+
"`session.ts` resolves it at RUNTIME out of the framework closure, so the",
|
|
69
|
+
"installer's closure-dedupe (D343) links all three at ONE copy. That copy",
|
|
70
|
+
"is a plain npm tarball. The closure has no `patches/` directory and had",
|
|
71
|
+
"no lifecycle step that could apply one, so production ran werift 0.22.9",
|
|
72
|
+
"UNPATCHED — verified 2026-09-12 on the hub, the little-unraid agent and",
|
|
73
|
+
"the Mac-mini Electron agent, all three showing `RTP_HISTORY_SIZE = 128`",
|
|
74
|
+
"and no forceTurn guard.",
|
|
75
|
+
"",
|
|
76
|
+
"It now reaches production because `@camstack/server` ships these patches",
|
|
77
|
+
"in `dist/closure-patches/` and applies them from its own `postinstall`,",
|
|
78
|
+
"which runs inside the staging closure (the closure install passes no",
|
|
79
|
+
"`--ignore-scripts`). A closure where a marker did not land is refused by",
|
|
80
|
+
"`findUnappliedClosurePatches` before it can be swapped in.",
|
|
81
|
+
"",
|
|
82
|
+
"The version is EXACT, in this file and in all three declaring packages.",
|
|
83
|
+
"A caret would let npm resolve 0.22.10 into the closure, the patch would",
|
|
84
|
+
"not apply, and the only honest outcome of that is a refused update — so",
|
|
85
|
+
"the range that could cause it is not allowed to exist."
|
|
86
|
+
]
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"patch": "node-turn+0.0.6.patch",
|
|
90
|
+
"package": "node-turn",
|
|
91
|
+
"reach": "dev-only",
|
|
92
|
+
"why": [
|
|
93
|
+
"`node-turn` is a devDependency of exactly one workspace package,",
|
|
94
|
+
"`@camstack/werift-turn-fix` — `private: true`, `camstack: false`, no",
|
|
95
|
+
"addons, never published and never deployed. It exists to stand up a real",
|
|
96
|
+
"TURN server for the relay specs. Nothing in production loads it, so the",
|
|
97
|
+
"patch has nothing to reach.",
|
|
98
|
+
"",
|
|
99
|
+
"The guard keeps this honest: if any publishable package ever declares",
|
|
100
|
+
"`node-turn` in `dependencies` or `peerDependencies`, `dev-only` becomes",
|
|
101
|
+
"a false claim and CI fails."
|
|
102
|
+
]
|
|
103
|
+
}
|
|
104
|
+
]
|
|
105
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
diff --git a/node_modules/werift/lib/ice/src/ice.js b/node_modules/werift/lib/ice/src/ice.js
|
|
2
|
+
index 74cf126..0c5cedf 100644
|
|
3
|
+
--- a/node_modules/werift/lib/ice/src/ice.js
|
|
4
|
+
+++ b/node_modules/werift/lib/ice/src/ice.js
|
|
5
|
+
@@ -635,7 +635,10 @@ class Connection {
|
|
6
|
+
const candidateAddress = [address, protocol.getExtraInfo()[1]];
|
|
7
|
+
protocol.localCandidate = new candidate_1.Candidate((0, candidate_1.candidateFoundation)("host", "udp", candidateAddress[0]), 1, "udp", (0, candidate_1.candidatePriority)("host"), candidateAddress[0], candidateAddress[1], "host", undefined, undefined, undefined, this.generation, this.localUsername);
|
|
8
|
+
this.pairLocalProtocol(protocol);
|
|
9
|
+
- this.appendLocalCandidate(protocol.localCandidate);
|
|
10
|
+
+ // camstack: relay-only SDP under forceTurn (werift forceTurn candidate leak) — see werift-turn-fix spec
|
|
11
|
+
+ if (!this.options.forceTurn) {
|
|
12
|
+
+ this.appendLocalCandidate(protocol.localCandidate);
|
|
13
|
+
+ }
|
|
14
|
+
return protocol;
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
@@ -656,7 +659,8 @@ class Connection {
|
|
18
|
+
const candidate = await (0, iceBase_1.serverReflexiveCandidate)(protocol, stunServer).catch((error) => {
|
|
19
|
+
log("error", error);
|
|
20
|
+
});
|
|
21
|
+
- if (candidate) {
|
|
22
|
+
+ // camstack: relay-only SDP under forceTurn (werift forceTurn candidate leak) — see werift-turn-fix spec
|
|
23
|
+
+ if (candidate && !this.options.forceTurn) {
|
|
24
|
+
this.appendLocalCandidate(candidate);
|
|
25
|
+
}
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
diff --git a/node_modules/werift/lib/index.mjs b/node_modules/werift/lib/index.mjs
|
|
28
|
+
index 3e8b013..48823e7 100644
|
|
29
|
+
--- a/node_modules/werift/lib/index.mjs
|
|
30
|
+
+++ b/node_modules/werift/lib/index.mjs
|
|
31
|
+
@@ -7940,7 +7940,10 @@ var Connection = class {
|
|
32
|
+
this.localUsername
|
|
33
|
+
);
|
|
34
|
+
this.pairLocalProtocol(protocol);
|
|
35
|
+
- this.appendLocalCandidate(protocol.localCandidate);
|
|
36
|
+
+ // camstack: relay-only SDP under forceTurn (werift forceTurn candidate leak) — see werift-turn-fix spec
|
|
37
|
+
+ if (!this.options.forceTurn) {
|
|
38
|
+
+ this.appendLocalCandidate(protocol.localCandidate);
|
|
39
|
+
+ }
|
|
40
|
+
return protocol;
|
|
41
|
+
} catch (error) {
|
|
42
|
+
log22("error protocol STUN", error);
|
|
43
|
+
@@ -7967,7 +7970,8 @@ var Connection = class {
|
|
44
|
+
).catch((error) => {
|
|
45
|
+
log22("error", error);
|
|
46
|
+
});
|
|
47
|
+
- if (candidate) {
|
|
48
|
+
+ // camstack: relay-only SDP under forceTurn (werift forceTurn candidate leak) — see werift-turn-fix spec
|
|
49
|
+
+ if (candidate && !this.options.forceTurn) {
|
|
50
|
+
this.appendLocalCandidate(candidate);
|
|
51
|
+
}
|
|
52
|
+
clearTimeout(timer2);
|
|
53
|
+
@@ -13565,7 +13569,7 @@ var SenderBandwidthEstimator = class {
|
|
54
|
+
|
|
55
|
+
// src/media/rtpSender.ts
|
|
56
|
+
var log33 = debug("werift:packages/webrtc/src/media/rtpSender.ts");
|
|
57
|
+
-var RTP_HISTORY_SIZE = 128;
|
|
58
|
+
+var RTP_HISTORY_SIZE = 4096;
|
|
59
|
+
var RTT_ALPHA = 0.85;
|
|
60
|
+
var RTCRtpSender = class {
|
|
61
|
+
constructor(trackOrKind) {
|
|
62
|
+
diff --git a/node_modules/werift/lib/webrtc/src/media/rtpSender.js b/node_modules/werift/lib/webrtc/src/media/rtpSender.js
|
|
63
|
+
index bc85968..64a5304 100644
|
|
64
|
+
--- a/node_modules/werift/lib/webrtc/src/media/rtpSender.js
|
|
65
|
+
+++ b/node_modules/werift/lib/webrtc/src/media/rtpSender.js
|
|
66
|
+
@@ -33,7 +33,7 @@ const utils_1 = require("../utils");
|
|
67
|
+
const senderBWE_1 = require("./sender/senderBWE");
|
|
68
|
+
const stats_1 = require("./stats");
|
|
69
|
+
const log = (0, rtp_1.debug)("werift:packages/webrtc/src/media/rtpSender.ts");
|
|
70
|
+
-const RTP_HISTORY_SIZE = 128;
|
|
71
|
+
+const RTP_HISTORY_SIZE = 4096;
|
|
72
|
+
const RTT_ALPHA = 0.85;
|
|
73
|
+
class RTCRtpSender {
|
|
74
|
+
constructor(trackOrKind) {
|
|
@@ -101,6 +101,7 @@ var require_dist = __commonJS({
|
|
|
101
101
|
devUploadsDir: /* @__PURE__ */ __name(() => devUploadsDir, "devUploadsDir"),
|
|
102
102
|
emptyServerRootState: /* @__PURE__ */ __name(() => emptyServerRootState, "emptyServerRootState"),
|
|
103
103
|
findMissingNativePrebuilds: /* @__PURE__ */ __name(() => findMissingNativePrebuilds, "findMissingNativePrebuilds"),
|
|
104
|
+
findUnappliedClosurePatches: /* @__PURE__ */ __name(() => findUnappliedClosurePatches, "findUnappliedClosurePatches"),
|
|
104
105
|
isDevChannelVersion: /* @__PURE__ */ __name(() => isDevChannelVersion, "isDevChannelVersion"),
|
|
105
106
|
isHostExternal: /* @__PURE__ */ __name(() => isHostExternal, "isHostExternal"),
|
|
106
107
|
isServerRootState: /* @__PURE__ */ __name(() => isServerRootState, "isServerRootState"),
|
|
@@ -505,6 +506,58 @@ var require_dist = __commonJS({
|
|
|
505
506
|
return missing;
|
|
506
507
|
}
|
|
507
508
|
__name(findMissingNativePrebuilds, "findMissingNativePrebuilds");
|
|
509
|
+
var CLOSURE_PATCH_PAYLOAD = [
|
|
510
|
+
"node_modules",
|
|
511
|
+
"@camstack",
|
|
512
|
+
"server",
|
|
513
|
+
"dist",
|
|
514
|
+
"closure-patches"
|
|
515
|
+
];
|
|
516
|
+
var CLOSURE_PATCH_MANIFEST = "patch-reach.json";
|
|
517
|
+
function fileContains(file, marker) {
|
|
518
|
+
try {
|
|
519
|
+
return fs3.readFileSync(file, "utf-8").includes(marker);
|
|
520
|
+
} catch {
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
__name(fileContains, "fileContains");
|
|
525
|
+
function findUnappliedClosurePatches(closureDir) {
|
|
526
|
+
const payloadDir = path3.join(closureDir, ...CLOSURE_PATCH_PAYLOAD);
|
|
527
|
+
let manifest;
|
|
528
|
+
try {
|
|
529
|
+
const raw = JSON.parse(fs3.readFileSync(path3.join(payloadDir, CLOSURE_PATCH_MANIFEST), "utf-8"));
|
|
530
|
+
if (typeof raw !== "object" || raw === null) return [];
|
|
531
|
+
manifest = raw;
|
|
532
|
+
} catch {
|
|
533
|
+
return [];
|
|
534
|
+
}
|
|
535
|
+
if (!Array.isArray(manifest.patches)) return [];
|
|
536
|
+
const nm = path3.join(closureDir, "node_modules");
|
|
537
|
+
const missing = [];
|
|
538
|
+
for (const raw of manifest.patches) {
|
|
539
|
+
if (raw.reach !== "closure-postinstall") continue;
|
|
540
|
+
const pkg = raw.package;
|
|
541
|
+
const markers = raw.markers;
|
|
542
|
+
if (typeof pkg !== "string" || typeof markers !== "object" || markers === null) continue;
|
|
543
|
+
const pkgDir = path3.join(nm, pkg);
|
|
544
|
+
if (!fs3.existsSync(pkgDir)) {
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
for (const [rel, list] of Object.entries(markers)) {
|
|
548
|
+
if (!Array.isArray(list)) continue;
|
|
549
|
+
const target = path3.join(pkgDir, ...rel.split("/"));
|
|
550
|
+
for (const marker of list) {
|
|
551
|
+
if (typeof marker !== "string") continue;
|
|
552
|
+
if (!fileContains(target, marker)) {
|
|
553
|
+
missing.push(`${pkg}/${rel}: ${marker}`);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return missing;
|
|
559
|
+
}
|
|
560
|
+
__name(findUnappliedClosurePatches, "findUnappliedClosurePatches");
|
|
508
561
|
function rmrf(target) {
|
|
509
562
|
try {
|
|
510
563
|
fs3.rmSync(target, {
|
|
@@ -1441,6 +1494,10 @@ var require_dist = __commonJS({
|
|
|
1441
1494
|
if (missingNatives.length > 0) {
|
|
1442
1495
|
throw new Error(`staged closure missing native prebuilds: ${missingNatives.join(", ")} \u2014 refusing to arm a swap that would break the forked runners`);
|
|
1443
1496
|
}
|
|
1497
|
+
const unpatched = findUnappliedClosurePatches(stagingDir);
|
|
1498
|
+
if (unpatched.length > 0) {
|
|
1499
|
+
throw new Error(`staged closure did not apply its shipped patches: ${unpatched.join(", ")} \u2014 refusing to arm a swap into an unpatched closure`);
|
|
1500
|
+
}
|
|
1444
1501
|
return stagingDir;
|
|
1445
1502
|
} catch (err) {
|
|
1446
1503
|
await fs6.promises.rm(stagingDir, {
|
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/server",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.276",
|
|
4
4
|
"private": false,
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
7
|
+
"scripts/closure-postinstall.mjs",
|
|
7
8
|
".env.example"
|
|
8
9
|
],
|
|
9
10
|
"exports": {
|
|
@@ -13,7 +14,8 @@
|
|
|
13
14
|
"./starter.js": "./dist/starter.js"
|
|
14
15
|
},
|
|
15
16
|
"scripts": {
|
|
16
|
-
"build": "tsc -p tsconfig.build.json && tsup",
|
|
17
|
+
"build": "tsc -p tsconfig.build.json && tsup && node scripts/copy-closure-patches.mjs",
|
|
18
|
+
"postinstall": "node scripts/closure-postinstall.mjs",
|
|
17
19
|
"dev": "npx concurrently -n server,ui -c blue,magenta \"CAMSTACK_RESTART_TOUCH_FILE=src/launcher.ts tsx watch --env-file=.env --ignore=./camstack-data --ignore=../../packages/core/dist --ignore=../../packages/sdk/dist --ignore=../../packages/ui-library/dist --ignore=../../packages/shm-ring/dist src/launcher.ts\" \"cd ../../packages/addon-admin-ui && npx vite --port 3001\"",
|
|
18
20
|
"serve": "CAMSTACK_RESTART_TOUCH_FILE=src/launcher.ts tsx watch --env-file=.env --ignore=./camstack-data --ignore=../../packages/core/dist --ignore=../../packages/sdk/dist --ignore=../../packages/ui-library/dist --ignore=../../packages/shm-ring/dist src/launcher.ts",
|
|
19
21
|
"serve:once": "tsx --env-file=.env src/launcher.ts",
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `@camstack/server`'s `postinstall` — apply the `closure-postinstall` patches
|
|
4
|
+
* to the framework closure this package was just installed into. (D478)
|
|
5
|
+
*
|
|
6
|
+
* ## Why a lifecycle script and not a bundler
|
|
7
|
+
*
|
|
8
|
+
* A patch under `patches/` reaches production only if the patched package is
|
|
9
|
+
* BUNDLED at build time. `moleculer` is (into `@camstack/system/dist`), so its
|
|
10
|
+
* patch travels inside a published tarball and needs nothing here. `werift` is
|
|
11
|
+
* not, on purpose: three addons resolve it at runtime from the closure so the
|
|
12
|
+
* installer's dedupe (D343) links them all at ONE 5.3 MB copy, and that copy
|
|
13
|
+
* arrives from npm unpatched. Production ran it unpatched from the day the
|
|
14
|
+
* patch was written until 2026-09-12.
|
|
15
|
+
*
|
|
16
|
+
* The closure is an `npm install` into a staging dir and it passes no
|
|
17
|
+
* `--ignore-scripts`, so this package's own `postinstall` is the one place that
|
|
18
|
+
* (a) runs inside the closure, (b) runs with the NEW release's patch files,
|
|
19
|
+
* and (c) needs no new dependency, no diff library and no change to the update
|
|
20
|
+
* service. Every node gets it identically — hub, Docker agent, Electron agent
|
|
21
|
+
* — because all three stage the closure through the same `RootUpdateService`.
|
|
22
|
+
*
|
|
23
|
+
* ## Failure policy, deliberately split in two
|
|
24
|
+
*
|
|
25
|
+
* This script is BEST-EFFORT: it exits 0 on anything it cannot do, including a
|
|
26
|
+
* missing patch payload (the workspace dev tree, where `patch-package` already
|
|
27
|
+
* did the work and `dist/` may not exist yet) and an `ignore-scripts` npmrc it
|
|
28
|
+
* cannot see from inside. It must never be the reason an install fails, and it
|
|
29
|
+
* must never leave a package half-patched — every hunk is applied to an
|
|
30
|
+
* in-memory copy and the file is written once, or not at all.
|
|
31
|
+
*
|
|
32
|
+
* The FAIL-CLOSED half lives elsewhere, in `@camstack/node-root`'s
|
|
33
|
+
* `findUnappliedClosurePatches`, which refuses to swap into a closure whose
|
|
34
|
+
* markers are absent. Silence here is therefore never silence overall: either
|
|
35
|
+
* the patch landed, or the update visibly does not apply. A best-effort applier
|
|
36
|
+
* plus a fail-closed validator is the only arrangement where a skipped patch
|
|
37
|
+
* cannot look like a working one.
|
|
38
|
+
*
|
|
39
|
+
* ## Idempotence
|
|
40
|
+
*
|
|
41
|
+
* npm may run this more than once, and the repo root's `patch-package` already
|
|
42
|
+
* applies the same patches to the dev tree. Every hunk therefore recognises its
|
|
43
|
+
* own output: `before` present exactly once ⇒ apply; `before` absent and
|
|
44
|
+
* `after` present exactly once ⇒ already applied, skip. Anything else refuses
|
|
45
|
+
* that FILE (never a partial write) and the validator turns it into a refused
|
|
46
|
+
* update.
|
|
47
|
+
*/
|
|
48
|
+
import * as fs from 'node:fs'
|
|
49
|
+
import * as path from 'node:path'
|
|
50
|
+
import { fileURLToPath } from 'node:url'
|
|
51
|
+
|
|
52
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
53
|
+
|
|
54
|
+
/** Where the build step (`copy-closure-patches.mjs`) puts the shipped payload. */
|
|
55
|
+
const PAYLOAD_DIRS = [
|
|
56
|
+
// `scripts/` and `dist/` are siblings inside the package in BOTH layouts —
|
|
57
|
+
// the published tarball and a workspace checkout with a built dist.
|
|
58
|
+
path.resolve(HERE, '..', 'dist', 'closure-patches'),
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
const log = (msg) => process.stdout.write(`[closure-patches] ${msg}\n`)
|
|
62
|
+
|
|
63
|
+
function findPayloadDir() {
|
|
64
|
+
for (const dir of PAYLOAD_DIRS) {
|
|
65
|
+
if (fs.existsSync(path.join(dir, 'patch-reach.json'))) return dir
|
|
66
|
+
}
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The `node_modules` the closure hoisted `werift` into. This script runs from
|
|
72
|
+
* `<closure>/node_modules/@camstack/server/scripts`, so the closure's own
|
|
73
|
+
* `node_modules` is three levels up — and a nested one (npm chose not to hoist)
|
|
74
|
+
* is checked first, because that is the copy this package would resolve.
|
|
75
|
+
*/
|
|
76
|
+
function moduleRootsFor(pkg) {
|
|
77
|
+
const own = path.resolve(HERE, '..', 'node_modules')
|
|
78
|
+
const closure = path.resolve(HERE, '..', '..', '..')
|
|
79
|
+
// `closure` is only a module root in the PUBLISHED layout
|
|
80
|
+
// (`<closure>/node_modules/@camstack/server/scripts` → `<closure>/node_modules`).
|
|
81
|
+
// From a workspace checkout the same walk lands on the repo root, which is
|
|
82
|
+
// not one — the basename check is what keeps this script inert in the dev
|
|
83
|
+
// tree instead of hunting for `<repo>/werift`.
|
|
84
|
+
const roots = [own, ...(path.basename(closure) === 'node_modules' ? [closure] : [])]
|
|
85
|
+
return roots.filter((root) => {
|
|
86
|
+
try {
|
|
87
|
+
return fs.statSync(path.join(root, pkg)).isDirectory()
|
|
88
|
+
} catch {
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Parse a `patch-package` unified diff into `{ file, before, after }` hunks.
|
|
96
|
+
*
|
|
97
|
+
* Line NUMBERS are ignored on purpose. A hunk is identified by its content —
|
|
98
|
+
* context plus removed lines — and applied only when that text occurs exactly
|
|
99
|
+
* once in the file. That is stricter than a positional apply (a hunk whose
|
|
100
|
+
* context appears twice refuses rather than guessing) and it survives an
|
|
101
|
+
* upstream file that shifted by a line, which a positional apply would not.
|
|
102
|
+
*/
|
|
103
|
+
function parsePatch(text, pkg) {
|
|
104
|
+
const hunks = []
|
|
105
|
+
let file = null
|
|
106
|
+
let before = null
|
|
107
|
+
let after = null
|
|
108
|
+
const flush = () => {
|
|
109
|
+
if (file !== null && before !== null && before.length > 0) {
|
|
110
|
+
hunks.push({ file, before: before.join('\n'), after: after.join('\n') })
|
|
111
|
+
}
|
|
112
|
+
before = null
|
|
113
|
+
after = null
|
|
114
|
+
}
|
|
115
|
+
const prefix = `node_modules/${pkg}/`
|
|
116
|
+
// A patch file ends with a newline, so `split` yields one trailing '' that is
|
|
117
|
+
// NOT a blank context line. Dropping it matters: the branch below treats a
|
|
118
|
+
// bare empty line as context (git can emit a blank context line with its
|
|
119
|
+
// leading space stripped), and letting the file's own terminator through
|
|
120
|
+
// would append a phantom newline requirement to the last hunk.
|
|
121
|
+
const lines = text.split('\n')
|
|
122
|
+
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
|
|
123
|
+
for (const raw of lines) {
|
|
124
|
+
if (raw.startsWith('diff --git ')) {
|
|
125
|
+
flush()
|
|
126
|
+
file = null
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
if (raw.startsWith('+++ ')) {
|
|
130
|
+
const spec = raw.slice(4).trim().replace(/^b\//, '')
|
|
131
|
+
file = spec.startsWith(prefix) ? spec.slice(prefix.length) : null
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
if (raw.startsWith('@@')) {
|
|
135
|
+
flush()
|
|
136
|
+
before = []
|
|
137
|
+
after = []
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
if (before === null || after === null) continue
|
|
141
|
+
if (raw.startsWith('+')) {
|
|
142
|
+
after.push(raw.slice(1))
|
|
143
|
+
} else if (raw.startsWith('-')) {
|
|
144
|
+
before.push(raw.slice(1))
|
|
145
|
+
} else if (raw.startsWith(' ')) {
|
|
146
|
+
before.push(raw.slice(1))
|
|
147
|
+
after.push(raw.slice(1))
|
|
148
|
+
} else if (raw === '') {
|
|
149
|
+
// A context line for a blank source line — patch-package emits it bare.
|
|
150
|
+
before.push('')
|
|
151
|
+
after.push('')
|
|
152
|
+
} else {
|
|
153
|
+
// `` and anything else: not content.
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
flush()
|
|
158
|
+
return hunks
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function countOf(haystack, needle) {
|
|
162
|
+
if (needle.length === 0) return 0
|
|
163
|
+
let n = 0
|
|
164
|
+
let at = haystack.indexOf(needle)
|
|
165
|
+
while (at !== -1) {
|
|
166
|
+
n += 1
|
|
167
|
+
at = haystack.indexOf(needle, at + needle.length)
|
|
168
|
+
}
|
|
169
|
+
return n
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Apply every hunk for one file, in memory. Returns the new text, or null. */
|
|
173
|
+
function applyHunks(original, hunks) {
|
|
174
|
+
let text = original
|
|
175
|
+
let changed = false
|
|
176
|
+
for (const hunk of hunks) {
|
|
177
|
+
const hits = countOf(text, hunk.before)
|
|
178
|
+
if (hits === 1) {
|
|
179
|
+
text = text.replace(hunk.before, hunk.after)
|
|
180
|
+
changed = true
|
|
181
|
+
continue
|
|
182
|
+
}
|
|
183
|
+
if (hits === 0 && countOf(text, hunk.after) >= 1) continue // already applied
|
|
184
|
+
return { error: `hunk matched ${hits} times (expected 1) and its result is absent` }
|
|
185
|
+
}
|
|
186
|
+
return { text, changed }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function applyPatchFile(payloadDir, entry) {
|
|
190
|
+
const patchPath = path.join(payloadDir, entry.patch)
|
|
191
|
+
let diff
|
|
192
|
+
try {
|
|
193
|
+
diff = fs.readFileSync(patchPath, 'utf-8')
|
|
194
|
+
} catch {
|
|
195
|
+
log(`SKIP ${entry.patch} — payload missing`)
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
const roots = moduleRootsFor(entry.package)
|
|
199
|
+
if (roots.length === 0) {
|
|
200
|
+
log(`SKIP ${entry.package} — not installed in this closure`)
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
const hunks = parsePatch(diff, entry.package)
|
|
204
|
+
const byFile = new Map()
|
|
205
|
+
for (const hunk of hunks) {
|
|
206
|
+
if (hunk.file === null) continue
|
|
207
|
+
const list = byFile.get(hunk.file) ?? []
|
|
208
|
+
list.push(hunk)
|
|
209
|
+
byFile.set(hunk.file, list)
|
|
210
|
+
}
|
|
211
|
+
for (const root of roots) {
|
|
212
|
+
const pkgDir = path.join(root, entry.package)
|
|
213
|
+
let installed = null
|
|
214
|
+
try {
|
|
215
|
+
installed = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf-8')).version
|
|
216
|
+
} catch {
|
|
217
|
+
/* unreadable — reported below */
|
|
218
|
+
}
|
|
219
|
+
if (installed !== entry.version) {
|
|
220
|
+
// Never patch a version the diff was not taken from. The validator turns
|
|
221
|
+
// this into a refused update rather than a silently-unpatched closure.
|
|
222
|
+
log(`REFUSE ${entry.package}@${String(installed)} in ${root} — patch is for ${entry.version}`)
|
|
223
|
+
continue
|
|
224
|
+
}
|
|
225
|
+
for (const [rel, fileHunks] of byFile) {
|
|
226
|
+
const target = path.join(pkgDir, rel)
|
|
227
|
+
let original
|
|
228
|
+
try {
|
|
229
|
+
original = fs.readFileSync(target, 'utf-8')
|
|
230
|
+
} catch {
|
|
231
|
+
log(`REFUSE ${entry.package} ${rel} — file absent`)
|
|
232
|
+
continue
|
|
233
|
+
}
|
|
234
|
+
const result = applyHunks(original, fileHunks)
|
|
235
|
+
if (result.error !== undefined) {
|
|
236
|
+
log(`REFUSE ${entry.package} ${rel} — ${result.error}`)
|
|
237
|
+
continue
|
|
238
|
+
}
|
|
239
|
+
if (!result.changed) {
|
|
240
|
+
log(`OK ${entry.package} ${rel} — already applied`)
|
|
241
|
+
continue
|
|
242
|
+
}
|
|
243
|
+
fs.writeFileSync(target, result.text)
|
|
244
|
+
log(`APPLIED ${entry.package}@${entry.version} ${rel} (${fileHunks.length} hunks)`)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function main() {
|
|
250
|
+
const payloadDir = findPayloadDir()
|
|
251
|
+
if (payloadDir === null) {
|
|
252
|
+
// The workspace dev tree: `patch-package` at the repo root owns this, and
|
|
253
|
+
// `dist/` may not be built yet. Not an error.
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
let manifest
|
|
257
|
+
try {
|
|
258
|
+
manifest = JSON.parse(fs.readFileSync(path.join(payloadDir, 'patch-reach.json'), 'utf-8'))
|
|
259
|
+
} catch (err) {
|
|
260
|
+
log(`SKIP — manifest unreadable: ${err instanceof Error ? err.message : String(err)}`)
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
const entries = Array.isArray(manifest.patches) ? manifest.patches : []
|
|
264
|
+
for (const entry of entries) {
|
|
265
|
+
if (entry?.reach !== 'closure-postinstall') continue
|
|
266
|
+
try {
|
|
267
|
+
applyPatchFile(payloadDir, entry)
|
|
268
|
+
} catch (err) {
|
|
269
|
+
log(`SKIP ${entry.patch} — ${err instanceof Error ? err.message : String(err)}`)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
main()
|