pompelmi 1.16.0 → 1.17.0

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.
package/README.md CHANGED
@@ -89,12 +89,15 @@ Most integrations require parsing ClamAV's stdout with regex, managing a clamd d
89
89
  - Symbol-based verdicts (`Verdict.Clean` / `Verdict.Malicious` / `Verdict.ScanError`) — typo-proof comparisons
90
90
  - Full clamd support via the INSTREAM protocol — TCP (`host`/`port`) or UNIX socket (`socket`) with configurable timeout
91
91
  - Built-in helpers to install ClamAV and update virus definitions programmatically
92
+ - **Native ESM support** — `import { scan } from 'pompelmi'` works out of the box (dual CJS/ESM build)
93
+ - **Deno support** — `import { scan } from 'npm:pompelmi'` — no install step required
94
+ - **Cloudflare Workers** — via `@pompelmi/cloudflare` — Web APIs only, no Node.js built-ins
92
95
  - Works with Express, Fastify, NestJS, Hono, Remix, SvelteKit, and any other Node.js HTTP framework
93
- - Works with Node.js and Bun uses `Bun.file()` for faster file reading when available
96
+ - Works with **Node.js Bun Deno Cloudflare Workers**
94
97
  - Interactive demo at [pompelmi.app/demo](https://pompelmi.app/demo.html) — try before you install
95
98
  - Zero runtime dependencies — ships nothing but source code
96
99
  - Tested with EICAR standard antivirus test files
97
- - CommonJS module; TypeScript type declarations available inline
100
+ - CommonJS + ESM module; TypeScript type declarations available inline
98
101
 
99
102
  See [how pompelmi compares](./docs/comparison.html) to other Node.js ClamAV integrations.
100
103
 
@@ -112,6 +115,7 @@ Official integration packages for popular frameworks:
112
115
  | [@pompelmi/remix](./packages/remix/) | Remix | `npm i @pompelmi/remix` |
113
116
  | [@pompelmi/sveltekit](./packages/sveltekit/) | SvelteKit | `npm i @pompelmi/sveltekit` |
114
117
  | [@pompelmi/testing](./packages/testing/) | Jest/Vitest/Node | `npm i -D @pompelmi/testing` |
118
+ | [@pompelmi/cloudflare](./packages/cloudflare/) | Cloudflare Workers | `npm i @pompelmi/cloudflare` |
115
119
 
116
120
  ### NestJS
117
121
 
@@ -197,6 +201,8 @@ export const actions: Actions = {
197
201
 
198
202
  - **Node.js** — any LTS release (no native addons, no C++ bindings)
199
203
  - **Bun** — fully supported; uses `Bun.file()` for faster file reading
204
+ - **Deno** — import from `npm:pompelmi` — no install step required
205
+ - **Cloudflare Workers** — via `@pompelmi/cloudflare` — connects to a remote clamd over TCP
200
206
  - **ClamAV** — must be installed on the host or reachable over TCP
201
207
 
202
208
  pompelmi does not bundle or automatically download ClamAV. Install it once per machine (see [Installing ClamAV](#installing-clamav)).
@@ -608,8 +614,8 @@ Please read [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) before contributing. To r
608
614
 
609
615
  ## Coming soon
610
616
 
611
- - [ ] Cloudflare Workers support — edge-native scanning via the clamd TCP protocol
612
- - [ ] NestJS official module — `PompelmiModule.forRoot()` with injectable `PompelmiService`
617
+ - [x] Cloudflare Workers support — `@pompelmi/cloudflare` ships in v1.17.0
618
+ - [x] NestJS official module — `PompelmiModule.forRoot()` with injectable `PompelmiService`
613
619
 
614
620
  ---
615
621
 
package/deno.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "@pompelmi/pompelmi",
3
+ "version": "1.17.0",
4
+ "exports": "./src/index.js"
5
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pompelmi",
3
- "version": "1.16.0",
3
+ "version": "1.17.0",
4
4
  "description": "ClamAV for humans — scan any file and get back Clean, Malicious, or ScanError. No daemons. No cloud. No native bindings.",
5
5
  "license": "ISC",
6
6
  "author": "pompelmi contributors",
@@ -35,12 +35,19 @@
35
35
  ],
36
36
  "type": "commonjs",
37
37
  "main": "./src/index.js",
38
+ "module": "./src/index.mjs",
38
39
  "types": "./types/index.d.ts",
40
+ "exports": {
41
+ ".": {
42
+ "import": "./src/index.mjs",
43
+ "require": "./src/index.js"
44
+ }
45
+ },
39
46
  "bin": {
40
47
  "pompelmi": "./bin/pompelmi.js"
41
48
  },
42
49
  "scripts": {
43
- "test": "node --test test/unit.test.js && node --test packages/nestjs/test/index.test.js && node --test packages/fastify/test/index.test.js && node --test packages/nextjs/test/index.test.js && node --test packages/hono/test/index.test.js && node --test packages/testing/test/index.test.js && node --test packages/remix/test/index.test.js && node --test packages/sveltekit/test/index.test.js && node test/scan.test.js",
50
+ "test": "node --test test/unit.test.js && node --test test/esm.test.mjs && node --test packages/nestjs/test/index.test.js && node --test packages/fastify/test/index.test.js && node --test packages/nextjs/test/index.test.js && node --test packages/hono/test/index.test.js && node --test packages/testing/test/index.test.js && node --test packages/remix/test/index.test.js && node --test packages/sveltekit/test/index.test.js && node --test packages/cloudflare/test/index.test.mjs && node test/scan.test.js",
44
51
  "lint": "eslint src/"
45
52
  },
46
53
  "publishConfig": {
@@ -0,0 +1,94 @@
1
+ # @pompelmi/cloudflare
2
+
3
+ Scan file uploads for malware inside **Cloudflare Workers** using a remote [ClamAV](https://www.clamav.net/) (clamd) instance.
4
+
5
+ Uses Web APIs only (`fetch`, `connect` from `cloudflare:sockets`) — no Node.js built-ins, fully compatible with the Workers Runtime.
6
+
7
+ ## Requirements
8
+
9
+ Cloudflare Workers cannot run clamd locally. You need a **publicly reachable** clamd instance. Options:
10
+
11
+ - A VPS running clamd with port 3310 open (add appropriate firewall rules).
12
+ - A Cloudflare Tunnel ([cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/)) pointing to a private clamd instance.
13
+
14
+ > **Security note:** Restrict clamd access to your Worker's outbound IPs or use a shared secret at the application layer. Never expose clamd directly to the public internet without access controls.
15
+
16
+ ## Installation
17
+
18
+ ```
19
+ npm i @pompelmi/cloudflare
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ```js
25
+ import { scanBuffer, scanRequest } from '@pompelmi/cloudflare';
26
+
27
+ export default {
28
+ async fetch(request, env) {
29
+ // Option A: scan the whole multipart form at once
30
+ const rejection = await scanRequest(request, {
31
+ host: env.CLAMAV_HOST,
32
+ port: parseInt(env.CLAMAV_PORT),
33
+ });
34
+ if (rejection) return rejection; // 422 or 500
35
+
36
+ return new Response('File accepted');
37
+ },
38
+ };
39
+ ```
40
+
41
+ Or scan an `ArrayBuffer` directly:
42
+
43
+ ```js
44
+ import { scanBuffer } from '@pompelmi/cloudflare';
45
+
46
+ export default {
47
+ async fetch(request, env) {
48
+ const formData = await request.formData();
49
+ const file = formData.get('file');
50
+ const buffer = await file.arrayBuffer();
51
+
52
+ const result = await scanBuffer(buffer, {
53
+ host: env.CLAMAV_HOST,
54
+ port: parseInt(env.CLAMAV_PORT),
55
+ });
56
+
57
+ if (result !== 'clean') {
58
+ return new Response('File rejected', { status: 422 });
59
+ }
60
+
61
+ return new Response('OK');
62
+ },
63
+ };
64
+ ```
65
+
66
+ ## API
67
+
68
+ ### `scanBuffer(buffer, options)`
69
+
70
+ | Parameter | Type | Description |
71
+ |---|---|---|
72
+ | `buffer` | `ArrayBuffer` | The file bytes to scan |
73
+ | `options.host` | `string` | clamd hostname or IP (required) |
74
+ | `options.port` | `number` | clamd port, typically `3310` (required) |
75
+ | `options.timeout` | `number` | Read timeout in ms (default: `15000`) |
76
+
77
+ Returns `Promise<'clean' | 'malicious' | 'error'>`.
78
+
79
+ ### `scanRequest(request, options)`
80
+
81
+ Reads the multipart form field (default: `file`), scans it, and returns:
82
+ - `null` — file is clean, proceed normally.
83
+ - `Response(422)` — malicious file detected.
84
+ - `Response(500)` — scan error (clamd unreachable, timeout, etc.).
85
+
86
+ Additional option: `options.field` — form field name (default: `'file'`).
87
+
88
+ ## Wrangler configuration
89
+
90
+ Copy [`wrangler.toml.example`](./wrangler.toml.example) to `wrangler.toml` and fill in your clamd host details.
91
+
92
+ ## License
93
+
94
+ ISC
@@ -0,0 +1,50 @@
1
+ /** Result returned by scanBuffer */
2
+ export type ScanResult = 'clean' | 'malicious' | 'error';
3
+
4
+ /** Options for connecting to a remote clamd instance */
5
+ export interface CloudflareScanOptions {
6
+ /** Hostname or IP of the remote clamd instance */
7
+ host: string;
8
+ /** Port the remote clamd listens on (typically 3310) */
9
+ port: number;
10
+ /** Socket read timeout in milliseconds (default: 15000) */
11
+ timeout?: number;
12
+ /** Form field name to scan when using scanRequest (default: 'file') */
13
+ field?: string;
14
+ }
15
+
16
+ /**
17
+ * Scan an ArrayBuffer with a remote clamd instance using INSTREAM protocol.
18
+ * Uses Cloudflare's `connect()` socket API — no Node.js built-ins required.
19
+ *
20
+ * @example
21
+ * const result = await scanBuffer(await file.arrayBuffer(), {
22
+ * host: env.CLAMAV_HOST,
23
+ * port: parseInt(env.CLAMAV_PORT),
24
+ * });
25
+ */
26
+ export declare function scanBuffer(
27
+ buffer: ArrayBuffer,
28
+ options: CloudflareScanOptions
29
+ ): Promise<ScanResult>;
30
+
31
+ /**
32
+ * Scan a multipart form field from a Cloudflare Worker Request.
33
+ * Returns null if the file is clean, or an HTTP Response (422/500) otherwise.
34
+ *
35
+ * @example
36
+ * export default {
37
+ * async fetch(request, env) {
38
+ * const rejection = await scanRequest(request, {
39
+ * host: env.CLAMAV_HOST,
40
+ * port: parseInt(env.CLAMAV_PORT),
41
+ * });
42
+ * if (rejection) return rejection;
43
+ * return new Response('OK');
44
+ * }
45
+ * }
46
+ */
47
+ export declare function scanRequest(
48
+ request: Request,
49
+ options: CloudflareScanOptions
50
+ ): Promise<Response | null>;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * @pompelmi/cloudflare
3
+ *
4
+ * Connects to a remote clamd instance over TCP using Cloudflare's socket API.
5
+ * Uses Web APIs only — no Node.js built-ins.
6
+ *
7
+ * Requires `connect` from `cloudflare:sockets` (available in Workers Runtime ≥ 2023-03-01).
8
+ */
9
+
10
+ const INSTREAM_CHUNK = 4096; // bytes per INSTREAM chunk
11
+ const CLEAN_RESPONSE = /^stream: OK/;
12
+ const MALICIOUS_RESPONSE = /^stream: (.+) FOUND/;
13
+
14
+ /**
15
+ * Send a Buffer or ArrayBuffer to clamd via INSTREAM and return the raw
16
+ * response string.
17
+ *
18
+ * @param {ArrayBuffer} data
19
+ * @param {{ host: string, port: number, timeout?: number }} options
20
+ * @returns {Promise<string>}
21
+ */
22
+ async function clamdInstream(data, { host, port, timeout = 15000 }) {
23
+ // eslint-disable-next-line no-undef
24
+ const { connect } = await import('cloudflare:sockets');
25
+ const socket = connect({ hostname: host, port });
26
+
27
+ const writer = socket.writable.getWriter();
28
+ const reader = socket.readable.getReader();
29
+
30
+ const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
31
+
32
+ // Write INSTREAM command
33
+ const cmd = new TextEncoder().encode('zINSTREAM\0');
34
+ await writer.write(cmd);
35
+
36
+ // Stream data in chunks with 4-byte big-endian length prefix
37
+ let offset = 0;
38
+ while (offset < bytes.byteLength) {
39
+ const end = Math.min(offset + INSTREAM_CHUNK, bytes.byteLength);
40
+ const chunk = bytes.subarray(offset, end);
41
+ const lenBuf = new ArrayBuffer(4);
42
+ new DataView(lenBuf).setUint32(0, chunk.byteLength, false);
43
+ await writer.write(new Uint8Array(lenBuf));
44
+ await writer.write(chunk);
45
+ offset = end;
46
+ }
47
+
48
+ // Write zero-length chunk to signal end of stream
49
+ const terminator = new Uint8Array([0, 0, 0, 0]);
50
+ await writer.write(terminator);
51
+ writer.releaseLock();
52
+
53
+ // Read response with timeout
54
+ const timeoutId = setTimeout(() => reader.cancel(), timeout);
55
+ let response = '';
56
+ const decoder = new TextDecoder();
57
+ try {
58
+ while (true) {
59
+ const { value, done } = await reader.read();
60
+ if (done) break;
61
+ response += decoder.decode(value, { stream: true });
62
+ if (response.includes('\n') || response.includes('\0')) break;
63
+ }
64
+ } finally {
65
+ clearTimeout(timeoutId);
66
+ reader.releaseLock();
67
+ }
68
+
69
+ await socket.close();
70
+ return response.trim().replace(/\0/g, '');
71
+ }
72
+
73
+ /**
74
+ * Scan an ArrayBuffer with a remote clamd instance.
75
+ *
76
+ * @param {ArrayBuffer} buffer
77
+ * @param {{ host: string, port: number, timeout?: number }} options
78
+ * @returns {Promise<'clean' | 'malicious' | 'error'>}
79
+ */
80
+ export async function scanBuffer(buffer, options) {
81
+ if (!options || !options.host || !options.port) {
82
+ throw new Error('@pompelmi/cloudflare: options.host and options.port are required');
83
+ }
84
+ try {
85
+ const response = await clamdInstream(buffer, options);
86
+ if (CLEAN_RESPONSE.test(response)) return 'clean';
87
+ if (MALICIOUS_RESPONSE.test(response)) return 'malicious';
88
+ return 'error';
89
+ } catch {
90
+ return 'error';
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Convenience Cloudflare Worker handler that reads a multipart form field,
96
+ * scans it, and returns HTTP 422 on malicious files.
97
+ *
98
+ * @param {Request} request
99
+ * @param {{ host: string, port: number, field?: string, timeout?: number }} options
100
+ * @returns {Promise<Response | null>} null if clean, Response if malicious/error
101
+ */
102
+ export async function scanRequest(request, options) {
103
+ const field = options.field || 'file';
104
+ const formData = await request.formData();
105
+ const file = formData.get(field);
106
+ if (!file) return null;
107
+ const buffer = await file.arrayBuffer();
108
+ const result = await scanBuffer(buffer, options);
109
+ if (result === 'malicious') {
110
+ return new Response('File rejected: malicious content detected', { status: 422 });
111
+ }
112
+ if (result === 'error') {
113
+ return new Response('Scan error', { status: 500 });
114
+ }
115
+ return null;
116
+ }
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@pompelmi/cloudflare",
3
+ "version": "1.0.0",
4
+ "description": "pompelmi adapter for Cloudflare Workers — scan file uploads via a remote clamd instance using Web APIs only.",
5
+ "license": "ISC",
6
+ "author": "pompelmi contributors",
7
+ "homepage": "https://pompelmi.app",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/pompelmi/pompelmi.git",
11
+ "directory": "packages/cloudflare"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/pompelmi/pompelmi/issues"
15
+ },
16
+ "keywords": [
17
+ "clamav",
18
+ "antivirus",
19
+ "virus-scan",
20
+ "malware",
21
+ "cloudflare",
22
+ "cloudflare-workers",
23
+ "file-upload",
24
+ "security"
25
+ ],
26
+ "type": "module",
27
+ "main": "./index.js",
28
+ "types": "./index.d.ts",
29
+ "exports": {
30
+ ".": {
31
+ "import": "./index.js"
32
+ }
33
+ },
34
+ "publishConfig": {
35
+ "registry": "https://registry.npmjs.org/",
36
+ "access": "public"
37
+ }
38
+ }
@@ -0,0 +1,13 @@
1
+ name = "my-worker"
2
+ main = "src/worker.js"
3
+ compatibility_date = "2024-01-01"
4
+
5
+ [vars]
6
+ CLAMAV_HOST = "your-clamd-host.example.com"
7
+ CLAMAV_PORT = "3310"
8
+
9
+ # Cloudflare Workers require a publicly reachable clamd instance.
10
+ # Options:
11
+ # 1. Run clamd on a VPS and expose port 3310 (add firewall rules).
12
+ # 2. Use a Cloudflare Tunnel (cloudflared) to expose a private clamd instance.
13
+ # See: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/
package/src/index.mjs ADDED
@@ -0,0 +1,19 @@
1
+ import { createRequire } from 'module';
2
+ const require = createRequire(import.meta.url);
3
+ const pompelmi = require('./index.js');
4
+ export const {
5
+ scan,
6
+ scanBuffer,
7
+ scanStream,
8
+ scanDirectory,
9
+ scanS3,
10
+ createPool,
11
+ watch,
12
+ middleware,
13
+ createScanner,
14
+ generateDashboard,
15
+ generateShareCard,
16
+ notify,
17
+ Verdict,
18
+ } = pompelmi;
19
+ export default pompelmi;