Skip to content

Commit 8bd151e

Browse files
authored
fix: adjust Node.js Polyfills and add Unicode Character Sanitization (#320)
* fix: export all zlib methods from unenv polyfill * fix: add buffer polyfill with latin1Slice and utf8Slice methods * feat: add Web Crypto API exports to crypto polyfill * refactor: replace custom util polyfills with unenv implementation * feat: add base64url encoding support to buffer polyfill * feat: add Date.toString polyfill for proper Date object identification * feat: add SanitizeWorker plugin to escape non-BMP Unicode characters in worker - Added SanitizeWorker esbuild plugin to escape Unicode characters outside the Basic Multilingual Plane - Plugin processes output files and converts characters with code points >= 0x10000 to escaped format - Integrated plugin into bundler plugin system with AZ_ENABLE_SANITIZE_WORKER environment variable control (experimental plugin) - Added logging to report number of escaped characters per file
1 parent 47019ae commit 8bd151e

18 files changed

Lines changed: 226 additions & 492 deletions

File tree

packages/bundler/src/bundlers/esbuild/esbuild.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import AzionEsbuildConfig from './esbuild.config';
88
import AzionPolyfillPlugin from './plugins/azion-polyfills';
99
import OptionalChainingAssignmentPlugin from './plugins/babel-custom';
1010
import NodePolyfillPlugin from './plugins/node-polyfills';
11+
import SanitizeWorker from './plugins/sanitize-worker';
1112

1213
// eslint-disable-next-line
1314
interface ESBuildConfig extends esbuild.BuildOptions {}
@@ -23,13 +24,15 @@ interface ESBuildPluginClasses {
2324
NodePolyfillsPlugin: (isProduction: boolean) => ESBuildPlugin;
2425
AzionPolyfillsPlugin: (isProduction: boolean) => ESBuildPlugin;
2526
OptionalChainingAssignmentPlugin: () => ESBuildPlugin;
27+
SanitizeWorker: (sanitize: boolean, options?: { outfile?: string }) => ESBuildPlugin;
2628
}
2729

2830
// Create esbuild-specific plugins
2931
const bundlerPlugins = createBundlerPlugins<ESBuildPluginClasses, ESBuildConfiguration>({
3032
NodePolyfillsPlugin: NodePolyfillPlugin,
3133
AzionPolyfillsPlugin: AzionPolyfillPlugin,
3234
OptionalChainingAssignmentPlugin: OptionalChainingAssignmentPlugin,
35+
SanitizeWorker: SanitizeWorker,
3336
});
3437

3538
/**
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import SanitizeWorker from './sanitize-worker';
2+
3+
export default SanitizeWorker;
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { Plugin, PluginBuild } from 'esbuild';
2+
import fs from 'fs';
3+
4+
const SanitizeWorker = (sanitize: boolean, options?: { outfile?: string }): Plugin => {
5+
const NAME = 'sanitize-worker';
6+
7+
const sanitizeUTF8 = (content: string) => {
8+
let escapedCount = 0;
9+
const chars = Array.from(content);
10+
const result = chars
11+
.map((char) => {
12+
const codePoint = char.codePointAt(0)!;
13+
// Escape caracteres fora do Basic Multilingual Plane (BMP)
14+
if (codePoint >= 0x10000) {
15+
escapedCount++;
16+
return `\\u{${codePoint.toString(16)}}`;
17+
}
18+
return char;
19+
})
20+
.join('');
21+
return { result, escapedCount };
22+
};
23+
24+
const processFile = (filePath: string) => {
25+
const content = fs.readFileSync(filePath, 'utf-8');
26+
const { result, escapedCount } = sanitizeUTF8(content);
27+
28+
if (escapedCount > 0) {
29+
console.log(`[sanitize-worker] Escaped ${escapedCount} Unicode character(s) in ${filePath}`);
30+
fs.writeFileSync(filePath, result, 'utf-8');
31+
}
32+
};
33+
34+
return {
35+
name: NAME,
36+
setup(build: PluginBuild) {
37+
build.onEnd(async (result) => {
38+
if (!sanitize || result.errors.length > 0) {
39+
return;
40+
}
41+
if (build.initialOptions.entryPoints) {
42+
for (const filePath of Object.keys(build.initialOptions.entryPoints)) {
43+
processFile(`${filePath}.js`);
44+
}
45+
} else {
46+
const outfile = options?.outfile || build.initialOptions.outfile;
47+
if (outfile) {
48+
processFile(outfile);
49+
}
50+
}
51+
});
52+
},
53+
};
54+
};
55+
56+
export default SanitizeWorker;

packages/bundler/src/helpers/bundler-utils.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,18 @@ export const createBundlerPlugins = <
4545
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
4646
[(pluginsClasses as any).OptionalChainingAssignmentPlugin()]
4747
: []),
48+
// Add sanitize worker plugin if available (esbuild only)
49+
...('SanitizeWorker' in pluginsClasses
50+
? [
51+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
52+
(pluginsClasses as any).SanitizeWorker(process.env.AZ_ENABLE_SANITIZE_WORKER ?? false, {
53+
options: {
54+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
55+
outfile: (config as any).outfile,
56+
},
57+
}),
58+
]
59+
: []),
4860
] as unknown as typeof config.plugins;
4961
return config;
5062
};
Lines changed: 6 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as crypto from 'node:crypto';
22

3+
export default crypto;
34
export var { Cipher } = crypto;
45
export var { Decipher } = crypto;
56
export var { DiffieHellman } = crypto;
@@ -35,39 +36,8 @@ export var { getRandomValues } = crypto;
3536
export var { randomUUID } = crypto;
3637
export var { generateKeyPair } = crypto;
3738

38-
export default {
39-
Cipher,
40-
Decipher,
41-
DiffieHellman,
42-
DiffieHellmanGroup,
43-
Hash,
44-
Hmac,
45-
Sign,
46-
Verify,
47-
constants,
48-
createCipheriv,
49-
createDecipheriv,
50-
createDiffieHellman,
51-
createDiffieHellmanGroup,
52-
createECDH,
53-
createHash,
54-
createHmac,
55-
createSign,
56-
createVerify,
57-
getCiphers,
58-
getDiffieHellman,
59-
getHashes,
60-
pbkdf2,
61-
pbkdf2Sync,
62-
privateDecrypt,
63-
privateEncrypt,
64-
pseudoRandomBytes,
65-
publicDecrypt,
66-
publicEncrypt,
67-
randomBytes,
68-
randomFill,
69-
randomFillSync,
70-
getRandomValues,
71-
randomUUID,
72-
generateKeyPair,
73-
};
39+
// Export Web Crypto API from globalThis.crypto (edge runtime)
40+
export var webcrypto = (typeof globalThis !== 'undefined' && globalThis.crypto) || crypto.webcrypto;
41+
export var subtle = webcrypto?.subtle;
42+
export var CryptoKey = (typeof globalThis !== 'undefined' && globalThis.CryptoKey) || crypto.CryptoKey;
43+
export var KeyObject = crypto.KeyObject;

packages/bundler/src/polyfills/crypto/crypto.polyfills.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ export var { randomFillSync } = CRYPTO_CONTEXT.cryptoContext;
3939
export var { getRandomValues } = CRYPTO_CONTEXT.cryptoContext;
4040
export var { randomUUID } = CRYPTO_CONTEXT.cryptoContext;
4141
export var { generateKeyPair } = CRYPTO_CONTEXT.cryptoContext;
42+
export var { CryptoKey } = CRYPTO_CONTEXT.cryptoContext;
43+
export var { KeyObject } = CRYPTO_CONTEXT.cryptoContext;
44+
export var { webcrypto } = CRYPTO_CONTEXT.cryptoContext;
45+
export var { subtle } = CRYPTO_CONTEXT.cryptoContext;
4246

4347
export default {
4448
Cipher,
@@ -75,4 +79,8 @@ export default {
7579
getRandomValues,
7680
randomUUID,
7781
generateKeyPair,
82+
CryptoKey,
83+
KeyObject,
84+
subtle,
85+
webcrypto,
7886
};

packages/unenv-preset/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export default {
1717
clearInterval: `${polyfillsPath}/node/globals/clear-interval.js`,
1818
console: `${polyfillsPath}/node/globals/console.js`,
1919
asyncStorage: `${polyfillsPath}/node/globals/async-storage.js`,
20+
dateToString: `${polyfillsPath}/node/globals/date-to-string.js`,
2021
},
2122
alias: {
2223
'azion/utils': 'azion/utils',
@@ -25,6 +26,7 @@ export default {
2526
'@fastly/http-compute-js': '@fastly/http-compute-js',
2627
accepts: 'accepts',
2728
assert: 'assert-browserify',
29+
buffer: `${polyfillsPath}/node/buffer.js`,
2830
https: `${polyfillsPath}/node/https.js`,
2931
module: `${polyfillsPath}/node/module.js`,
3032
string_decoder: 'string_decoder/lib/string_decoder.js',
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import * as base64 from 'base64-js';
2+
import * as originalBuffer from 'unenv/node/buffer';
3+
4+
if (!originalBuffer.Buffer.prototype.latin1Slice) {
5+
originalBuffer.Buffer.prototype.latin1Slice = function (start, end) {
6+
return originalBuffer.Buffer.from(this).toString('latin1', start, end);
7+
};
8+
}
9+
if (!originalBuffer.Buffer.prototype.utf8Slice) {
10+
originalBuffer.Buffer.prototype.utf8Slice = function (start, end) {
11+
return originalBuffer.Buffer.from(this).toString('utf8', start, end);
12+
};
13+
}
14+
15+
// Store original methods
16+
const originalToString = originalBuffer.Buffer.prototype.toString;
17+
const originalWrite = originalBuffer.Buffer.prototype.write;
18+
const originalIsEncoding = originalBuffer.Buffer.prototype.isEncoding;
19+
20+
// Helper functions for base64url conversion
21+
const INVALID_BASE64_RE = /[^+/0-9A-Za-z-_]/g;
22+
const BASE64_CHAR_62 = '+';
23+
const BASE64_CHAR_63 = '/';
24+
const BASE64URL_CHAR_62 = '-';
25+
const BASE64URL_CHAR_63 = '_';
26+
27+
function base64urlFromBase64(str) {
28+
return str.replaceAll(BASE64_CHAR_62, BASE64URL_CHAR_62).replaceAll(BASE64_CHAR_63, BASE64URL_CHAR_63);
29+
}
30+
31+
function base64urlToBase64(str) {
32+
return str.replaceAll(BASE64URL_CHAR_62, BASE64_CHAR_62).replaceAll(BASE64URL_CHAR_63, BASE64_CHAR_63);
33+
}
34+
35+
// Override toString to support base64url encoding
36+
originalBuffer.Buffer.prototype.toString = function toString(encoding, start, end) {
37+
if (encoding === 'base64url') {
38+
return base64Slice(this, start, end, encoding);
39+
}
40+
if (encoding === 'base64') {
41+
return base64Slice(this, start, end, encoding);
42+
}
43+
return originalToString.call(this, encoding, start, end);
44+
};
45+
46+
function blitBuffer(src, dst, offset, length) {
47+
let i;
48+
for (i = 0; i < length; ++i) {
49+
if (i + offset >= dst.length || i >= src.length) break;
50+
dst[i + offset] = src[i];
51+
}
52+
return i;
53+
}
54+
55+
function base64clean(str) {
56+
// Node takes equal signs as end of the Base64 encoding
57+
str = str.split('=')[0];
58+
// Node strips out invalid characters like \n and \t from the string, base64-js does not
59+
str = str.trim().replace(INVALID_BASE64_RE, '');
60+
// Node converts strings with length < 2 to ''
61+
if (str.length < 2) return '';
62+
// Node allows for non-padded base64 strings (missing trailing ===), base64-js does not
63+
while (str.length % 4 !== 0) {
64+
str = str + '=';
65+
}
66+
return str;
67+
}
68+
69+
function base64ToBytes(str) {
70+
return base64.toByteArray(base64clean(str));
71+
}
72+
73+
function base64Write(buf, string, offset, length, encoding) {
74+
const b64 = encoding === 'base64url' ? base64urlToBase64(string) : string;
75+
return blitBuffer(base64ToBytes(b64), buf, offset, length);
76+
}
77+
78+
function base64Slice(buf, start, end, encoding) {
79+
let b64;
80+
if (start === 0 && end === buf.length) {
81+
b64 = base64.fromByteArray(buf);
82+
} else {
83+
b64 = base64.fromByteArray(buf.slice(start, end));
84+
}
85+
return encoding === 'base64url' ? base64urlFromBase64(b64) : b64;
86+
}
87+
88+
originalBuffer.Buffer.prototype.write = function write(string, offset, length, encoding) {
89+
if (encoding === 'base64') {
90+
return base64Write(this, string, offset, length, encoding);
91+
}
92+
return originalWrite.call(this, string, offset, length, encoding);
93+
};
94+
95+
originalBuffer.Buffer.prototype.isEncoding = function isEncoding(encoding) {
96+
if (String(encoding).toLowerCase() === 'base64url') {
97+
return true;
98+
}
99+
return originalIsEncoding.call(this, encoding);
100+
};
101+
102+
export * from 'unenv/node/buffer';
103+
104+
export const Buffer = originalBuffer.Buffer;
105+
106+
export default {
107+
...originalBuffer.default,
108+
Buffer,
109+
};
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// TODO: fix temp, move to runtime
2+
const originalToString = Object.prototype.toString;
3+
4+
// Override Object.prototype.toString to properly identify Date objects
5+
Object.prototype.toString = function () {
6+
// Check if this is a Date instance
7+
if (this instanceof Date || (this && typeof this.getTime === 'function' && typeof this.toISOString === 'function')) {
8+
return '[object Date]';
9+
}
10+
// Fall back to original toString for other objects
11+
return originalToString.call(this);
12+
};
13+
14+
export default Object.prototype.toString;

packages/unenv-preset/src/polyfills/node/internal/_internal.js

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

0 commit comments

Comments
 (0)