Skip to content

Commit ff51210

Browse files
authored
Replace sha1 npm package with inline implementation (#1465)
Fixes #1464 The `sha1` npm package references `Buffer.isBuffer()` internally. Bundlers like webpack/Next.js see this and inject a **~28 KB Buffer polyfill** into every client page — even though the `Buffer` code path is never reached (we only hash short strings) On top of that, `sha1` only ships CommonJS, forcing CJS interop on every bundler that consumes the ESM dist - Remove `sha1` and `@types/sha1` dependencies - Add a ~90-line inline SHA-1 (FIPS 180-4) that only handles UTF-8 strings — which is all the tracker needs - Tests cover RFC 3174 vectors plus the actual domain-hash values to ensure identical output - **-28 KB raw / ~7 KB gzipped** from every page for Next.js / webpack 5 users - Eliminates the last CJS dependency in `browser-tracker-core` - No API or behavioral changes
1 parent 7ed64ad commit ff51210

8 files changed

Lines changed: 147 additions & 43 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"comment": "Replace SHA1 buffer polyfill with lightweight implementation",
5+
"type": "none",
6+
"packageName": "@snowplow/browser-tracker-core"
7+
}
8+
],
9+
"packageName": "@snowplow/browser-tracker-core"
10+
}

common/config/rush/browser-approved-packages.json

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -222,10 +222,6 @@
222222
"name": "@types/react",
223223
"allowedCategories": [ "trackers" ]
224224
},
225-
{
226-
"name": "@types/sha1",
227-
"allowedCategories": [ "libraries" ]
228-
},
229225
{
230226
"name": "@types/sinon",
231227
"allowedCategories": [ "trackers" ]
@@ -430,10 +426,6 @@
430426
"name": "saucelabs",
431427
"allowedCategories": [ "trackers" ]
432428
},
433-
{
434-
"name": "sha1",
435-
"allowedCategories": [ "libraries" ]
436-
},
437429
{
438430
"name": "sinon",
439431
"allowedCategories": [ "trackers" ]

common/config/rush/pnpm-lock.yaml

Lines changed: 0 additions & 31 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

common/config/rush/repo-state.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush.
22
{
3-
"pnpmShrinkwrapHash": "d3b2991182c4ca5879833e9c94d65eb10cb6c7f1",
3+
"pnpmShrinkwrapHash": "7bfb995166f0ecc4b03f50797de4b75379120fb5",
44
"preferredVersionsHash": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f"
55
}

libraries/browser-tracker-core/package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
},
2424
"dependencies": {
2525
"@snowplow/tracker-core": "workspace:*",
26-
"sha1": "^1.1.1",
2726
"tslib": "^2.3.1",
2827
"uuid": "^10.0.0"
2928
},
@@ -33,7 +32,6 @@
3332
"@rollup/plugin-node-resolve": "~13.1.3",
3433
"@types/jest": "~28.1.1",
3534
"@types/jsdom": "~16.2.14",
36-
"@types/sha1": "~1.1.3",
3735
"@types/uuid": "^10.0.0",
3836
"@typescript-eslint/eslint-plugin": "~5.15.0",
3937
"@typescript-eslint/parser": "~5.15.0",
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* Pure-JS SHA-1 implementation (FIPS 180-4).
3+
* Replaces the `sha1` npm package to avoid pulling in a Node.js `Buffer`
4+
* polyfill (~28 KB) when bundled for the browser.
5+
*/
6+
/** Compute SHA-1 hex digest of a UTF-8 string (FIPS 180-4). */
7+
export function sha1(message: string): string {
8+
// Encode UTF-8
9+
const bytes: number[] = [];
10+
for (let i = 0; i < message.length; i++) {
11+
let c = message.charCodeAt(i);
12+
if (c < 0x80) {
13+
bytes.push(c);
14+
} else if (c < 0x800) {
15+
bytes.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f));
16+
} else if (c >= 0xd800 && c <= 0xdbff && i + 1 < message.length) {
17+
const next = message.charCodeAt(++i);
18+
c = 0x10000 + ((c & 0x3ff) << 10) + (next & 0x3ff);
19+
bytes.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 0x3f), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
20+
} else {
21+
bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
22+
}
23+
}
24+
25+
// Pre-processing: pad to 512-bit blocks
26+
const bitLen = bytes.length * 8;
27+
bytes.push(0x80);
28+
while (bytes.length % 64 !== 56) bytes.push(0);
29+
// Append length as 64-bit big-endian (high 32 bits always 0 for strings < 512 MB)
30+
bytes.push(0, 0, 0, 0);
31+
bytes.push((bitLen >>> 24) & 0xff, (bitLen >>> 16) & 0xff, (bitLen >>> 8) & 0xff, bitLen & 0xff);
32+
33+
// Initialize hash values
34+
let h0 = 0x67452301;
35+
let h1 = 0xefcdab89;
36+
let h2 = 0x98badcfe;
37+
let h3 = 0x10325476;
38+
let h4 = 0xc3d2e1f0;
39+
40+
// Process each 512-bit block
41+
const w = new Array<number>(80);
42+
for (let offset = 0; offset < bytes.length; offset += 64) {
43+
for (let i = 0; i < 16; i++) {
44+
w[i] =
45+
(bytes[offset + i * 4] << 24) |
46+
(bytes[offset + i * 4 + 1] << 16) |
47+
(bytes[offset + i * 4 + 2] << 8) |
48+
bytes[offset + i * 4 + 3];
49+
}
50+
for (let i = 16; i < 80; i++) {
51+
const n = w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16];
52+
w[i] = (n << 1) | (n >>> 31);
53+
}
54+
55+
let a = h0,
56+
b = h1,
57+
c = h2,
58+
d = h3,
59+
e = h4;
60+
61+
for (let i = 0; i < 80; i++) {
62+
let f: number, k: number;
63+
if (i < 20) {
64+
f = (b & c) | (~b & d);
65+
k = 0x5a827999;
66+
} else if (i < 40) {
67+
f = b ^ c ^ d;
68+
k = 0x6ed9eba1;
69+
} else if (i < 60) {
70+
f = (b & c) | (b & d) | (c & d);
71+
k = 0x8f1bbcdc;
72+
} else {
73+
f = b ^ c ^ d;
74+
k = 0xca62c1d6;
75+
}
76+
const temp = (((a << 5) | (a >>> 27)) + f + e + k + w[i]) | 0;
77+
e = d;
78+
d = c;
79+
c = (b << 30) | (b >>> 2);
80+
b = a;
81+
a = temp;
82+
}
83+
84+
h0 = (h0 + a) | 0;
85+
h1 = (h1 + b) | 0;
86+
h2 = (h2 + c) | 0;
87+
h3 = (h3 + d) | 0;
88+
h4 = (h4 + e) | 0;
89+
}
90+
91+
// Produce hex digest
92+
let hex = '';
93+
for (const h of [h0, h1, h2, h3, h4]) {
94+
const part = (h >>> 0).toString(16);
95+
hex += (part.length < 8 ? '00000000'.slice(part.length) : '') + part;
96+
}
97+
return hex;
98+
}

libraries/browser-tracker-core/src/tracker/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
SelfDescribingJson,
88
LOG,
99
} from '@snowplow/tracker-core';
10-
import hash from 'sha1';
10+
import { sha1 as hash } from '../helpers/sha1';
1111
import { v4 as uuid } from 'uuid';
1212
import {
1313
decorateQuerystring,
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { sha1 } from '../../src/helpers/sha1';
2+
3+
describe('sha1', () => {
4+
// RFC 3174 test vectors
5+
test('empty string', () => {
6+
expect(sha1('')).toBe('da39a3ee5e6b4b0d3255bfef95601890afd80709');
7+
});
8+
9+
test('"abc"', () => {
10+
expect(sha1('abc')).toBe('a9993e364706816aba3e25717850c26c9cd0d89d');
11+
});
12+
13+
test('"The quick brown fox jumps over the lazy dog"', () => {
14+
expect(sha1('The quick brown fox jumps over the lazy dog')).toBe('2fd4e1c67a2d28fced849ee1bb76e7391b93eb12');
15+
});
16+
17+
test('multi-block message', () => {
18+
expect(sha1('abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq')).toBe(
19+
'84983e441c3bd26ebaae4aa1f95129e5e54670f1'
20+
);
21+
});
22+
23+
// Domain hash use case — must match output of sha1 npm package v1.1.1
24+
test('domain hash: "example.com/"', () => {
25+
expect(sha1('example.com/')).toBe('880970443b82bdca0439e34c62e6c667277c2b39');
26+
expect(sha1('example.com/').slice(0, 4)).toBe('8809');
27+
});
28+
29+
test('domain hash: "localhost/"', () => {
30+
expect(sha1('localhost/')).toBe('1fffd42e9a20211889ebfae87a84665b392c19a4');
31+
expect(sha1('localhost/').slice(0, 4)).toBe('1fff');
32+
});
33+
34+
test('returns 40-char lowercase hex string', () => {
35+
expect(sha1('anything')).toMatch(/^[0-9a-f]{40}$/);
36+
});
37+
});

0 commit comments

Comments
 (0)