lopata 0.2.1 → 0.3.1

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 (45) hide show
  1. package/dist/dashboard/{chunk-jrv1mhg9.css → chunk-paesqsyf.css} +33 -17
  2. package/dist/dashboard/{chunk-5j5em7qa.js → chunk-ymq225fp.js} +190 -98
  3. package/dist/dashboard/index.html +1 -1
  4. package/package.json +13 -16
  5. package/src/api/dispatch.ts +2 -0
  6. package/src/api/generate-sql.ts +51 -0
  7. package/src/api/handlers/d1.ts +8 -0
  8. package/src/api/handlers/do.ts +8 -0
  9. package/src/api/handlers/email.ts +1 -1
  10. package/src/api/handlers/queue.ts +1 -1
  11. package/src/api/handlers/warnings.ts +8 -0
  12. package/src/api/index.ts +6 -1
  13. package/src/api/r2.ts +1 -1
  14. package/src/api/types.ts +2 -0
  15. package/src/bindings/browser.ts +0 -3
  16. package/src/bindings/cache.ts +1 -1
  17. package/src/bindings/container.ts +4 -4
  18. package/src/bindings/crypto-extras.ts +1 -1
  19. package/src/bindings/do-executor-inprocess.ts +4 -0
  20. package/src/bindings/do-executor-worker.ts +4 -0
  21. package/src/bindings/do-executor.ts +3 -0
  22. package/src/bindings/durable-object.ts +32 -2
  23. package/src/bindings/html-rewriter.ts +26 -1
  24. package/src/bindings/images.ts +237 -32
  25. package/src/bindings/queue.ts +17 -1
  26. package/src/bindings/scheduled.ts +141 -22
  27. package/src/bindings/service-binding.ts +10 -5
  28. package/src/bindings/static-assets.ts +13 -1
  29. package/src/bindings/workflow.ts +5 -2
  30. package/src/cli/dev.ts +2 -1
  31. package/src/cli.ts +0 -0
  32. package/src/config.ts +17 -3
  33. package/src/dashboard-serve.ts +1 -1
  34. package/src/db.ts +6 -0
  35. package/src/env.ts +40 -2
  36. package/src/execution-context.ts +5 -0
  37. package/src/generation-manager.ts +7 -3
  38. package/src/generation.ts +31 -2
  39. package/src/lopata-config.ts +7 -0
  40. package/src/plugin.ts +77 -4
  41. package/src/request-cf.ts +2 -0
  42. package/src/tracing/store.ts +1 -1
  43. package/src/tsconfig.json +1 -0
  44. package/src/vite-plugin/dev-server-plugin.ts +0 -1
  45. package/src/warnings.ts +30 -0
@@ -37,10 +37,13 @@ export class ServiceBinding {
37
37
  private _limits: Required<ServiceBindingLimits>
38
38
  _subrequestCount: number = 0
39
39
 
40
- constructor(serviceName: string, entrypoint?: string, limits?: ServiceBindingLimits) {
40
+ private _props: Record<string, unknown>
41
+
42
+ constructor(serviceName: string, entrypoint?: string, limits?: ServiceBindingLimits, props?: Record<string, unknown>) {
41
43
  this._serviceName = serviceName
42
44
  this._entrypoint = entrypoint
43
45
  this._limits = { ...SERVICE_BINDING_DEFAULTS, ...limits }
46
+ this._props = props ?? {}
44
47
  }
45
48
 
46
49
  _wire(
@@ -80,24 +83,25 @@ export class ServiceBinding {
80
83
 
81
84
  private _getTarget(ctx?: ExecutionContext): Record<string, unknown> {
82
85
  const { workerModule, env } = this._resolve()
86
+ const execCtx = ctx ?? new ExecutionContext(this._props)
83
87
  if (this._entrypoint) {
84
88
  const cls = workerModule[this._entrypoint] as (new(...args: unknown[]) => Record<string, unknown>) | undefined
85
89
  if (!cls) {
86
90
  throw new Error(`Entrypoint "${this._entrypoint}" not exported from worker module`)
87
91
  }
88
- return new cls(ctx ?? new ExecutionContext(), env)
92
+ return new cls(execCtx, env)
89
93
  }
90
94
  // Default export: could be class-based or object-based
91
95
  const def = workerModule.default
92
96
  if (typeof def === 'function' && def.prototype && typeof def.prototype.fetch === 'function') {
93
- return new (def as new(ctx: ExecutionContext, env: unknown) => Record<string, unknown>)(ctx ?? new ExecutionContext(), env)
97
+ return new (def as new(ctx: ExecutionContext, env: unknown) => Record<string, unknown>)(execCtx, env)
94
98
  }
95
99
  return def as Record<string, unknown>
96
100
  }
97
101
 
98
102
  async fetch(input: Request | string | URL, init?: RequestInit): Promise<Response> {
99
103
  this._checkSubrequestLimit()
100
- const execCtx = new ExecutionContext()
104
+ const execCtx = new ExecutionContext(this._props)
101
105
  const target = this._getTarget(execCtx)
102
106
  if (!target?.fetch || typeof target.fetch !== 'function') {
103
107
  throw new Error(`Service binding "${this._serviceName}" target has no fetch() handler`)
@@ -214,7 +218,8 @@ export function createServiceBinding(
214
218
  serviceName: string,
215
219
  entrypoint?: string,
216
220
  limits?: ServiceBindingLimits,
221
+ props?: Record<string, unknown>,
217
222
  ): Record<string, unknown> {
218
- const binding = new ServiceBinding(serviceName, entrypoint, limits)
223
+ const binding = new ServiceBinding(serviceName, entrypoint, limits, props)
219
224
  return binding.toProxy()
220
225
  }
@@ -26,6 +26,7 @@ const STATIC_ASSETS_LIMITS_DEFAULTS: Required<StaticAssetsLimits> = {
26
26
  interface HeaderRule {
27
27
  pattern: string
28
28
  headers: Record<string, string>
29
+ removals: string[]
29
30
  }
30
31
 
31
32
  interface RedirectRule {
@@ -277,6 +278,9 @@ export class StaticAssets {
277
278
  for (const [key, value] of Object.entries(rule.headers)) {
278
279
  headers.set(key, value)
279
280
  }
281
+ for (const name of rule.removals) {
282
+ headers.delete(name)
283
+ }
280
284
  }
281
285
  }
282
286
  }
@@ -339,6 +343,14 @@ export function parseHeadersFile(content: string, limits: Required<StaticAssetsL
339
343
  if (line.startsWith(' ') || line.startsWith('\t')) {
340
344
  if (!currentRule) continue
341
345
  const trimmed = line.trim()
346
+ // Header removal: ! Header-Name
347
+ if (trimmed.startsWith('!')) {
348
+ const headerName = trimmed.slice(1).trim()
349
+ if (headerName) {
350
+ currentRule.removals.push(headerName)
351
+ }
352
+ continue
353
+ }
342
354
  const colonIdx = trimmed.indexOf(':')
343
355
  if (colonIdx === -1) continue
344
356
  const key = trimmed.slice(0, colonIdx).trim()
@@ -351,7 +363,7 @@ export function parseHeadersFile(content: string, limits: Required<StaticAssetsL
351
363
  if (rules.length >= limits.maxHeaderRules) {
352
364
  break // reached rule limit
353
365
  }
354
- currentRule = { pattern: line.trim(), headers: {} }
366
+ currentRule = { pattern: line.trim(), headers: {}, removals: [] }
355
367
  rules.push(currentRule)
356
368
  }
357
369
  }
@@ -552,9 +552,12 @@ export class SqliteWorkflowInstance {
552
552
  }
553
553
 
554
554
  async resume(): Promise<void> {
555
+ // If workflow was waiting for an event before pause, restore 'waiting' status
556
+ const waiters = eventWaiters.get(this.instanceId)
557
+ const newStatus = (waiters && waiters.size > 0) ? 'waiting' : 'running'
555
558
  this.db
556
- .query("UPDATE workflow_instances SET status = 'running', updated_at = ? WHERE id = ? AND status = 'paused'")
557
- .run(Date.now(), this.instanceId)
559
+ .query("UPDATE workflow_instances SET status = ?, updated_at = ? WHERE id = ? AND status = 'paused'")
560
+ .run(newStatus, Date.now(), this.instanceId)
558
561
  }
559
562
 
560
563
  async terminate(): Promise<void> {
package/src/cli/dev.ts CHANGED
@@ -3,7 +3,7 @@ Error.stackTraceLimit = 50
3
3
 
4
4
  import '../plugin'
5
5
  import path from 'node:path'
6
- import { handleApiRequest, setDashboardConfig, setGenerationManager, setWorkerRegistry } from '../api'
6
+ import { handleApiRequest, setDashboardConfig, setGenerationManager, setLopataConfig, setWorkerRegistry } from '../api'
7
7
  import { QueuePullConsumer } from '../bindings/queue'
8
8
  import type { AckRequest, PullRequest } from '../bindings/queue'
9
9
  import { CFWebSocket } from '../bindings/websocket-pair'
@@ -36,6 +36,7 @@ export async function run(ctx: CliContext) {
36
36
  if (lopataConfig) {
37
37
  // ─── Multi-worker mode ─────────────────────────────────────────
38
38
  console.log('[lopata] Multi-worker mode (lopata.config.ts found)')
39
+ setLopataConfig(lopataConfig)
39
40
 
40
41
  // Create executor factory based on isolation mode
41
42
  let executorFactory: import('../bindings/do-executor').DOExecutorFactory | undefined
package/src/cli.ts CHANGED
File without changes
package/src/config.ts CHANGED
@@ -17,7 +17,15 @@ export interface WranglerConfig {
17
17
  d1_databases?: { binding: string; database_name: string; database_id: string; migrations_dir?: string }[]
18
18
  queues?: {
19
19
  producers?: { binding: string; queue: string; delivery_delay?: number }[]
20
- consumers?: { queue: string; max_batch_size?: number; max_batch_timeout?: number; max_retries?: number; dead_letter_queue?: string }[]
20
+ consumers?: {
21
+ queue: string
22
+ max_batch_size?: number
23
+ max_batch_timeout?: number
24
+ max_retries?: number
25
+ dead_letter_queue?: string
26
+ max_concurrency?: number
27
+ retry_delay?: number
28
+ }[]
21
29
  }
22
30
  send_email?: {
23
31
  name: string
@@ -30,7 +38,7 @@ export interface WranglerConfig {
30
38
  id: string
31
39
  localConnectionString?: string
32
40
  }[]
33
- services?: { binding: string; service: string; entrypoint?: string }[]
41
+ services?: { binding: string; service: string; entrypoint?: string; props?: Record<string, unknown> }[]
34
42
  triggers?: { crons?: string[] }
35
43
  vars?: Record<string, string>
36
44
  assets?: {
@@ -53,7 +61,13 @@ export interface WranglerConfig {
53
61
  analytics_engine_datasets?: { binding: string; dataset?: string }[]
54
62
  browser?: { binding: string }
55
63
  version_metadata?: { binding: string }
56
- migrations?: { tag: string; new_classes?: string[]; new_sqlite_classes?: string[] }[]
64
+ migrations?: {
65
+ tag: string
66
+ new_classes?: string[]
67
+ new_sqlite_classes?: string[]
68
+ renamed_classes?: { from: string; to: string }[]
69
+ deleted_classes?: string[]
70
+ }[]
57
71
  env?: Record<string, Partial<Omit<WranglerConfig, 'env'>>>
58
72
  }
59
73
 
@@ -77,7 +77,7 @@ export function handleDashboardRequest(request: Request): Response {
77
77
  if (assetMatch && dashboardAssets) {
78
78
  const asset = dashboardAssets.get(assetMatch[1]!)
79
79
  if (asset) {
80
- return new Response(asset.content as unknown as BodyInit, {
80
+ return new Response(asset.content as unknown as Bun.BodyInit, {
81
81
  headers: {
82
82
  'Content-Type': asset.contentType,
83
83
  'Cache-Control': 'public, max-age=31536000, immutable',
package/src/db.ts CHANGED
@@ -250,6 +250,12 @@ export function runMigrations(db: Database): void {
250
250
  )
251
251
  `)
252
252
  db.run(`CREATE INDEX IF NOT EXISTS idx_analytics_engine_dataset_ts ON analytics_engine(dataset, timestamp)`)
253
+
254
+ db.run(`
255
+ CREATE TABLE IF NOT EXISTS do_migrations (
256
+ tag TEXT PRIMARY KEY
257
+ )
258
+ `)
253
259
  }
254
260
 
255
261
  /** Returns the path to the .lopata data directory. */
package/src/env.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from 'node:fs'
1
+ import { existsSync, readFileSync, renameSync, rmSync } from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { AiBinding } from './bindings/ai'
4
4
  import { SqliteAnalyticsEngine } from './bindings/analytics-engine'
@@ -60,6 +60,8 @@ interface ConsumerConfig {
60
60
  maxBatchTimeout: number
61
61
  maxRetries: number
62
62
  deadLetterQueue: string | null
63
+ maxConcurrency: number | null
64
+ retryDelay: number | null
63
65
  }
64
66
 
65
67
  interface ServiceBindingEntry {
@@ -111,6 +113,40 @@ export function buildEnv(
111
113
 
112
114
  // KV namespaces
113
115
  const db = getDatabase()
116
+
117
+ // DO migrations (renamed_classes, deleted_classes)
118
+ if (config.migrations) {
119
+ for (const migration of config.migrations) {
120
+ const applied = db.query('SELECT 1 FROM do_migrations WHERE tag = ?').get(migration.tag)
121
+ if (applied) continue
122
+
123
+ for (const { from, to } of migration.renamed_classes ?? []) {
124
+ db.run('UPDATE do_storage SET namespace = ? WHERE namespace = ?', [to, from])
125
+ db.run('UPDATE do_alarms SET namespace = ? WHERE namespace = ?', [to, from])
126
+ db.run('UPDATE do_instances SET namespace = ? WHERE namespace = ?', [to, from])
127
+ const fromDir = path.join(getDataDir(), 'do-sql', from)
128
+ const toDir = path.join(getDataDir(), 'do-sql', to)
129
+ if (existsSync(fromDir)) {
130
+ renameSync(fromDir, toDir)
131
+ }
132
+ console.log(`[lopata] Migration ${migration.tag}: renamed DO class ${from} → ${to}`)
133
+ }
134
+
135
+ for (const className of migration.deleted_classes ?? []) {
136
+ db.run('DELETE FROM do_storage WHERE namespace = ?', [className])
137
+ db.run('DELETE FROM do_alarms WHERE namespace = ?', [className])
138
+ db.run('DELETE FROM do_instances WHERE namespace = ?', [className])
139
+ const classDir = path.join(getDataDir(), 'do-sql', className)
140
+ if (existsSync(classDir)) {
141
+ rmSync(classDir, { recursive: true })
142
+ }
143
+ console.log(`[lopata] Migration ${migration.tag}: deleted DO class ${className}`)
144
+ }
145
+
146
+ db.run('INSERT INTO do_migrations (tag) VALUES (?)', [migration.tag])
147
+ }
148
+ }
149
+
114
150
  for (const kv of config.kv_namespaces ?? []) {
115
151
  console.log(`[lopata] KV namespace: ${kv.binding}`)
116
152
  env[kv.binding] = instrumentBinding(new SqliteKVNamespace(db, kv.id), {
@@ -183,13 +219,15 @@ export function buildEnv(
183
219
  maxBatchTimeout: consumer.max_batch_timeout ?? 5,
184
220
  maxRetries: consumer.max_retries ?? 3,
185
221
  deadLetterQueue: consumer.dead_letter_queue ?? null,
222
+ maxConcurrency: consumer.max_concurrency ?? null,
223
+ retryDelay: consumer.retry_delay ?? null,
186
224
  })
187
225
  }
188
226
 
189
227
  // Service bindings
190
228
  for (const svc of config.services ?? []) {
191
229
  console.log(`[lopata] Service binding: ${svc.binding} -> ${svc.service}${svc.entrypoint ? ` (${svc.entrypoint})` : ''}`)
192
- const proxy = createServiceBinding(svc.service, svc.entrypoint)
230
+ const proxy = createServiceBinding(svc.service, svc.entrypoint, undefined, svc.props)
193
231
  env[svc.binding] = instrumentServiceBinding(proxy as object, svc.service) as Record<string, unknown>
194
232
  registry.serviceBindings.push({
195
233
  bindingName: svc.binding,
@@ -12,6 +12,11 @@ export function runWithExecutionContext<T>(ctx: ExecutionContext, fn: () => T):
12
12
 
13
13
  export class ExecutionContext {
14
14
  private _promises: Promise<unknown>[] = []
15
+ readonly props: Record<string, unknown>
16
+
17
+ constructor(props?: Record<string, unknown>) {
18
+ this.props = props ?? {}
19
+ }
15
20
 
16
21
  waitUntil(promise: Promise<unknown>): void {
17
22
  this._promises.push(promise.catch(err => {
@@ -165,12 +165,16 @@ export class GenerationManager {
165
165
  setGlobalEnv(env)
166
166
  }
167
167
 
168
- // 5. Validate default export
168
+ // 5. Validate default export (or service worker fetch handler)
169
169
  const defaultExport = workerModule.default
170
170
  const classBasedExport = isEntrypointClass(defaultExport)
171
+ const swHandlers = (globalThis as any).__lopata_sw_handlers as { fetch?: (event: any) => void } | undefined
172
+ const hasServiceWorkerFetch = !!swHandlers?.fetch
171
173
 
172
- if (!classBasedExport && !defaultExport?.fetch) {
173
- throw new Error('Worker module must export a default object with a fetch() method, or a class with a fetch() method on its prototype')
174
+ if (!classBasedExport && !defaultExport?.fetch && !hasServiceWorkerFetch) {
175
+ throw new Error(
176
+ 'Worker module must export a default object with a fetch() method, a class with a fetch() method on its prototype, or use addEventListener("fetch", handler)',
177
+ )
174
178
  }
175
179
 
176
180
  // 5b. Patch frameworks that use .then()/.catch() for dispatch (e.g. Hono)
package/src/generation.ts CHANGED
@@ -16,7 +16,15 @@ interface ClassRegistry {
16
16
  durableObjects: { bindingName: string; className: string; namespace: DurableObjectNamespaceImpl }[]
17
17
  workflows: { bindingName: string; className: string; binding: SqliteWorkflowBinding }[]
18
18
  containers: { className: string; image: string; maxInstances?: number; namespace: DurableObjectNamespaceImpl }[]
19
- queueConsumers: { queue: string; maxBatchSize: number; maxBatchTimeout: number; maxRetries: number; deadLetterQueue: string | null }[]
19
+ queueConsumers: {
20
+ queue: string
21
+ maxBatchSize: number
22
+ maxBatchTimeout: number
23
+ maxRetries: number
24
+ deadLetterQueue: string | null
25
+ maxConcurrency: number | null
26
+ retryDelay: number | null
27
+ }[]
20
28
  serviceBindings: { bindingName: string; serviceName: string; entrypoint?: string; proxy: Record<string, unknown> }[]
21
29
  staticAssets: { fetch(req: Request): Promise<Response> } | null
22
30
  }
@@ -109,7 +117,28 @@ export class Generation {
109
117
  const instance = new (this.defaultExport as new(ctx: ExecutionContext, env: unknown) => Record<string, unknown>)(ctx, this.env)
110
118
  return await (instance.fetch as (r: Request) => Promise<Response>)(req)
111
119
  }
112
- return await (this.defaultExport as { fetch: Function }).fetch(req, this.env, ctx) as Response
120
+ if ((this.defaultExport as Record<string, unknown>)?.fetch) {
121
+ return await (this.defaultExport as { fetch: Function }).fetch(req, this.env, ctx) as Response
122
+ }
123
+ // Service worker syntax: addEventListener("fetch", handler)
124
+ const swHandlers = (globalThis as any).__lopata_sw_handlers as { fetch?: (event: any) => void } | undefined
125
+ if (swHandlers?.fetch) {
126
+ return await new Promise<Response>((resolve, reject) => {
127
+ const event = {
128
+ type: 'fetch',
129
+ request: req,
130
+ respondWith(response: Response | Promise<Response>) {
131
+ Promise.resolve(response).then(resolve, reject)
132
+ },
133
+ waitUntil(promise: Promise<unknown>) {
134
+ ctx.waitUntil(promise)
135
+ },
136
+ passThroughOnException() {},
137
+ }
138
+ swHandlers.fetch!(event)
139
+ })
140
+ }
141
+ throw new Error('No fetch handler found')
113
142
  }
114
143
 
115
144
  const handleResponse = (response: Response): Response | undefined => {
@@ -17,6 +17,13 @@ export interface LopataConfig {
17
17
  * - "isolated" — each DO instance runs in a separate Bun Worker thread (faithful to CF production)
18
18
  */
19
19
  isolation?: 'dev' | 'isolated'
20
+ /** AI SQL generation config for the D1 console */
21
+ ai?: {
22
+ /** OpenRouter API key (fallback: OPENROUTER_API_KEY env var) */
23
+ apiKey?: string
24
+ /** Model to use (default: "anthropic/claude-haiku-4.5") */
25
+ model?: string
26
+ }
20
27
  /** Browser Rendering binding config for local dev */
21
28
  browser?: {
22
29
  /** WS endpoint of an existing Chrome instance. If set, uses puppeteer-core connect(). */
package/src/plugin.ts CHANGED
@@ -7,6 +7,7 @@ import { patchGlobalCrypto } from './bindings/crypto-extras'
7
7
  import { DurableObjectBase, WebSocketRequestResponsePair } from './bindings/durable-object'
8
8
  import { EmailMessage } from './bindings/email'
9
9
  import { HTMLRewriter } from './bindings/html-rewriter'
10
+ import type { ImageTransformOptions, OutputOptions } from './bindings/images'
10
11
  import { WebSocketPair } from './bindings/websocket-pair'
11
12
  import { NonRetryableError, WorkflowEntrypointBase } from './bindings/workflow'
12
13
  import { getDatabase } from './db'
@@ -97,6 +98,21 @@ Object.defineProperty(globalThis.performance, 'timeOrigin', {
97
98
  configurable: true,
98
99
  })
99
100
 
101
+ // Register addEventListener shim for legacy service worker syntax
102
+ // Workers that use addEventListener("fetch", handler) instead of export default { fetch }
103
+ const _serviceWorkerHandlers: { fetch?: (event: any) => void } = {}
104
+
105
+ Object.defineProperty(globalThis, 'addEventListener', {
106
+ value: (type: string, handler: (event: any) => void) => {
107
+ if (type === 'fetch') {
108
+ _serviceWorkerHandlers.fetch = handler
109
+ }
110
+ },
111
+ writable: false,
112
+ configurable: true,
113
+ }) /** @internal Get the registered service worker fetch handler */
114
+ ;(globalThis as any).__lopata_sw_handlers = _serviceWorkerHandlers
115
+
100
116
  // Register scheduler.wait(ms) — await-able setTimeout alternative
101
117
  Object.defineProperty(globalThis, 'scheduler', {
102
118
  value: {
@@ -186,14 +202,66 @@ async function readBodyLimited(r: Request | Response): Promise<string | null> {
186
202
  }
187
203
  }
188
204
 
205
+ /** Apply cf.image transform to a fetch response */
206
+ async function applyCfImageTransform(response: Response, imageOpts: Record<string, unknown>): Promise<Response> {
207
+ const ct = response.headers.get('content-type') ?? ''
208
+ if (!ct.startsWith('image/') || !response.body) return response
209
+
210
+ const { ImagesBinding } = await import('./bindings/images')
211
+ const images = new ImagesBinding()
212
+
213
+ // Split cf.image options into transform options and output options
214
+ const { format: rawFormat, quality, compression, metadata, ...transformRest } = imageOpts
215
+ const transformer = images.input(response.body).transform(transformRest as ImageTransformOptions)
216
+
217
+ // Determine output format
218
+ let outputFormat: OutputOptions['format'] = ct as OutputOptions['format']
219
+ if (rawFormat && rawFormat !== 'auto') {
220
+ const shortToMime: Record<string, OutputOptions['format']> = {
221
+ avif: 'image/avif',
222
+ webp: 'image/webp',
223
+ jpeg: 'image/jpeg',
224
+ png: 'image/png',
225
+ gif: 'image/gif',
226
+ }
227
+ outputFormat = shortToMime[rawFormat as string] ?? outputFormat
228
+ }
229
+ // Fallback to a valid format if the source CT isn't in our supported set
230
+ if (!['image/png', 'image/jpeg', 'image/webp', 'image/avif', 'image/gif'].includes(outputFormat)) {
231
+ outputFormat = 'image/webp'
232
+ }
233
+
234
+ const outputOpts: OutputOptions = { format: outputFormat }
235
+ if (rawFormat === 'auto') {
236
+ // Pass format through transform-level auto detection
237
+ ;(transformRest as ImageTransformOptions).format = 'auto'
238
+ }
239
+ if (quality !== undefined) outputOpts.quality = quality as OutputOptions['quality']
240
+ if (compression !== undefined) outputOpts.compression = compression as OutputOptions['compression']
241
+ if (metadata !== undefined) outputOpts.metadata = metadata as OutputOptions['metadata']
242
+
243
+ const result = await transformer.output(outputOpts)
244
+ const headers = new Headers(response.headers)
245
+ headers.set('content-type', result.contentType())
246
+ headers.delete('content-length') // size changed after transform
247
+ return new Response(result.image(), { status: response.status, statusText: response.statusText, headers })
248
+ }
249
+
189
250
  const _originalFetch = globalThis.fetch
190
251
  globalThis.fetch = ((input: any, init?: any): Promise<Response> => {
191
252
  const ctx = getActiveContext()
192
253
  if (ctx) {
193
254
  ctx.fetchStack.current = new Error()
194
255
  }
195
- // Outside a trace context, just pass through
196
- if (!ctx) return _originalFetch(input, init)
256
+
257
+ // Extract cf.image options before creating request (Request constructor drops cf)
258
+ const cfImageOpts = init?.cf?.image as Record<string, unknown> | undefined
259
+
260
+ // Outside a trace context, handle cf.image without tracing
261
+ if (!ctx) {
262
+ const p = _originalFetch(input, init)
263
+ return cfImageOpts ? p.then(r => applyCfImageTransform(r, cfImageOpts)) : p
264
+ }
197
265
 
198
266
  const request = new Request(input, init)
199
267
  const fetchRequest = request.clone()
@@ -219,13 +287,18 @@ globalThis.fetch = ((input: any, init?: any): Promise<Response> => {
219
287
  const reqBody = await readBodyLimited(request)
220
288
  if (reqBody) setSpanAttribute('http.request.body', reqBody)
221
289
 
222
- const response = await _originalFetch(fetchRequest as globalThis.Request)
290
+ let response = await _originalFetch(fetchRequest as globalThis.Request)
291
+
292
+ // Apply cf.image transform if present
293
+ if (cfImageOpts) {
294
+ response = await applyCfImageTransform(response, cfImageOpts)
295
+ }
223
296
 
224
297
  setSpanAttribute('http.status_code', response.status)
225
298
  setSpanAttribute('http.response.headers', headersToRecord(response.headers))
226
299
 
227
300
  // Capture response body from a clone (caller keeps the original stream)
228
- const resBody = await readBodyLimited(response.clone())
301
+ const resBody = await readBodyLimited(response.clone() as Response)
229
302
  if (resBody) setSpanAttribute('http.response.body', resBody)
230
303
 
231
304
  return response
package/src/request-cf.ts CHANGED
@@ -15,6 +15,8 @@ const DEFAULT_CF: Record<string, unknown> = {
15
15
  httpProtocol: 'HTTP/2',
16
16
  tlsVersion: 'TLSv1.3',
17
17
  tlsCipher: 'AEAD-AES128-GCM-SHA256',
18
+ isEUCountry: '0',
19
+ clientAcceptEncoding: 'gzip, deflate, br',
18
20
  }
19
21
 
20
22
  export function addCfProperty(request: Request): Request {
@@ -210,7 +210,7 @@ export class TraceStore {
210
210
 
211
211
  params.push(limit)
212
212
 
213
- const rows = this.db.prepare<Record<string, unknown>, unknown[]>(`
213
+ const rows = this.db.prepare<Record<string, unknown>, any>(`
214
214
  SELECT
215
215
  s.trace_id,
216
216
  s.name as root_span_name,
package/src/tsconfig.json CHANGED
@@ -3,6 +3,7 @@
3
3
  "compilerOptions": {
4
4
  "allowJs": true,
5
5
  "composite": true,
6
+ "outDir": "../.tsc-out/src",
6
7
 
7
8
  "noUnusedLocals": false,
8
9
  "noUnusedParameters": false,
@@ -467,7 +467,6 @@ function nodeReqToRequest(req: IncomingMessage): Request {
467
467
  method,
468
468
  headers,
469
469
  body: hasBody ? nodeStreamToReadable(req) : undefined,
470
- // @ts-expect-error duplex is needed for streaming request bodies
471
470
  duplex: hasBody ? 'half' : undefined,
472
471
  })
473
472
  }
@@ -0,0 +1,30 @@
1
+ export interface OptionalDep {
2
+ id: string
3
+ description: string
4
+ install: string
5
+ installed: boolean
6
+ }
7
+
8
+ function isAvailable(name: string): boolean {
9
+ try {
10
+ import.meta.resolve(name)
11
+ return true
12
+ } catch {
13
+ return false
14
+ }
15
+ }
16
+
17
+ const OPTIONAL_DEPS: { id: string; description: string; install: string }[] = [
18
+ { id: 'html-rewriter-wasm', description: 'HTMLRewriter API', install: 'bun add html-rewriter-wasm' },
19
+ { id: 'sharp', description: 'Image transformations', install: 'bun add sharp' },
20
+ { id: 'puppeteer-core', description: 'Browser rendering', install: 'bun add puppeteer-core' },
21
+ ]
22
+
23
+ const resolved: OptionalDep[] = OPTIONAL_DEPS.map(dep => ({
24
+ ...dep,
25
+ installed: isAvailable(dep.id),
26
+ }))
27
+
28
+ export function getOptionalDeps(): OptionalDep[] {
29
+ return resolved
30
+ }