@camstack/server 1.2.274 → 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.274",
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()