Skip to content

Commit 08909c1

Browse files
authored
Stamp built HTML with a build fingerprint in mark-build (#30)
mark-build now computes the manifest checksum from the lockfile and injects it (window.__PATCHSTACK_BUILD__) beside the production flag, so the disclosure widget can report the running build for manifest-parity checks. The fingerprint matches the server's byte-for-byte. Replaces the flag-only injector.
1 parent 5eb54b7 commit 08909c1

6 files changed

Lines changed: 300 additions & 140 deletions

File tree

src/buildFlag.ts

Lines changed: 0 additions & 123 deletions
This file was deleted.

src/checksum.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { createHash } from 'node:crypto';
2+
3+
import type { WirePackage } from './normalize.js';
4+
5+
/**
6+
* Fingerprint of a manifest's package set, byte-for-byte identical to the
7+
* checksum Patchstack stores server-side (PulseController::storeManifest): the
8+
* first 12 hex chars of sha256 over the JSON of the packages sorted by
9+
* `[name, version]` with plain lexicographic (byte) ordering.
10+
*
11+
* This mirrors PHP's `usort(..., fn($a,$b) => [$a['name'],$a['version']] <=> ...)`
12+
* followed by `json_encode(..., JSON_UNESCAPED_SLASHES)`. Two deliberate details:
13+
*
14+
* - The sort is lexicographic, NOT the semver-aware ordering `buildWirePayload`
15+
* uses for display. The server re-sorts lexicographically before hashing, so
16+
* the fingerprint must too (e.g. `1.10.0` sorts before `1.9.0`).
17+
* - npm/composer package names and versions are ASCII, so JS `JSON.stringify`
18+
* and PHP `json_encode` produce identical bytes (no unicode escaping, and
19+
* neither escapes `/`, so scoped names like `@babel/core` match).
20+
*
21+
* Injected into built HTML by `mark-build` and reported by the disclosure widget
22+
* so Patchstack can compare the live build against the last reported manifest.
23+
*/
24+
export function computeManifestChecksum(packages: WirePackage[]): string {
25+
const canonical = packages
26+
.map((pkg) => ({ name: pkg.name, version: pkg.version }))
27+
.sort((a, b) => {
28+
if (a.name !== b.name) {
29+
return a.name < b.name ? -1 : 1;
30+
}
31+
if (a.version !== b.version) {
32+
return a.version < b.version ? -1 : 1;
33+
}
34+
return 0;
35+
});
36+
37+
return createHash('sha256').update(JSON.stringify(canonical)).digest('hex').slice(0, 12);
38+
}

src/cli.ts

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1+
import { readFileSync, writeFileSync } from 'node:fs';
2+
13
import { scanLockfile } from './parsers/index.js';
24
import { buildWirePayload } from './normalize.js';
3-
import { markProductionBuild } from './buildFlag.js';
5+
import { computeManifestChecksum } from './checksum.js';
46
import { buildClaimUrl, postManifest } from './client.js';
57
import { persistSiteUuid, resolveConfig, writeConfigFile } from './config.js';
8+
import {
9+
buildInjectionSnippet,
10+
findHtmlFiles,
11+
injectMarker,
12+
resolveBuildDir,
13+
} from './mark-build.js';
614
import { PatchstackError } from './types.js';
715

816
const HELP = `@patchstack/connect — scan your lockfile and report packages to Patchstack.
@@ -14,10 +22,8 @@ Usage:
1422
patchstack-connect init <site-uuid> Optional: pre-seed .patchstackrc.json
1523
with an existing site UUID
1624
patchstack-connect status [options] Show current configuration
17-
patchstack-connect mark-build [--dir <path>] Inject the production flag into
18-
the built HTML (run as a postbuild
19-
step). Tells the widget it's live so
20-
it hides the claim flow.
25+
patchstack-connect mark-build [options] Stamp built HTML with a production flag +
26+
build fingerprint (run as a postbuild step)
2127
patchstack-connect help Print this message
2228
2329
Options (for scan and status):
@@ -26,8 +32,8 @@ Options (for scan and status):
2632
--dry-run (scan only) Show the payload without posting
2733
2834
Options (for mark-build):
29-
--dir <path> Build output dir (default: auto-detect dist/, build/,
30-
out/, .output/public)
35+
--dir <path> Build output directory (default: auto-detect
36+
dist/ build/ out/ .output/public)
3137
3238
Environment:
3339
PATCHSTACK_SITE_UUID Site UUID
@@ -197,25 +203,49 @@ async function runStatus(args: ParsedArgs): Promise<number> {
197203
}
198204

199205
async function runMarkBuild(args: ParsedArgs): Promise<number> {
200-
const result = await markProductionBuild(process.cwd(), getStringFlag(args.flags, 'dir'));
206+
const cwd = process.cwd();
207+
208+
// Compute the build fingerprint from the lockfile. Best-effort: mark-build is a
209+
// postbuild step and must never fail the build, so a missing/unreadable lockfile
210+
// just means we stamp the production flag without a fingerprint.
211+
let checksum: string | null = null;
212+
try {
213+
const manifest = await scanLockfile(cwd);
214+
const { payload } = buildWirePayload(manifest);
215+
checksum = computeManifestChecksum(payload.packages);
216+
} catch (err) {
217+
console.warn(
218+
`mark-build: could not compute the build fingerprint (${(err as Error).message}). Stamping the production flag only.`,
219+
);
220+
}
201221

202-
// Never fail the build over this — a missing flag just means the widget falls
203-
// back to showing the claim flow, which is safe.
204-
if (result.dir === null) {
222+
const dir = resolveBuildDir(cwd, getStringFlag(args.flags, 'dir'));
223+
if (dir === null) {
205224
console.warn(
206-
'patchstack: no build output found (looked for dist/, build/, out/, .output/public). ' +
207-
'Pass --dir <path> if your build outputs elsewhere. Skipping production flag.',
225+
'mark-build: no build output directory found (looked for dist/, build/, out/, .output/public). Pass --dir <path> if it is elsewhere. Nothing to mark.',
208226
);
209227
return 0;
210228
}
211-
if (result.patched.length === 0 && result.skipped.length === 0) {
212-
console.warn(`patchstack: no HTML files found under ${result.dir}; skipping production flag.`);
229+
230+
const files = findHtmlFiles(dir);
231+
if (files.length === 0) {
232+
console.warn(`mark-build: no HTML files found under ${dir}. Nothing to mark.`);
213233
return 0;
214234
}
215235

216-
const already = result.skipped.length > 0 ? ` (${result.skipped.length} already marked)` : '';
236+
const snippet = buildInjectionSnippet(checksum);
237+
let marked = 0;
238+
for (const file of files) {
239+
const before = readFileSync(file, 'utf8');
240+
const after = injectMarker(before, snippet);
241+
if (after !== before) {
242+
writeFileSync(file, after);
243+
marked += 1;
244+
}
245+
}
246+
217247
console.log(
218-
`patchstack: marked ${result.patched.length} HTML file(s) as a production build in ${result.dir}${already}.`,
248+
`mark-build: marked ${marked} HTML file(s) in ${dir}${checksum !== null ? ` (build ${checksum})` : ''}.`,
219249
);
220250
return 0;
221251
}

src/mark-build.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { existsSync, readdirSync, statSync } from 'node:fs';
2+
import path from 'node:path';
3+
4+
/** Attribute that tags our injected <script> so re-runs replace it instead of stacking. */
5+
export const MARKER_ATTR = 'data-patchstack-build';
6+
7+
/** Build output directories we look for, in priority order (Vite, CRA, Next export, Nuxt). */
8+
export const BUILD_DIR_CANDIDATES = ['dist', 'build', 'out', '.output/public'];
9+
10+
/**
11+
* Resolve the directory holding the built HTML. Honours an explicit `--dir`
12+
* override, otherwise picks the first known build directory that exists.
13+
* Returns null when nothing is found (mark-build then no-ops without failing).
14+
*/
15+
export function resolveBuildDir(cwd: string, override?: string): string | null {
16+
if (override !== undefined && override !== '') {
17+
const abs = path.resolve(cwd, override);
18+
return existsSync(abs) && statSync(abs).isDirectory() ? abs : null;
19+
}
20+
21+
for (const candidate of BUILD_DIR_CANDIDATES) {
22+
const abs = path.resolve(cwd, candidate);
23+
if (existsSync(abs) && statSync(abs).isDirectory()) {
24+
return abs;
25+
}
26+
}
27+
28+
return null;
29+
}
30+
31+
/** Recursively collect every `.html` file under `dir`. */
32+
export function findHtmlFiles(dir: string): string[] {
33+
const out: string[] = [];
34+
35+
const walk = (current: string): void => {
36+
for (const entry of readdirSync(current, { withFileTypes: true })) {
37+
const full = path.join(current, entry.name);
38+
if (entry.isDirectory()) {
39+
walk(full);
40+
} else if (entry.isFile() && entry.name.toLowerCase().endsWith('.html')) {
41+
out.push(full);
42+
}
43+
}
44+
};
45+
46+
walk(dir);
47+
return out;
48+
}
49+
50+
/**
51+
* The <script> we inject into built HTML. Always marks the build as production
52+
* (so the widget hides the connect/claim prompt on the published site) and, when
53+
* a fingerprint is available, exposes it for the widget's parity heartbeat.
54+
*/
55+
export function buildInjectionSnippet(checksum: string | null): string {
56+
const statements = ['window.__PATCHSTACK_PROD__=true;'];
57+
if (checksum !== null && checksum !== '') {
58+
statements.push(`window.__PATCHSTACK_BUILD__=${JSON.stringify(checksum)};`);
59+
}
60+
return `<script ${MARKER_ATTR}>${statements.join('')}</script>`;
61+
}
62+
63+
/**
64+
* Insert (or replace) the marker script in a single HTML document. Idempotent:
65+
* a prior marker is stripped first so repeated builds don't stack tags. Prefers
66+
* `</head>`, falls back to `</body>`, then appends.
67+
*/
68+
export function injectMarker(html: string, snippet: string): string {
69+
const stripped = html.replace(
70+
new RegExp(`\\s*<script ${MARKER_ATTR}[^>]*>[\\s\\S]*?</script>`, 'gi'),
71+
'',
72+
);
73+
74+
if (/<\/head>/i.test(stripped)) {
75+
return stripped.replace(/<\/head>/i, `${snippet}</head>`);
76+
}
77+
if (/<\/body>/i.test(stripped)) {
78+
return stripped.replace(/<\/body>/i, `${snippet}</body>`);
79+
}
80+
return stripped + snippet;
81+
}

tests/checksum.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { computeManifestChecksum } from '../src/checksum.js';
3+
4+
// The expected values below are cross-checked byte-for-byte against the server
5+
// algorithm in PulseController::storeManifest:
6+
// substr(hash('sha256', json_encode($sortedPackages, JSON_UNESCAPED_SLASHES)), 0, 12)
7+
describe('computeManifestChecksum', () => {
8+
it('matches the server checksum for a single package', () => {
9+
expect(computeManifestChecksum([{ name: 'lodash', version: '4.17.21' }])).toBe('705680d827bc');
10+
});
11+
12+
it('matches the server for scoped names and multiple packages', () => {
13+
expect(
14+
computeManifestChecksum([
15+
{ name: '@babel/core', version: '7.0.0' },
16+
{ name: 'axios', version: '1.6.0' },
17+
]),
18+
).toBe('a828c5c61d95');
19+
});
20+
21+
it('sorts versions lexicographically like the server (1.10.0 before 1.9.0), not semver', () => {
22+
const ascending = computeManifestChecksum([
23+
{ name: 'foo', version: '1.9.0' },
24+
{ name: 'foo', version: '1.10.0' },
25+
]);
26+
const descending = computeManifestChecksum([
27+
{ name: 'foo', version: '1.10.0' },
28+
{ name: 'foo', version: '1.9.0' },
29+
]);
30+
expect(ascending).toBe('ba8275c7b0ab');
31+
expect(descending).toBe('ba8275c7b0ab');
32+
});
33+
34+
it('is stable regardless of input order', () => {
35+
expect(
36+
computeManifestChecksum([
37+
{ name: 'axios', version: '1.6.0' },
38+
{ name: '@babel/core', version: '7.0.0' },
39+
]),
40+
).toBe('a828c5c61d95');
41+
});
42+
});

0 commit comments

Comments
 (0)