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.
- package/dist/dashboard/{chunk-jrv1mhg9.css → chunk-paesqsyf.css} +33 -17
- package/dist/dashboard/{chunk-5j5em7qa.js → chunk-ymq225fp.js} +190 -98
- package/dist/dashboard/index.html +1 -1
- package/package.json +13 -16
- package/src/api/dispatch.ts +2 -0
- package/src/api/generate-sql.ts +51 -0
- package/src/api/handlers/d1.ts +8 -0
- package/src/api/handlers/do.ts +8 -0
- package/src/api/handlers/email.ts +1 -1
- package/src/api/handlers/queue.ts +1 -1
- package/src/api/handlers/warnings.ts +8 -0
- package/src/api/index.ts +6 -1
- package/src/api/r2.ts +1 -1
- package/src/api/types.ts +2 -0
- package/src/bindings/browser.ts +0 -3
- package/src/bindings/cache.ts +1 -1
- package/src/bindings/container.ts +4 -4
- package/src/bindings/crypto-extras.ts +1 -1
- package/src/bindings/do-executor-inprocess.ts +4 -0
- package/src/bindings/do-executor-worker.ts +4 -0
- package/src/bindings/do-executor.ts +3 -0
- package/src/bindings/durable-object.ts +32 -2
- package/src/bindings/html-rewriter.ts +26 -1
- package/src/bindings/images.ts +237 -32
- package/src/bindings/queue.ts +17 -1
- package/src/bindings/scheduled.ts +141 -22
- package/src/bindings/service-binding.ts +10 -5
- package/src/bindings/static-assets.ts +13 -1
- package/src/bindings/workflow.ts +5 -2
- package/src/cli/dev.ts +2 -1
- package/src/cli.ts +0 -0
- package/src/config.ts +17 -3
- package/src/dashboard-serve.ts +1 -1
- package/src/db.ts +6 -0
- package/src/env.ts +40 -2
- package/src/execution-context.ts +5 -0
- package/src/generation-manager.ts +7 -3
- package/src/generation.ts +31 -2
- package/src/lopata-config.ts +7 -0
- package/src/plugin.ts +77 -4
- package/src/request-cf.ts +2 -0
- package/src/tracing/store.ts +1 -1
- package/src/tsconfig.json +1 -0
- package/src/vite-plugin/dev-server-plugin.ts +0 -1
- 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
|
-
|
|
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(
|
|
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>)(
|
|
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
|
}
|
package/src/bindings/workflow.ts
CHANGED
|
@@ -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 =
|
|
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?: {
|
|
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?: {
|
|
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
|
|
package/src/dashboard-serve.ts
CHANGED
|
@@ -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,
|
package/src/execution-context.ts
CHANGED
|
@@ -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(
|
|
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: {
|
|
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
|
-
|
|
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 => {
|
package/src/lopata-config.ts
CHANGED
|
@@ -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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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 {
|
package/src/tracing/store.ts
CHANGED
package/src/tsconfig.json
CHANGED
|
@@ -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
|
}
|
package/src/warnings.ts
ADDED
|
@@ -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
|
+
}
|