Skip to content

Commit ab10d17

Browse files
committed
feat(analyze): Replace wasm-bindgen with jco generated bindings
1 parent 58b6d2e commit ab10d17

21 files changed

+1036
-821
lines changed

analyze/.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,4 @@ dist
133133
index.js
134134
index.d.ts
135135
test/*.js
136+
_virtual/*.js

analyze/index.ts

+131-127
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,111 @@
1-
import initWasm, {
2-
detect_bot,
3-
generate_fingerprint,
4-
is_valid_email,
5-
type EmailValidationConfig,
6-
} from "./wasm/arcjet_analyze_js_req.js";
7-
8-
export { type EmailValidationConfig };
9-
10-
type WasmAPI = {
11-
/**
12-
* The WASM detect_bot function. Initialized by calling `init()`. Defined at a
13-
* class level to avoid having to load the WASM module multiple times.
14-
*/
15-
detectBot: typeof detect_bot;
16-
/**
17-
* The WASM fingerprint function. Initialized by calling `init()`. Defined at
18-
* a class level to avoid having to load the WASM module multiple times.
19-
*/
20-
fingerprint: typeof generate_fingerprint;
21-
/**
22-
* The WASM email validation function. Initialized by calling `init()`. Defined at
23-
* a class level to avoid having to load the WASM module multiple times.
24-
*/
25-
isValidEmail: typeof is_valid_email;
26-
};
27-
28-
type WasmState = "initialized" | "uninitialized" | "unsupported" | "errored";
29-
30-
let state: WasmState = "uninitialized";
31-
32-
/**
33-
* Initialize the WASM module. This can be explicitly called after creating
34-
* the client, but it will be called automatically if it has not been called
35-
* when the first request is made. This uses a factory-style pattern because
36-
* the call must be async and the constructor cannot be async.
37-
*/
38-
async function init(): Promise<WasmAPI | undefined> {
39-
if (state === "errored" || state === "unsupported") return;
40-
41-
if (typeof WebAssembly === "undefined") {
42-
state = "unsupported";
43-
return;
1+
import logger from "@arcjet/logger";
2+
3+
import * as core from "./wasm/arcjet_analyze_js_req.component.js";
4+
import type {
5+
ImportObject,
6+
EmailValidationConfig,
7+
BotDetectionResult,
8+
BotType,
9+
} from "./wasm/arcjet_analyze_js_req.component.js";
10+
11+
// TODO: Do we actually need this wasmCache or does `import` cache correctly?
12+
const wasmCache = new Map<string, WebAssembly.Module>();
13+
14+
async function moduleFromPath(path: string): Promise<WebAssembly.Module> {
15+
const cachedModule = wasmCache.get(path);
16+
if (typeof cachedModule !== "undefined") {
17+
return cachedModule;
4418
}
4519

46-
if (state === "uninitialized") {
47-
try {
48-
let wasmModule: WebAssembly.Module;
49-
// We use `NEXT_RUNTIME` env var to DCE the Node/Browser code in the `else` block
50-
// possible values: "edge" | "nodejs" | undefined
51-
if (process.env["NEXT_RUNTIME"] === "edge") {
52-
const mod = await import(
53-
// @ts-expect-error
54-
"./wasm/arcjet_analyze_js_req_bg.wasm?module"
55-
);
56-
wasmModule = mod.default;
57-
} else {
58-
const { wasm } = await import("./wasm/arcjet.wasm.js");
59-
wasmModule = await WebAssembly.compile(await wasm());
60-
}
61-
62-
await initWasm(wasmModule);
63-
state = "initialized";
64-
} catch (err) {
65-
state = "errored";
66-
return;
20+
if (process.env["NEXT_RUNTIME"] === "edge") {
21+
if (path === "arcjet_analyze_js_req.component.core.wasm") {
22+
const mod = await import(
23+
"./wasm/arcjet_analyze_js_req.component.core.wasm?module"
24+
);
25+
wasmCache.set(path, mod.default);
26+
return mod.default;
27+
}
28+
if (path === "arcjet_analyze_js_req.component.core2.wasm") {
29+
const mod = await import(
30+
"./wasm/arcjet_analyze_js_req.component.core2.wasm?module"
31+
);
32+
wasmCache.set(path, mod.default);
33+
return mod.default;
34+
}
35+
if (path === "arcjet_analyze_js_req.component.core3.wasm") {
36+
const mod = await import(
37+
"./wasm/arcjet_analyze_js_req.component.core3.wasm?module"
38+
);
39+
wasmCache.set(path, mod.default);
40+
return mod.default;
41+
}
42+
} else {
43+
if (path === "arcjet_analyze_js_req.component.core.wasm") {
44+
const { wasm } = await import(
45+
"./wasm/arcjet_analyze_js_req.component.core.wasm"
46+
);
47+
const mod = await wasm();
48+
wasmCache.set(path, mod);
49+
return mod;
50+
}
51+
if (path === "arcjet_analyze_js_req.component.core2.wasm") {
52+
const { wasm } = await import(
53+
"./wasm/arcjet_analyze_js_req.component.core2.wasm"
54+
);
55+
const mod = await wasm();
56+
wasmCache.set(path, mod);
57+
return mod;
58+
}
59+
if (path === "arcjet_analyze_js_req.component.core3.wasm") {
60+
const { wasm } = await import(
61+
"./wasm/arcjet_analyze_js_req.component.core3.wasm"
62+
);
63+
const mod = await wasm();
64+
wasmCache.set(path, mod);
65+
return mod;
6766
}
6867
}
6968

70-
return {
71-
detectBot: detect_bot,
72-
fingerprint: generate_fingerprint,
73-
isValidEmail: is_valid_email,
74-
};
69+
throw new Error(`Unknown path: ${path}`);
7570
}
7671

72+
const coreImports: ImportObject = {
73+
"arcjet:js-req/logger": {
74+
debug(msg) {
75+
logger.debug(msg);
76+
},
77+
error(msg) {
78+
logger.error(msg);
79+
},
80+
},
81+
};
82+
83+
async function init() {
84+
try {
85+
return core.instantiate(moduleFromPath, coreImports);
86+
} catch {
87+
logger.debug("WebAssembly is not supported in this runtime");
88+
}
89+
}
90+
91+
export {
92+
type EmailValidationConfig,
93+
type BotType,
94+
/**
95+
* Represents the result of the bot detection.
96+
*
97+
* @property `botType` - What type of bot this is. This will be one of `BotType`.
98+
* @property `botScore` - A score ranging from 0 to 99 representing the degree of
99+
* certainty. The higher the number within the type category, the greater the
100+
* degree of certainty. E.g. `BotType.Automated` with a score of 1 means we are
101+
* sure the request was made by an automated bot. `BotType.LikelyNotABot` with a
102+
* score of 30 means we don't think this request was a bot, but it's lowest
103+
* confidence level. `BotType.LikelyNotABot` with a score of 99 means we are
104+
* almost certain this request was not a bot.
105+
*/
106+
type BotDetectionResult,
107+
};
108+
77109
/**
78110
* Generate a fingerprint for the client. This is used to identify the client
79111
* across multiple requests.
@@ -85,48 +117,37 @@ export async function generateFingerprint(ip: string): Promise<string> {
85117
return "";
86118
}
87119

88-
// We use `NEXT_RUNTIME` env var to DCE the JS fallback code in the `else` block
89-
// possible values: "edge" | "nodejs" | undefined
90-
if (process.env["NEXT_RUNTIME"] === "edge") {
91-
const analyze = await init();
92-
// We HAVE to have the WasmAPI in Edge
93-
const fingerprint = analyze!.fingerprint(ip);
94-
return fingerprint;
95-
} else {
96-
const analyze = await init();
97-
if (typeof analyze !== "undefined") {
98-
const fingerprint = analyze.fingerprint(ip);
99-
return fingerprint;
100-
}
101-
102-
if (hasSubtleCryptoDigest()) {
103-
// Fingerprint v1 is just the IP address
104-
const fingerprintRaw = `fp_1_${ip}`;
105-
106-
// Based on MDN example at
107-
// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest#converting_a_digest_to_a_hex_string
120+
const analyze = await init();
108121

109-
// Encode the raw fingerprint into a utf-8 Uint8Array
110-
const fingerprintUint8 = new TextEncoder().encode(fingerprintRaw);
111-
// Hash the message with SHA-256
112-
const fingerprintArrayBuffer = await crypto.subtle.digest(
113-
"SHA-256",
114-
fingerprintUint8,
115-
);
116-
// Convert the ArrayBuffer to a byte array
117-
const fingerprintArray = Array.from(
118-
new Uint8Array(fingerprintArrayBuffer),
119-
);
120-
// Convert the bytes to a hex string
121-
const fingerprint = fingerprintArray
122-
.map((b) => b.toString(16).padStart(2, "0"))
123-
.join("");
122+
if (typeof analyze !== "undefined") {
123+
return analyze.generateFingerprint(ip);
124+
}
124125

125-
return fingerprint;
126-
}
126+
if (hasSubtleCryptoDigest()) {
127+
// Fingerprint v1 is just the IP address
128+
const fingerprintRaw = `fp_1_${ip}`;
129+
130+
// Based on MDN example at
131+
// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest#converting_a_digest_to_a_hex_string
132+
133+
// Encode the raw fingerprint into a utf-8 Uint8Array
134+
const fingerprintUint8 = new TextEncoder().encode(fingerprintRaw);
135+
// Hash the message with SHA-256
136+
const fingerprintArrayBuffer = await crypto.subtle.digest(
137+
"SHA-256",
138+
fingerprintUint8,
139+
);
140+
// Convert the ArrayBuffer to a byte array
141+
const fingerprintArray = Array.from(new Uint8Array(fingerprintArrayBuffer));
142+
// Convert the bytes to a hex string
143+
const fingerprint = fingerprintArray
144+
.map((b) => b.toString(16).padStart(2, "0"))
145+
.join("");
127146

128-
return "";
147+
return fingerprint;
129148
}
149+
150+
return "";
130151
}
131152

132153
export async function isValidEmail(
@@ -143,37 +164,20 @@ export async function isValidEmail(
143164
}
144165
}
145166

146-
/**
147-
* Represents the result of the bot detection.
148-
*
149-
* @property `bot_type` - What type of bot this is. This will be one of `BotType`.
150-
* @property `bot_score` - A score ranging from 0 to 99 representing the degree of
151-
* certainty. The higher the number within the type category, the greater the
152-
* degree of certainty. E.g. `BotType.Automated` with a score of 1 means we are
153-
* sure the request was made by an automated bot. `BotType.LikelyNotABot` with a
154-
* score of 30 means we don't think this request was a bot, but it's lowest
155-
* confidence level. `BotType.LikelyNotABot` with a score of 99 means we are
156-
* almost certain this request was not a bot.
157-
*/
158-
export interface BotResult {
159-
bot_type: number;
160-
bot_score: number;
161-
}
162-
163167
export async function detectBot(
164168
headers: string,
165169
patterns_add: string,
166170
patterns_remove: string,
167-
): Promise<BotResult> {
171+
): Promise<BotDetectionResult> {
168172
const analyze = await init();
169173

170174
if (typeof analyze !== "undefined") {
171175
return analyze.detectBot(headers, patterns_add, patterns_remove);
172176
} else {
173177
// TODO: Fallback to JS if we don't have WASM?
174178
return {
175-
bot_type: 1, // NOT_ANALYZED
176-
bot_score: 0,
179+
botType: "not-analyzed",
180+
botScore: 0,
177181
};
178182
}
179183
}

analyze/package.json

+4-1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"files": [
2828
"LICENSE",
2929
"README.md",
30+
"_virtual/",
3031
"wasm/",
3132
"*.js",
3233
"*.d.ts",
@@ -35,7 +36,8 @@
3536
],
3637
"scripts": {
3738
"prepublishOnly": "npm run build",
38-
"build": "rollup --config rollup.config.js",
39+
"jco": "jco transpile wasm/arcjet_analyze_js_req.component.wasm --no-wasi-shim --instantiation async -o wasm",
40+
"build": "npm run jco; rollup --config rollup.config.js",
3941
"lint": "eslint .",
4042
"pretest": "npm run build",
4143
"test": "NODE_OPTIONS=--experimental-vm-modules jest --passWithNoTests"
@@ -50,6 +52,7 @@
5052
"@arcjet/eslint-config": "1.0.0-alpha.9",
5153
"@arcjet/rollup-config": "1.0.0-alpha.9",
5254
"@arcjet/tsconfig": "1.0.0-alpha.9",
55+
"@bytecodealliance/jco": "1.0.2",
5356
"@jest/globals": "29.7.0",
5457
"@rollup/wasm-node": "4.12.1",
5558
"@types/node": "18.18.0",

0 commit comments

Comments
 (0)