ai-evaluate 2.1.7 → 2.1.8

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.
Files changed (92) hide show
  1. package/README.md +61 -4
  2. package/dist/evaluate.d.ts.map +1 -1
  3. package/dist/evaluate.js +18 -16
  4. package/dist/evaluate.js.map +1 -1
  5. package/dist/index.d.ts +2 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/miniflare-pool.d.ts +109 -0
  10. package/dist/miniflare-pool.d.ts.map +1 -0
  11. package/dist/miniflare-pool.js +308 -0
  12. package/dist/miniflare-pool.js.map +1 -0
  13. package/dist/node.d.ts.map +1 -1
  14. package/dist/node.js +42 -10
  15. package/dist/node.js.map +1 -1
  16. package/dist/shared.d.ts +66 -0
  17. package/dist/shared.d.ts.map +1 -0
  18. package/dist/shared.js +169 -0
  19. package/dist/shared.js.map +1 -0
  20. package/dist/type-guards.d.ts +21 -0
  21. package/dist/type-guards.d.ts.map +1 -0
  22. package/dist/type-guards.js +216 -0
  23. package/dist/type-guards.js.map +1 -0
  24. package/dist/types.d.ts +17 -2
  25. package/dist/types.d.ts.map +1 -1
  26. package/dist/validation.d.ts +26 -0
  27. package/dist/validation.d.ts.map +1 -0
  28. package/dist/validation.js +104 -0
  29. package/dist/validation.js.map +1 -0
  30. package/dist/worker-template/code-transforms.d.ts +9 -0
  31. package/dist/worker-template/code-transforms.d.ts.map +1 -0
  32. package/dist/worker-template/code-transforms.js +28 -0
  33. package/dist/worker-template/code-transforms.js.map +1 -0
  34. package/{src/worker-template.d.ts → dist/worker-template/core.d.ts} +7 -15
  35. package/dist/worker-template/core.d.ts.map +1 -0
  36. package/dist/worker-template/core.js +502 -0
  37. package/dist/worker-template/core.js.map +1 -0
  38. package/dist/worker-template/helpers.d.ts +14 -0
  39. package/dist/worker-template/helpers.d.ts.map +1 -0
  40. package/dist/worker-template/helpers.js +79 -0
  41. package/dist/worker-template/helpers.js.map +1 -0
  42. package/dist/worker-template/index.d.ts +14 -0
  43. package/dist/worker-template/index.d.ts.map +1 -0
  44. package/dist/worker-template/index.js +19 -0
  45. package/dist/worker-template/index.js.map +1 -0
  46. package/dist/worker-template/sdk-generator.d.ts +17 -0
  47. package/dist/worker-template/sdk-generator.d.ts.map +1 -0
  48. package/{src/worker-template.js → dist/worker-template/sdk-generator.js} +377 -1506
  49. package/dist/worker-template/sdk-generator.js.map +1 -0
  50. package/dist/worker-template/test-generator.d.ts +16 -0
  51. package/dist/worker-template/test-generator.d.ts.map +1 -0
  52. package/dist/worker-template/test-generator.js +357 -0
  53. package/dist/worker-template/test-generator.js.map +1 -0
  54. package/dist/worker-template.d.ts +2 -2
  55. package/dist/worker-template.d.ts.map +1 -1
  56. package/dist/worker-template.js +22 -10
  57. package/dist/worker-template.js.map +1 -1
  58. package/example/src/index.ts +12 -5
  59. package/package.json +1 -3
  60. package/src/evaluate.ts +21 -17
  61. package/src/index.ts +3 -1
  62. package/src/miniflare-pool.ts +395 -0
  63. package/src/node.ts +56 -11
  64. package/src/shared.ts +186 -0
  65. package/src/type-guards.ts +323 -0
  66. package/src/types.ts +18 -2
  67. package/src/validation.ts +120 -0
  68. package/src/worker-template/code-transforms.ts +32 -0
  69. package/src/worker-template/core.ts +557 -0
  70. package/src/worker-template/helpers.ts +90 -0
  71. package/src/worker-template/index.ts +23 -0
  72. package/src/{worker-template.ts → worker-template/sdk-generator.ts} +322 -1613
  73. package/src/worker-template/test-generator.ts +358 -0
  74. package/test/miniflare-pool.test.ts +246 -0
  75. package/test/node.test.ts +467 -0
  76. package/test/security.test.ts +1009 -0
  77. package/test/shared.test.ts +105 -0
  78. package/test/type-guards.test.ts +303 -0
  79. package/test/validation.test.ts +240 -0
  80. package/test/worker-template.test.ts +21 -19
  81. package/src/evaluate.js +0 -187
  82. package/src/index.js +0 -10
  83. package/src/node.d.ts +0 -17
  84. package/src/node.d.ts.map +0 -1
  85. package/src/node.js +0 -168
  86. package/src/node.js.map +0 -1
  87. package/src/types.d.ts +0 -172
  88. package/src/types.d.ts.map +0 -1
  89. package/src/types.js +0 -4
  90. package/src/types.js.map +0 -1
  91. package/src/worker-template.d.ts.map +0 -1
  92. package/src/worker-template.js.map +0 -1
package/src/evaluate.ts CHANGED
@@ -10,13 +10,9 @@
10
10
  */
11
11
 
12
12
  import type { EvaluateOptions, EvaluateResult, WorkerLoader, SandboxEnv } from './types.js'
13
- import { generateWorkerCode } from './worker-template.js'
13
+ import { generateWorkerCode } from './worker-template/index.js'
14
14
  import { CAPNWEB_SOURCE } from './capnweb-bundle.js'
15
-
16
- /**
17
- * Compatibility date for dynamic workers (2026)
18
- */
19
- const COMPATIBILITY_DATE = '2026-01-01'
15
+ import { COMPATIBILITY_DATE, normalizeImport, extractPackageName } from './shared.js'
20
16
 
21
17
  /**
22
18
  * Generate a minimal worker for simple script execution
@@ -37,10 +33,8 @@ function generateSimpleWorkerCode(options: {
37
33
 
38
34
  // Make imports available as globals
39
35
  const importGlobals = imports
40
- .map((url, i) => {
41
- // Extract package name from URL for variable naming
42
- const match = url.match(/esm\.sh\/([^@/]+)/)
43
- const pkgName = match ? match[1].replace(/-/g, '_') : `pkg${i}`
36
+ .map((specifier, i) => {
37
+ const pkgName = extractPackageName(specifier, i)
44
38
  const varName = pkgName === 'lodash' ? '_' : pkgName
45
39
  return `globalThis.${varName} = __import${i}__.default || __import${i}__;
46
40
  globalThis.pkg = __import${i}__.default || __import${i}__;`
@@ -157,17 +151,24 @@ export async function evaluate(
157
151
  }
158
152
 
159
153
  /**
160
- * Pre-fetch external modules from URLs (e.g., esm.sh)
154
+ * Pre-fetch external modules from URLs or package names
161
155
  * Returns a map of module name to source code
162
156
  *
157
+ * Supports:
158
+ * - Full URLs: https://esm.sh/lodash@4.17.21
159
+ * - Bare package names: lodash, lodash@4.17.21, @scope/pkg
160
+ *
163
161
  * Handles esm.sh's redirect-style modules by following the internal import paths.
164
162
  */
165
163
  async function prefetchModules(imports: string[]): Promise<Record<string, string>> {
166
164
  const modules: Record<string, string> = {}
167
165
 
168
166
  await Promise.all(
169
- imports.map(async (url, i) => {
167
+ imports.map(async (specifier, i) => {
170
168
  try {
169
+ // Normalize bare package names to esm.sh URLs
170
+ const url = normalizeImport(specifier)
171
+
171
172
  // For esm.sh URLs, try to get the bundled version directly
172
173
  let fetchUrl = url
173
174
  if (url.includes('esm.sh/') && !url.includes('.mjs') && !url.includes('.js')) {
@@ -197,7 +198,9 @@ async function prefetchModules(imports: string[]): Promise<Record<string, string
197
198
  modules[moduleName] = source
198
199
  } catch (error) {
199
200
  throw new Error(
200
- `Failed to fetch import ${url}: ${error instanceof Error ? error.message : String(error)}`
201
+ `Failed to fetch import ${specifier}: ${
202
+ error instanceof Error ? error.message : String(error)
203
+ }`
201
204
  )
202
205
  }
203
206
  })
@@ -244,8 +247,8 @@ async function evaluateSimple(
244
247
  ...externalModules,
245
248
  },
246
249
  compatibilityDate: COMPATIBILITY_DATE,
247
- // Block network access only if fetch: null
248
- globalOutbound: options.fetch === null ? null : undefined,
250
+ // Block network if fetch is false or null
251
+ globalOutbound: options.fetch === false || options.fetch === null ? null : undefined,
249
252
  }))
250
253
 
251
254
  // Get the entrypoint and call fetch
@@ -274,6 +277,7 @@ async function evaluateWithWorkerLoader(
274
277
  script: options.script,
275
278
  sdk: options.sdk,
276
279
  imports: options.imports,
280
+ fetch: options.fetch,
277
281
  })
278
282
  const id = `sandbox-${Date.now()}-${Math.random().toString(36).slice(2)}`
279
283
 
@@ -285,8 +289,8 @@ async function evaluateWithWorkerLoader(
285
289
  'capnweb.js': CAPNWEB_SOURCE,
286
290
  },
287
291
  compatibilityDate: COMPATIBILITY_DATE,
288
- // Block network access only if fetch: null
289
- globalOutbound: options.fetch === null ? null : undefined,
292
+ // Block network if fetch is false or null
293
+ globalOutbound: options.fetch === false || options.fetch === null ? null : undefined,
290
294
  bindings: {
291
295
  TEST: testService,
292
296
  },
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  export { evaluate, createEvaluator } from './evaluate.js'
12
+ export { normalizeImport, normalizeImports } from './shared.js'
12
13
 
13
14
  export type {
14
15
  EvaluateOptions,
@@ -17,5 +18,6 @@ export type {
17
18
  TestResults,
18
19
  TestResult,
19
20
  SandboxEnv,
20
- SDKConfig
21
+ SDKConfig,
22
+ FetchConfig,
21
23
  } from './types.js'
@@ -0,0 +1,395 @@
1
+ /**
2
+ * Miniflare instance pool for improved performance
3
+ *
4
+ * Reuses Miniflare instances between evaluations instead of creating/disposing
5
+ * for each evaluation, providing 4-5x performance improvement.
6
+ *
7
+ * Uses Miniflare's setOptions() to update the worker script between uses,
8
+ * avoiding the expensive instance creation/teardown cycle.
9
+ */
10
+
11
+ import type {
12
+ Miniflare as MiniflareType,
13
+ MiniflareOptions as MiniflareOptionsType,
14
+ } from 'miniflare'
15
+
16
+ /**
17
+ * Pool configuration options
18
+ */
19
+ export interface PoolConfig {
20
+ /** Number of instances to maintain in the pool (default: 3) */
21
+ size?: number
22
+ /** Milliseconds before disposing idle instance (default: 30000) */
23
+ maxIdleTime?: number
24
+ }
25
+
26
+ /**
27
+ * Outbound service handler type for network control
28
+ * - () => never: Block all network access (throw error)
29
+ * - (request: Request) => Response | Promise<Response>: Custom handler (allowlist, proxy, etc.)
30
+ */
31
+ export type OutboundServiceHandler =
32
+ | (() => never)
33
+ | ((request: Request) => Response | Promise<Response>)
34
+
35
+ /**
36
+ * Options for updating a pooled instance's worker
37
+ */
38
+ export interface WorkerOptions {
39
+ script: string
40
+ compatibilityDate?: string
41
+ outboundService?: OutboundServiceHandler
42
+ }
43
+
44
+ /**
45
+ * A pooled Miniflare instance with metadata
46
+ */
47
+ interface PooledInstance {
48
+ instance: MiniflareType
49
+ inUse: boolean
50
+ lastUsed: number
51
+ createdAt: number
52
+ }
53
+
54
+ // Type for the Miniflare constructor
55
+ type MiniflareConstructor = new (config: MiniflareOptionsType) => MiniflareType
56
+
57
+ /**
58
+ * Global pool state (singleton per process)
59
+ */
60
+ let pool: PooledInstance[] = []
61
+ let poolConfig: Required<PoolConfig> = {
62
+ size: 3,
63
+ maxIdleTime: 30000,
64
+ }
65
+ let idleCleanupInterval: NodeJS.Timeout | null = null
66
+ let MiniflareClass: MiniflareConstructor | null = null
67
+ let isShuttingDown = false
68
+
69
+ // Default worker script for warm instances
70
+ const WARM_WORKER_SCRIPT = `
71
+ export default {
72
+ async fetch(request, env) {
73
+ return new Response('ready', { status: 200 });
74
+ }
75
+ };
76
+ `
77
+
78
+ /**
79
+ * Configure the Miniflare pool
80
+ *
81
+ * @example
82
+ * ```ts
83
+ * import { configurePool } from 'ai-evaluate/node'
84
+ *
85
+ * configurePool({
86
+ * size: 5, // Keep 5 warm instances
87
+ * maxIdleTime: 60000 // Dispose after 60s idle
88
+ * })
89
+ * ```
90
+ */
91
+ export function configurePool(config: PoolConfig): void {
92
+ poolConfig = {
93
+ size: config.size ?? poolConfig.size,
94
+ maxIdleTime: config.maxIdleTime ?? poolConfig.maxIdleTime,
95
+ }
96
+ startIdleCleanup()
97
+ }
98
+
99
+ /**
100
+ * Get the current pool configuration
101
+ */
102
+ export function getPoolConfig(): Required<PoolConfig> {
103
+ return { ...poolConfig }
104
+ }
105
+
106
+ /**
107
+ * Get pool statistics for monitoring
108
+ */
109
+ export function getPoolStats(): {
110
+ size: number
111
+ available: number
112
+ inUse: number
113
+ config: Required<PoolConfig>
114
+ } {
115
+ const available = pool.filter((p) => !p.inUse).length
116
+ return {
117
+ size: pool.length,
118
+ available,
119
+ inUse: pool.length - available,
120
+ config: { ...poolConfig },
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Initialize the Miniflare class (lazy load)
126
+ */
127
+ async function getMiniflareClass(): Promise<MiniflareConstructor> {
128
+ if (!MiniflareClass) {
129
+ const { Miniflare } = await import('miniflare')
130
+ MiniflareClass = Miniflare as MiniflareConstructor
131
+ }
132
+ return MiniflareClass
133
+ }
134
+
135
+ /**
136
+ * Create a new Miniflare instance with a warm worker
137
+ */
138
+ async function createInstance(): Promise<MiniflareType> {
139
+ const Miniflare = await getMiniflareClass()
140
+ return new Miniflare({
141
+ modules: true,
142
+ script: WARM_WORKER_SCRIPT,
143
+ compatibilityDate: '2026-01-01',
144
+ })
145
+ }
146
+
147
+ /**
148
+ * Start the idle cleanup interval
149
+ */
150
+ function startIdleCleanup(): void {
151
+ if (idleCleanupInterval) {
152
+ clearInterval(idleCleanupInterval)
153
+ }
154
+
155
+ idleCleanupInterval = setInterval(async () => {
156
+ if (isShuttingDown) return
157
+
158
+ const now = Date.now()
159
+ const toDispose: PooledInstance[] = []
160
+
161
+ // Find idle instances beyond the idle timeout
162
+ for (let i = pool.length - 1; i >= 0; i--) {
163
+ const item = pool[i]
164
+ if (!item.inUse && now - item.lastUsed > poolConfig.maxIdleTime) {
165
+ // Keep at least one warm instance
166
+ if (pool.filter((p) => !p.inUse && !toDispose.includes(p)).length > 1) {
167
+ toDispose.push(item)
168
+ pool.splice(i, 1)
169
+ }
170
+ }
171
+ }
172
+
173
+ // Dispose old instances
174
+ for (const item of toDispose) {
175
+ try {
176
+ await item.instance.dispose()
177
+ } catch {
178
+ // Ignore disposal errors
179
+ }
180
+ }
181
+ }, 5000) // Check every 5 seconds
182
+
183
+ // Don't keep the process alive just for cleanup
184
+ if (idleCleanupInterval.unref) {
185
+ idleCleanupInterval.unref()
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Acquire a Miniflare instance from the pool and configure it with a worker
191
+ *
192
+ * If a free instance is available, it will be reconfigured and returned.
193
+ * Otherwise, a new instance will be created (up to pool size limit).
194
+ * If pool is exhausted, creates a temporary instance.
195
+ *
196
+ * @param workerOptions - Configuration for the worker to run
197
+ * @returns Object with the configured instance and a release function
198
+ */
199
+ export async function acquireInstance(workerOptions: WorkerOptions): Promise<{
200
+ instance: MiniflareType
201
+ release: () => Promise<void>
202
+ isPooled: boolean
203
+ }> {
204
+ if (isShuttingDown) {
205
+ throw new Error('Pool is shutting down')
206
+ }
207
+
208
+ // Start idle cleanup if not started
209
+ if (!idleCleanupInterval) {
210
+ startIdleCleanup()
211
+ }
212
+
213
+ const { script, compatibilityDate = '2026-01-01', outboundService } = workerOptions
214
+
215
+ // Build the options for setOptions
216
+ const updateOptions: MiniflareOptionsType = {
217
+ modules: true,
218
+ script,
219
+ compatibilityDate,
220
+ }
221
+
222
+ // Only add outboundService if it's defined (for blocking network)
223
+ if (outboundService !== undefined) {
224
+ updateOptions.outboundService = outboundService
225
+ }
226
+
227
+ // Try to find an available instance
228
+ const available = pool.find((p) => !p.inUse)
229
+ if (available) {
230
+ available.inUse = true
231
+ // Reconfigure the instance with the new worker script
232
+ await available.instance.setOptions(updateOptions)
233
+ return {
234
+ instance: available.instance,
235
+ release: async () => {
236
+ available.inUse = false
237
+ available.lastUsed = Date.now()
238
+ },
239
+ isPooled: true,
240
+ }
241
+ }
242
+
243
+ // Create new instance if pool not full
244
+ if (pool.length < poolConfig.size) {
245
+ const Miniflare = await getMiniflareClass()
246
+ const instance = new Miniflare(updateOptions)
247
+ const pooled: PooledInstance = {
248
+ instance,
249
+ inUse: true,
250
+ lastUsed: Date.now(),
251
+ createdAt: Date.now(),
252
+ }
253
+ pool.push(pooled)
254
+ return {
255
+ instance,
256
+ release: async () => {
257
+ pooled.inUse = false
258
+ pooled.lastUsed = Date.now()
259
+ },
260
+ isPooled: true,
261
+ }
262
+ }
263
+
264
+ // Pool exhausted - create temporary instance
265
+ const Miniflare = await getMiniflareClass()
266
+ const tempInstance = new Miniflare(updateOptions)
267
+ return {
268
+ instance: tempInstance,
269
+ release: async () => {
270
+ // Dispose temporary instance immediately
271
+ await tempInstance.dispose()
272
+ },
273
+ isPooled: false,
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Pre-warm the pool with instances
279
+ *
280
+ * Call this at application startup to avoid cold start latency.
281
+ *
282
+ * @example
283
+ * ```ts
284
+ * import { warmPool } from 'ai-evaluate/node'
285
+ *
286
+ * // Pre-warm 3 instances at startup
287
+ * await warmPool(3)
288
+ * ```
289
+ */
290
+ export async function warmPool(count?: number): Promise<void> {
291
+ const targetCount = count ?? poolConfig.size
292
+ const toCreate = Math.max(0, targetCount - pool.length)
293
+
294
+ const promises: Promise<void>[] = []
295
+ for (let i = 0; i < toCreate; i++) {
296
+ promises.push(
297
+ (async () => {
298
+ const instance = await createInstance()
299
+ pool.push({
300
+ instance,
301
+ inUse: false,
302
+ lastUsed: Date.now(),
303
+ createdAt: Date.now(),
304
+ })
305
+ })()
306
+ )
307
+ }
308
+
309
+ await Promise.all(promises)
310
+
311
+ // Start idle cleanup if not already started
312
+ if (!idleCleanupInterval) {
313
+ startIdleCleanup()
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Dispose all instances and clean up the pool
319
+ *
320
+ * Call this before process exit to ensure clean shutdown.
321
+ *
322
+ * @example
323
+ * ```ts
324
+ * import { disposePool } from 'ai-evaluate/node'
325
+ *
326
+ * process.on('beforeExit', async () => {
327
+ * await disposePool()
328
+ * })
329
+ * ```
330
+ */
331
+ export async function disposePool(): Promise<void> {
332
+ isShuttingDown = true
333
+
334
+ if (idleCleanupInterval) {
335
+ clearInterval(idleCleanupInterval)
336
+ idleCleanupInterval = null
337
+ }
338
+
339
+ const instances = [...pool]
340
+ pool = []
341
+
342
+ await Promise.all(
343
+ instances.map(async (item) => {
344
+ try {
345
+ await item.instance.dispose()
346
+ } catch {
347
+ // Ignore disposal errors
348
+ }
349
+ })
350
+ )
351
+
352
+ isShuttingDown = false
353
+ }
354
+
355
+ /**
356
+ * Reset the pool (for testing purposes)
357
+ */
358
+ export async function resetPool(): Promise<void> {
359
+ await disposePool()
360
+ poolConfig = {
361
+ size: 3,
362
+ maxIdleTime: 30000,
363
+ }
364
+ MiniflareClass = null
365
+ }
366
+
367
+ // Register cleanup on process exit
368
+ if (typeof process !== 'undefined') {
369
+ const cleanup = () => {
370
+ isShuttingDown = true
371
+ if (idleCleanupInterval) {
372
+ clearInterval(idleCleanupInterval)
373
+ }
374
+ // Synchronous disposal attempt - best effort
375
+ for (const item of pool) {
376
+ try {
377
+ // Fire and forget - we're exiting anyway
378
+ item.instance.dispose().catch(() => {})
379
+ } catch {
380
+ // Ignore errors during shutdown
381
+ }
382
+ }
383
+ pool = []
384
+ }
385
+
386
+ process.on('exit', cleanup)
387
+ process.on('SIGINT', () => {
388
+ cleanup()
389
+ process.exit(0)
390
+ })
391
+ process.on('SIGTERM', () => {
392
+ cleanup()
393
+ process.exit(0)
394
+ })
395
+ }
package/src/node.ts CHANGED
@@ -5,8 +5,15 @@
5
5
  * For Workers-only builds, import from 'ai-evaluate' instead.
6
6
  */
7
7
 
8
- import type { EvaluateOptions, EvaluateResult, WorkerLoader, SandboxEnv } from './types.js'
9
- import { generateWorkerCode, generateDevWorkerCode } from './worker-template.js'
8
+ import type {
9
+ EvaluateOptions,
10
+ EvaluateResult,
11
+ WorkerLoader,
12
+ SandboxEnv,
13
+ FetchConfig,
14
+ } from './types.js'
15
+ import { generateWorkerCode, generateDevWorkerCode } from './worker-template/index.js'
16
+ import { isDomainAllowed, normalizeImports } from './shared.js'
10
17
 
11
18
  /**
12
19
  * Check if code contains JSX syntax that needs transformation
@@ -60,6 +67,7 @@ export async function evaluate(
60
67
  module: transformedModule,
61
68
  tests: transformedTests,
62
69
  script: transformedScript,
70
+ imports: normalizeImports(options.imports),
63
71
  }
64
72
 
65
73
  // Use worker_loaders if available (Cloudflare Workers)
@@ -122,6 +130,25 @@ async function evaluateWithWorkerLoader(
122
130
  }
123
131
  }
124
132
 
133
+ /**
134
+ * Determine if network access should be blocked based on fetch options
135
+ * fetch: false | null -> block
136
+ */
137
+ function shouldBlockNetwork(options: EvaluateOptions): boolean {
138
+ return options.fetch === false || options.fetch === null
139
+ }
140
+
141
+ /**
142
+ * Get allowlist domains if fetch is an array
143
+ * fetch: string[] -> allowlist
144
+ */
145
+ function getAllowlistDomains(options: EvaluateOptions): string[] | null {
146
+ if (Array.isArray(options.fetch)) {
147
+ return options.fetch
148
+ }
149
+ return null
150
+ }
151
+
125
152
  /**
126
153
  * Evaluate using Miniflare (for Node.js/development)
127
154
  */
@@ -140,20 +167,38 @@ async function evaluateWithMiniflare(
140
167
  fetch: options.fetch, // Pass fetch option to worker template
141
168
  })
142
169
 
143
- // Block outbound network requests at Miniflare level when fetch: null
144
- // This complements the globalThis.fetch override in the worker template
145
- const blockNetwork = options.fetch === null
170
+ // Determine outbound service configuration based on fetch option
171
+ const blockNetwork = shouldBlockNetwork(options)
172
+ const allowlistDomains = getAllowlistDomains(options)
173
+
174
+ // Build outboundService based on mode:
175
+ // - block: throw error for all requests
176
+ // - allowlist: check domain against allowlist
177
+ // - allow (default): no outboundService (allow all)
178
+ type OutboundServiceFn = (() => never) | ((request: Request) => Response | Promise<Response>)
179
+ let outboundService: OutboundServiceFn | undefined
180
+ if (blockNetwork) {
181
+ outboundService = () => {
182
+ throw new Error('Network access blocked: fetch is disabled in this sandbox')
183
+ }
184
+ } else if (allowlistDomains) {
185
+ outboundService = (request: Request) => {
186
+ const url = request.url
187
+ if (!isDomainAllowed(url, allowlistDomains)) {
188
+ const hostname = new URL(url).hostname
189
+ throw new Error(`Network access blocked: domain not in allowlist. Attempted: ${hostname}`)
190
+ }
191
+ // Allow the request by returning a fetched response
192
+ return fetch(request)
193
+ }
194
+ }
146
195
 
147
196
  const mf = new Miniflare({
148
197
  modules: true,
149
198
  script: workerCode,
150
199
  compatibilityDate: '2026-01-01',
151
- // Block all outbound fetch/connect when network is disabled
152
- ...(blockNetwork && {
153
- outboundService: () => {
154
- throw new Error('Network access blocked: fetch is disabled in this sandbox')
155
- },
156
- }),
200
+ // Configure outbound service based on fetch mode
201
+ ...(outboundService && { outboundService }),
157
202
  })
158
203
 
159
204
  try {