lopata 0.19.2 → 0.20.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 (38) hide show
  1. package/dist/types/bindings/ai-search.d.ts +44 -0
  2. package/dist/types/bindings/analytics-engine-sql.d.ts +107 -0
  3. package/dist/types/bindings/artifacts-git-http.d.ts +19 -0
  4. package/dist/types/bindings/artifacts.d.ts +108 -0
  5. package/dist/types/bindings/flagship.d.ts +37 -0
  6. package/dist/types/bindings/vpc-network.d.ts +22 -0
  7. package/dist/types/bindings/worker-loader-entry.d.ts +71 -0
  8. package/dist/types/bindings/worker-loader.d.ts +81 -0
  9. package/dist/types/config.d.ts +23 -0
  10. package/dist/types/env.d.ts +3 -1
  11. package/dist/types/generation-manager.d.ts +6 -0
  12. package/dist/types/tsconfig.tsbuildinfo +1 -1
  13. package/dist/types/vite-plugin/dev-server-plugin.d.ts +8 -0
  14. package/dist/types/worker-thread/executor.d.ts +2 -0
  15. package/dist/types/worker-thread/protocol.d.ts +2 -0
  16. package/dist/types/worker-thread/thread-env.d.ts +3 -1
  17. package/package.json +1 -1
  18. package/src/bindings/ai-search.ts +185 -0
  19. package/src/bindings/analytics-engine-sql.ts +1138 -0
  20. package/src/bindings/artifacts-git-http.ts +249 -0
  21. package/src/bindings/artifacts.ts +453 -0
  22. package/src/bindings/do-worker-env.ts +4 -0
  23. package/src/bindings/flagship.ts +117 -0
  24. package/src/bindings/vpc-network.ts +40 -0
  25. package/src/bindings/worker-loader-entry.ts +143 -0
  26. package/src/bindings/worker-loader.ts +325 -0
  27. package/src/cli/dev.ts +25 -3
  28. package/src/config.ts +20 -0
  29. package/src/db.ts +54 -0
  30. package/src/env.ts +71 -0
  31. package/src/generation-manager.ts +5 -1
  32. package/src/generation.ts +20 -3
  33. package/src/plugin.ts +37 -0
  34. package/src/vite-plugin/dev-server-plugin.ts +18 -2
  35. package/src/worker-thread/entry.ts +1 -0
  36. package/src/worker-thread/executor.ts +3 -0
  37. package/src/worker-thread/protocol.ts +2 -0
  38. package/src/worker-thread/thread-env.ts +72 -1
package/src/cli/dev.ts CHANGED
@@ -24,13 +24,14 @@ import {
24
24
  setRouteDispatcher,
25
25
  setWorkerRegistry,
26
26
  } from '../api'
27
+ import { handleArtifactsGitRequest } from '../bindings/artifacts-git-http'
27
28
  import { reapOrphanContainers } from '../bindings/container-cleanup'
28
29
  import { QueuePullConsumer } from '../bindings/queue'
29
30
  import type { AckRequest, PullRequest } from '../bindings/queue'
30
31
  import { CFWebSocket } from '../bindings/websocket-pair'
31
32
  import { autoLoadConfig, findConfigPath, loadConfig } from '../config'
32
33
  import { handleDashboardRequest } from '../dashboard-serve'
33
- import { getDatabase } from '../db'
34
+ import { getDatabase, getDataDir } from '../db'
34
35
  import { FileWatcher } from '../file-watcher'
35
36
  import { GenerationManager } from '../generation-manager'
36
37
  import { ImportGraphWatcher } from '../import-graph'
@@ -63,6 +64,15 @@ export async function run(ctx: CliContext, args: string[]) {
63
64
  const envFlag = ctx.envName
64
65
  const portFlag = values.port
65
66
 
67
+ // Resolve port + host early so baseUrls (e.g. the Artifacts git remote) can be
68
+ // built before the generation managers, which pass it into their worker threads.
69
+ const earlyPort = parseInt(portFlag ?? process.env.PORT ?? '8787', 10)
70
+ const earlyHost = values.host ? '0.0.0.0' : (values.listen ?? process.env.HOST ?? 'localhost')
71
+ const artifactsHost = earlyHost === '0.0.0.0' ? 'localhost' : earlyHost
72
+ const baseUrls = {
73
+ artifacts: process.env.LOPATA_ARTIFACTS_BASE_URL ?? `http://${artifactsHost}:${earlyPort}/__artifacts/git`,
74
+ }
75
+
66
76
  const baseDir = process.cwd()
67
77
  const watchers: { stop(): void }[] = []
68
78
 
@@ -117,6 +127,7 @@ export async function run(ctx: CliContext, args: string[]) {
117
127
  executorFactory: await makeExecutorFactory(),
118
128
  configPath: lopataConfig.main,
119
129
  browserConfig: lopataConfig.browser,
130
+ baseUrls,
120
131
  })
121
132
  registry.register(mainConfig.name, mainManager, true)
122
133
 
@@ -135,6 +146,7 @@ export async function run(ctx: CliContext, args: string[]) {
135
146
  cron: lopataConfig.cron,
136
147
  executorFactory: await makeExecutorFactory(),
137
148
  configPath: workerDef.config,
149
+ baseUrls,
138
150
  })
139
151
  registry.register(workerDef.name, auxManager)
140
152
 
@@ -273,6 +285,7 @@ export async function run(ctx: CliContext, args: string[]) {
273
285
  isMain: true,
274
286
  executorFactory,
275
287
  configPath: findConfigPath(baseDir),
288
+ baseUrls,
276
289
  })
277
290
  registry.register(config.name, manager, true)
278
291
  const firstGen = await manager.reload()
@@ -306,8 +319,8 @@ export async function run(ctx: CliContext, args: string[]) {
306
319
  }
307
320
 
308
321
  // Start server — one Bun.serve(), delegates to active generation
309
- const port = parseInt(portFlag ?? process.env.PORT ?? '8787', 10)
310
- const hostname = values.host ? '0.0.0.0' : (values.listen ?? process.env.HOST ?? 'localhost')
322
+ const port = earlyPort
323
+ const hostname = earlyHost
311
324
 
312
325
  const server = Bun.serve({
313
326
  port,
@@ -334,6 +347,15 @@ export async function run(ctx: CliContext, args: string[]) {
334
347
  return handleDashboardRequest(request)
335
348
  }
336
349
 
350
+ // Artifacts git HTTP backend (clone/fetch/push) — backed by `git http-backend`.
351
+ if (url.pathname.startsWith('/__artifacts/git/')) {
352
+ const resp = await handleArtifactsGitRequest(request, {
353
+ db: getDatabase(),
354
+ artifactsDir: path.join(getDataDir(), 'artifacts'),
355
+ })
356
+ if (resp) return resp
357
+ }
358
+
337
359
  // S3-compatible proxy: /__s3/{bucket}/{key...} → R2 binding on active worker
338
360
  const s3Match = matchS3Path(url.pathname)
339
361
  if (s3Match) {
package/src/config.ts CHANGED
@@ -31,13 +31,32 @@ export interface WranglerConfig {
31
31
  name: string
32
32
  destination_address?: string
33
33
  allowed_destination_addresses?: string[]
34
+ remote?: boolean
34
35
  }[]
35
36
  ai?: { binding: string }
37
+ ai_search_namespaces?: {
38
+ binding: string
39
+ namespace: string
40
+ remote?: boolean
41
+ }[]
42
+ artifacts?: {
43
+ binding: string
44
+ namespace: string
45
+ }[]
46
+ worker_loaders?: {
47
+ binding: string
48
+ }[]
36
49
  hyperdrive?: {
37
50
  binding: string
38
51
  id: string
39
52
  localConnectionString?: string
40
53
  }[]
54
+ vpc_networks?: {
55
+ binding: string
56
+ network_id?: string
57
+ tunnel_id?: string
58
+ remote?: boolean
59
+ }[]
41
60
  services?: { binding: string; service: string; entrypoint?: string; props?: Record<string, unknown> }[]
42
61
  triggers?: { crons?: string[] }
43
62
  vars?: Record<string, string>
@@ -65,6 +84,7 @@ export interface WranglerConfig {
65
84
  analytics_engine_datasets?: { binding: string; dataset?: string }[]
66
85
  browser?: { binding: string }
67
86
  version_metadata?: { binding: string }
87
+ flagship?: { binding: string; app_id: string }
68
88
  migrations?: {
69
89
  tag: string
70
90
  new_classes?: string[]
package/src/db.ts CHANGED
@@ -257,6 +257,60 @@ export function runMigrations(db: Database): void {
257
257
  tag TEXT PRIMARY KEY
258
258
  )
259
259
  `)
260
+
261
+ db.run(`
262
+ CREATE TABLE IF NOT EXISTS artifacts_repos (
263
+ id TEXT PRIMARY KEY,
264
+ namespace TEXT NOT NULL,
265
+ name TEXT NOT NULL,
266
+ description TEXT,
267
+ default_branch TEXT NOT NULL DEFAULT 'main',
268
+ read_only INTEGER NOT NULL DEFAULT 0,
269
+ forked_from TEXT,
270
+ created_at INTEGER NOT NULL,
271
+ UNIQUE(namespace, name)
272
+ )
273
+ `)
274
+
275
+ db.run(`
276
+ CREATE TABLE IF NOT EXISTS artifacts_tokens (
277
+ id TEXT PRIMARY KEY,
278
+ repo_id TEXT NOT NULL,
279
+ plaintext TEXT NOT NULL,
280
+ scope TEXT NOT NULL DEFAULT 'write',
281
+ expires_at INTEGER,
282
+ created_at INTEGER NOT NULL,
283
+ revoked_at INTEGER
284
+ )
285
+ `)
286
+ db.run(`CREATE INDEX IF NOT EXISTS idx_artifacts_tokens_repo ON artifacts_tokens(repo_id)`)
287
+
288
+ db.run(`
289
+ CREATE TABLE IF NOT EXISTS ai_search_requests (
290
+ id TEXT PRIMARY KEY,
291
+ namespace TEXT NOT NULL,
292
+ operation TEXT NOT NULL,
293
+ input_summary TEXT,
294
+ output_summary TEXT,
295
+ duration_ms INTEGER NOT NULL,
296
+ status TEXT NOT NULL DEFAULT 'ok',
297
+ error TEXT,
298
+ created_at INTEGER NOT NULL
299
+ )
300
+ `)
301
+ db.run(`CREATE INDEX IF NOT EXISTS idx_ai_search_requests_ts ON ai_search_requests(created_at)`)
302
+
303
+ db.run(`
304
+ CREATE TABLE IF NOT EXISTS flagship_flags (
305
+ app_id TEXT NOT NULL,
306
+ flag_key TEXT NOT NULL,
307
+ type TEXT NOT NULL,
308
+ value TEXT NOT NULL,
309
+ variant TEXT,
310
+ updated_at INTEGER NOT NULL,
311
+ PRIMARY KEY (app_id, flag_key)
312
+ )
313
+ `)
260
314
  }
261
315
 
262
316
  /** Returns the path to the .lopata data directory. */
package/src/env.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { existsSync, readFileSync, renameSync, rmSync } from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { AiBinding } from './bindings/ai'
4
+ import { AiSearchNamespaceBinding } from './bindings/ai-search'
4
5
  import { SqliteAnalyticsEngine } from './bindings/analytics-engine'
6
+ import { ArtifactsBinding } from './bindings/artifacts'
5
7
  import { BrowserBinding } from './bindings/browser'
6
8
  import { ContainerBase } from './bindings/container'
7
9
  import { containerLabels, registerContainer, unregisterContainer } from './bindings/container-cleanup'
@@ -10,6 +12,7 @@ import { openD1Database } from './bindings/d1'
10
12
  import type { DOExecutorFactory } from './bindings/do-executor'
11
13
  import { DurableObjectNamespaceImpl } from './bindings/durable-object'
12
14
  import { SendEmailBinding } from './bindings/email'
15
+ import { FlagshipBinding } from './bindings/flagship'
13
16
  import { HyperdriveBinding } from './bindings/hyperdrive'
14
17
  import { ImagesBinding } from './bindings/images'
15
18
  import { SqliteKVNamespace } from './bindings/kv'
@@ -18,6 +21,8 @@ import { QueueConsumer, SqliteQueueProducer } from './bindings/queue'
18
21
  import { FileR2Bucket } from './bindings/r2'
19
22
  import { createServiceBinding } from './bindings/service-binding'
20
23
  import { StaticAssets } from './bindings/static-assets'
24
+ import { VpcNetworkBinding } from './bindings/vpc-network'
25
+ import { WorkerLoaderBinding } from './bindings/worker-loader'
21
26
  import { SqliteWorkflowBinding, wireWorkflowClass } from './bindings/workflow'
22
27
  import type { WranglerConfig } from './config'
23
28
  import { getDatabase, getDataDir } from './db'
@@ -88,6 +93,7 @@ export function buildEnv(
88
93
  executorFactory?: DOExecutorFactory,
89
94
  browserConfig?: { wsEndpoint?: string; executablePath?: string; headless?: boolean },
90
95
  existingNamespaces?: Map<string, DurableObjectNamespaceImpl>,
96
+ baseUrls?: { artifacts?: string },
91
97
  ): { env: Record<string, unknown>; registry: ClassRegistry } {
92
98
  const env: Record<string, unknown> = {}
93
99
  const registry: ClassRegistry = { durableObjects: [], workflows: [], containers: [], queueConsumers: [], serviceBindings: [], staticAssets: null }
@@ -363,6 +369,71 @@ export function buildEnv(
363
369
  )
364
370
  }
365
371
 
372
+ // VPC Networks — pass-through fetcher (network_id = Mesh, tunnel_id = tunnel)
373
+ for (const vpc of config.vpc_networks ?? []) {
374
+ const networkId = vpc.network_id ?? vpc.tunnel_id
375
+ if (!networkId) {
376
+ throw new Error(`VPC Network "${vpc.binding}" requires either network_id or tunnel_id`)
377
+ }
378
+ console.log(`[lopata] VPC Network: ${vpc.binding} (${vpc.tunnel_id ? 'tunnel' : 'network'}: ${networkId})`)
379
+ env[vpc.binding] = instrumentBinding(new VpcNetworkBinding({ networkId, bindingName: vpc.binding }), {
380
+ type: 'vpc_network',
381
+ name: vpc.binding,
382
+ methods: ['fetch'],
383
+ })
384
+ }
385
+
386
+ // AI Search namespaces — proxy to the CF AI Search REST API
387
+ for (const ns of config.ai_search_namespaces ?? []) {
388
+ const accountId = (env.CLOUDFLARE_ACCOUNT_ID ?? process.env.CLOUDFLARE_ACCOUNT_ID) as string | undefined
389
+ const apiToken = (env.CLOUDFLARE_API_TOKEN ?? process.env.CLOUDFLARE_API_TOKEN) as string | undefined
390
+ console.log(`[lopata] AI Search namespace: ${ns.binding} (namespace: ${ns.namespace})`)
391
+ env[ns.binding] = instrumentBinding(new AiSearchNamespaceBinding(db, ns.namespace, accountId, apiToken), {
392
+ type: 'ai_search',
393
+ name: ns.binding,
394
+ methods: ['create', 'get', 'list', 'delete', 'search', 'chatCompletions'],
395
+ })
396
+ }
397
+
398
+ // Artifacts — control plane (SQLite + bare git repos); the git-over-HTTP endpoint
399
+ // is served by the dev server at /__artifacts/git/* (see cli/dev.ts).
400
+ for (const artifacts of config.artifacts ?? []) {
401
+ const remoteBase = (baseUrls?.artifacts ?? 'http://localhost:8787/__artifacts/git').replace(/\/$/, '')
402
+ console.log(`[lopata] Artifacts binding: ${artifacts.binding} (namespace: ${artifacts.namespace})`)
403
+ env[artifacts.binding] = instrumentBinding(
404
+ new ArtifactsBinding(db, artifacts.namespace, path.join(getDataDir(), 'artifacts'), remoteBase),
405
+ { type: 'artifacts', name: artifacts.binding, methods: ['create', 'get', 'list', 'import', 'delete'] },
406
+ )
407
+ }
408
+
409
+ // Worker Loader — dynamic Workers (each its own Bun worker thread). Not wrapped in
410
+ // instrumentBinding: load()/get() return live WorkerStub handles *synchronously*, and
411
+ // the async span wrapper would turn them into Promises, breaking the
412
+ // `loader.get(id).getEntrypoint().fetch()` chaining the Cloudflare API requires.
413
+ for (const loader of config.worker_loaders ?? []) {
414
+ console.log(`[lopata] Worker Loader: ${loader.binding}`)
415
+ env[loader.binding] = new WorkerLoaderBinding(path.join(getDataDir(), 'worker-loader'))
416
+ }
417
+
418
+ // Flagship — SQLite-backed feature flags
419
+ if (config.flagship) {
420
+ console.log(`[lopata] Flagship binding: ${config.flagship.binding} (app: ${config.flagship.app_id})`)
421
+ env[config.flagship.binding] = instrumentBinding(new FlagshipBinding(db, config.flagship.app_id), {
422
+ type: 'flagship',
423
+ name: config.flagship.binding,
424
+ methods: [
425
+ 'getBooleanValue',
426
+ 'getStringValue',
427
+ 'getNumberValue',
428
+ 'getObjectValue',
429
+ 'getBooleanValueDetails',
430
+ 'getStringValueDetails',
431
+ 'getNumberValueDetails',
432
+ 'getObjectValueDetails',
433
+ ],
434
+ })
435
+ }
436
+
366
437
  // Version metadata binding
367
438
  if (config.version_metadata) {
368
439
  const binding = config.version_metadata.binding
@@ -37,6 +37,7 @@ export class GenerationManager {
37
37
  readonly cronEnabled: boolean
38
38
  readonly executorFactory: DOExecutorFactory | undefined
39
39
  readonly browserConfig: { wsEndpoint?: string; executablePath?: string; headless?: boolean } | undefined
40
+ readonly baseUrls: { artifacts?: string } | undefined
40
41
  /** @internal Path to the wrangler config file (DO worker threads re-load it). */
41
42
  _configPath: string = ''
42
43
 
@@ -51,6 +52,7 @@ export class GenerationManager {
51
52
  executorFactory?: DOExecutorFactory
52
53
  configPath?: string
53
54
  browserConfig?: { wsEndpoint?: string; executablePath?: string; headless?: boolean }
55
+ baseUrls?: { artifacts?: string }
54
56
  },
55
57
  ) {
56
58
  this.config = config
@@ -62,6 +64,7 @@ export class GenerationManager {
62
64
  this.cronEnabled = options?.cron ?? false
63
65
  this.executorFactory = options?.executorFactory
64
66
  this.browserConfig = options?.browserConfig
67
+ this.baseUrls = options?.baseUrls
65
68
  this._configPath = options?.configPath ?? ''
66
69
  }
67
70
 
@@ -104,7 +107,7 @@ export class GenerationManager {
104
107
  // service bindings, email, browser, containers) live in main — the worker
105
108
  // RPCs into them. Stateless ones duplicate in the thread. Static assets
106
109
  // stay main-side for the auto-serve fallback.
107
- const { env, registry } = buildEnv(this.config, this.baseDir, this.executorFactory, this.browserConfig, this._doNamespaces)
110
+ const { env, registry } = buildEnv(this.config, this.baseDir, this.executorFactory, this.browserConfig, this._doNamespaces, this.baseUrls)
108
111
  wireServiceBindings(registry, {}, env, this.workerRegistry)
109
112
 
110
113
  const executor = new WorkerThreadExecutor({
@@ -113,6 +116,7 @@ export class GenerationManager {
113
116
  baseDir: this.baseDir,
114
117
  workerName: this.workerName,
115
118
  browserConfig: this.browserConfig,
119
+ artifactsBaseUrl: this.baseUrls?.artifacts,
116
120
  mainEnv: env,
117
121
  })
118
122
  let readyInfo: WorkerReadyInfo
package/src/generation.ts CHANGED
@@ -98,17 +98,34 @@ export class Generation {
98
98
  try {
99
99
  const assets = this.registry.staticAssets
100
100
  let response: Response
101
- if (!assets || this.config.assets?.binding) {
101
+ if (!assets) {
102
102
  response = await this.threadExecutor.executeFetch(request)
103
103
  } else {
104
+ // Cloudflare Static Assets are served FIRST for any existing asset,
105
+ // even when an `assets.binding` is configured — the binding only adds
106
+ // programmatic `env.ASSETS.fetch()` access, it does NOT make the worker
107
+ // own routing. The worker runs first ONLY for paths in `run_worker_first`
108
+ // (or when it's `true`). Some framework adapters (e.g. Astro's Cloudflare
109
+ // worker) greedily route-match catch-alls like `/[slug]` and 404 instead
110
+ // of delegating to ASSETS, so honoring assets-first here is required to
111
+ // match workerd — otherwise directory-index assets like `/account/`
112
+ // (served from `/account/index.html`) never reach the asset layer.
104
113
  const workerFirst = shouldRunWorkerFirst(this.config.assets?.run_worker_first, url.pathname)
105
- if (!workerFirst) {
114
+ // Cloudflare only serves static assets for GET/HEAD; every other
115
+ // method goes straight to the worker. WebSocket upgrades are GETs but
116
+ // must reach the worker's upgrade handler, so they're excluded too.
117
+ // Without this gate, assets-first routing would answer e.g.
118
+ // `POST /account/` with `/account/index.html` (200) and silently drop
119
+ // the write, or serve a colliding asset to a WS upgrade.
120
+ const isWebSocketUpgrade = request.headers.get('upgrade')?.toLowerCase() === 'websocket'
121
+ const canServeAssets = (request.method === 'GET' || request.method === 'HEAD') && !isWebSocketUpgrade
122
+ if (!workerFirst && canServeAssets) {
106
123
  const assetResponse = await assets.fetch(request)
107
124
  if (assetResponse.status !== 404) return assetResponse
108
125
  response = await this.threadExecutor.executeFetch(request)
109
126
  } else {
110
127
  response = await this.threadExecutor.executeFetch(request)
111
- if (response.status === 404) {
128
+ if (response.status === 404 && canServeAssets) {
112
129
  // Drop the worker's streaming body so the source pump on the
113
130
  // other side stops — we're discarding this response in favour
114
131
  // of the assets fallback.
package/src/plugin.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { plugin } from 'bun'
2
+ import type { Database } from 'bun:sqlite'
3
+ import { buildAnalyticsEngineSqlResponse, isAnalyticsEngineSqlUrl, isLocalAnalyticsEngineToken } from './bindings/analytics-engine-sql'
2
4
  import type { ImageTransformOptions, OutputOptions } from './bindings/images'
3
5
  import { setupCloudflareGlobals } from './setup-globals'
4
6
  import { getActiveContext } from './tracing/context'
@@ -158,6 +160,41 @@ async function applyCfImageTransform(response: Response, imageOpts: Record<strin
158
160
 
159
161
  const _originalFetch = globalThis.fetch
160
162
  globalThis.fetch = ((input: any, init?: any): Promise<Response> => {
163
+ // Intercept the Cloudflare Analytics Engine SQL API (no Worker binding exists
164
+ // for reads) and serve it from the local SQLite store — same code runs in dev
165
+ // and prod unchanged.
166
+ const aeMethod = String(init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase()
167
+ const aeUrl = typeof input === 'string'
168
+ ? input
169
+ : input instanceof URL
170
+ ? input.href
171
+ : input instanceof Request
172
+ ? input.url
173
+ : undefined
174
+ // Gate on the bearer token: missing or `Bearer local` is served locally; a real
175
+ // token falls through to the actual Cloudflare API (prod).
176
+ const aeAuth = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)).get('authorization')
177
+ if (aeMethod === 'POST' && typeof aeUrl === 'string' && isAnalyticsEngineSqlUrl(aeUrl) && isLocalAnalyticsEngineToken(aeAuth)) {
178
+ // Set per worker-thread by buildThreadEnv / buildDoWorkerEnv before user code
179
+ // runs. Fail loudly rather than fall back to a possibly-wrong data dir, which
180
+ // would return plausible but incorrect numbers.
181
+ const db = (globalThis as { __lopata_db?: Database }).__lopata_db
182
+ if (!db) throw new Error('Analytics Engine SQL API was intercepted before the worker-thread database was wired')
183
+ const aeReq = new Request(input, init)
184
+ if (!getActiveContext()) return aeReq.text().then(sql => buildAnalyticsEngineSqlResponse(db, sql))
185
+ return startSpan({
186
+ name: 'analytics_engine sql',
187
+ kind: 'client',
188
+ attributes: { 'http.method': 'POST', 'http.url': aeUrl },
189
+ }, async () => {
190
+ const sql = (await aeReq.text()).trim()
191
+ if (sql) setSpanAttribute('db.statement', sql)
192
+ const res = buildAnalyticsEngineSqlResponse(db, sql)
193
+ setSpanAttribute('http.status_code', res.status)
194
+ return res
195
+ })
196
+ }
197
+
161
198
  const ctx = getActiveContext()
162
199
  if (ctx) {
163
200
  ctx.fetchStack.current = new Error()
@@ -923,12 +923,28 @@ function nodeStreamToReadable(stream: IncomingMessage): ReadableStream<Uint8Arra
923
923
  })
924
924
  }
925
925
 
926
- async function writeResponse(response: Response, res: ServerResponse): Promise<void> {
926
+ /**
927
+ * Convert Response headers to a node writeHead record. `set-cookie` must go
928
+ * through `getSetCookie()`: Headers iteration yields it once per cookie (and a
929
+ * keyed record would keep only the last one), so a multi-cookie response —
930
+ * e.g. better-auth's session_token + session_data — would silently lose
931
+ * cookies. Exported for tests.
932
+ */
933
+ export function buildNodeHeaders(response: Response): Record<string, string | string[]> {
927
934
  const headerRecord: Record<string, string | string[]> = {}
928
935
  response.headers.forEach((value, key) => {
936
+ if (key.toLowerCase() === 'set-cookie') return
929
937
  headerRecord[key] = value
930
938
  })
931
- res.writeHead(response.status, headerRecord)
939
+ const setCookies = response.headers.getSetCookie()
940
+ if (setCookies.length > 0) {
941
+ headerRecord['set-cookie'] = setCookies
942
+ }
943
+ return headerRecord
944
+ }
945
+
946
+ async function writeResponse(response: Response, res: ServerResponse): Promise<void> {
947
+ res.writeHead(response.status, buildNodeHeaders(response))
932
948
 
933
949
  if (!response.body) {
934
950
  res.end()
@@ -191,6 +191,7 @@ async function initRuntime(init: WorkerInitConfig) {
191
191
  dataDir: init.dataDir,
192
192
  rpc,
193
193
  browserConfig: init.browserConfig,
194
+ artifactsBaseUrl: init.artifactsBaseUrl,
194
195
  envWsBridge,
195
196
  })
196
197
  const { env } = built
@@ -49,6 +49,8 @@ export interface WorkerThreadExecutorOptions {
49
49
  baseDir: string
50
50
  workerName?: string
51
51
  browserConfig?: { wsEndpoint?: string; executablePath?: string; headless?: boolean }
52
+ /** Base URL for the Artifacts git remote (main's `/__artifacts/git` endpoint). */
53
+ artifactsBaseUrl?: string
52
54
  /** Main-thread env holding the stateful binding instances the worker calls into via RPC. */
53
55
  mainEnv: Record<string, unknown>
54
56
  }
@@ -204,6 +206,7 @@ export class WorkerThreadExecutor {
204
206
  dataDir: getDataDir(),
205
207
  workerName: this._initConfig.workerName,
206
208
  browserConfig: this._initConfig.browserConfig,
209
+ artifactsBaseUrl: this._initConfig.artifactsBaseUrl,
207
210
  },
208
211
  })
209
212
  break
@@ -152,6 +152,8 @@ export interface WorkerInitConfig {
152
152
  workerName?: string
153
153
  /** Browser Rendering local dev config (Chrome wsEndpoint or executable path). */
154
154
  browserConfig?: { wsEndpoint?: string; executablePath?: string; headless?: boolean }
155
+ /** Base URL for the Artifacts git remote (main's `/__artifacts/git` endpoint). */
156
+ artifactsBaseUrl?: string
155
157
  }
156
158
 
157
159
  /** Names of the worker handlers we know how to invoke via RPC. */
@@ -10,11 +10,14 @@ import { Database } from 'bun:sqlite'
10
10
  import { existsSync, mkdirSync, readFileSync } from 'node:fs'
11
11
  import path from 'node:path'
12
12
  import { AiBinding } from '../bindings/ai'
13
+ import { AiSearchNamespaceBinding } from '../bindings/ai-search'
13
14
  import { SqliteAnalyticsEngine } from '../bindings/analytics-engine'
15
+ import { ArtifactsBinding } from '../bindings/artifacts'
14
16
  import { BrowserBinding } from '../bindings/browser'
15
17
  import { openD1Database } from '../bindings/d1'
16
18
  import { DurableObjectIdImpl, hashIdFromName, randomUniqueIdHex } from '../bindings/durable-object'
17
19
  import { EmailMessage } from '../bindings/email'
20
+ import { FlagshipBinding } from '../bindings/flagship'
18
21
  import { HyperdriveBinding } from '../bindings/hyperdrive'
19
22
  import { ImagesBinding } from '../bindings/images'
20
23
  import { SqliteKVNamespace } from '../bindings/kv'
@@ -23,7 +26,9 @@ import { FileR2Bucket } from '../bindings/r2'
23
26
  import { makeBindingProxy } from '../bindings/rpc-stub'
24
27
  import { serviceBindingConnectError } from '../bindings/service-binding'
25
28
  import { StaticAssets } from '../bindings/static-assets'
29
+ import { VpcNetworkBinding } from '../bindings/vpc-network'
26
30
  import type { ResponseWithWebSocket } from '../bindings/websocket-pair'
31
+ import { WorkerLoaderBinding } from '../bindings/worker-loader'
27
32
  import { SqliteWorkflowBinding } from '../bindings/workflow'
28
33
  import type { WranglerConfig } from '../config'
29
34
  import { runMigrations } from '../db'
@@ -47,6 +52,8 @@ export interface ThreadEnvOptions {
47
52
  /** Guest-side bridge for WebSockets returned by env-binding fetches. */
48
53
  envWsBridge: WsGuestBridge<WorkerMessage>
49
54
  browserConfig?: { wsEndpoint?: string; executablePath?: string; headless?: boolean }
55
+ /** Base URL for the Artifacts git remote (main's `/__artifacts/git` endpoint). */
56
+ artifactsBaseUrl?: string
50
57
  }
51
58
 
52
59
  export interface ThreadEnvBuilt {
@@ -58,7 +65,7 @@ export interface ThreadEnvBuilt {
58
65
  workflows: { bindingName: string; className: string; binding: SqliteWorkflowBinding }[]
59
66
  }
60
67
 
61
- export function buildThreadEnv({ config, baseDir, dataDir, rpc, envWsBridge, browserConfig }: ThreadEnvOptions): ThreadEnvBuilt {
68
+ export function buildThreadEnv({ config, baseDir, dataDir, rpc, envWsBridge, browserConfig, artifactsBaseUrl }: ThreadEnvOptions): ThreadEnvBuilt {
62
69
  mkdirSync(dataDir, { recursive: true })
63
70
  mkdirSync(path.join(dataDir, 'r2'), { recursive: true })
64
71
  mkdirSync(path.join(dataDir, 'd1'), { recursive: true })
@@ -67,6 +74,10 @@ export function buildThreadEnv({ config, baseDir, dataDir, rpc, envWsBridge, bro
67
74
  db.run('PRAGMA journal_mode=WAL')
68
75
  db.run('PRAGMA busy_timeout=5000')
69
76
  runMigrations(db)
77
+ // Expose this thread's DB handle so the fetch patch (plugin.ts) can serve the
78
+ // intercepted Analytics Engine SQL API from the right data dir in multi-worker setups.
79
+ const threadGlobals = globalThis as { __lopata_db?: Database }
80
+ threadGlobals.__lopata_db = db
70
81
 
71
82
  const env: Record<string, unknown> = {}
72
83
 
@@ -196,6 +207,66 @@ export function buildThreadEnv({ config, baseDir, dataDir, rpc, envWsBridge, bro
196
207
  })
197
208
  }
198
209
 
210
+ // VPC Networks — pass-through fetcher (network_id = Mesh, tunnel_id = tunnel)
211
+ for (const vpc of config.vpc_networks ?? []) {
212
+ const networkId = vpc.network_id ?? vpc.tunnel_id
213
+ if (!networkId) {
214
+ throw new Error(`VPC Network "${vpc.binding}" requires either network_id or tunnel_id`)
215
+ }
216
+ env[vpc.binding] = instrumentBinding(new VpcNetworkBinding({ networkId, bindingName: vpc.binding }), {
217
+ type: 'vpc_network',
218
+ name: vpc.binding,
219
+ methods: ['fetch'],
220
+ })
221
+ }
222
+
223
+ // AI Search namespaces — proxy to the CF AI Search REST API
224
+ for (const ns of config.ai_search_namespaces ?? []) {
225
+ const accountId = typeof env.CLOUDFLARE_ACCOUNT_ID === 'string' ? env.CLOUDFLARE_ACCOUNT_ID : process.env.CLOUDFLARE_ACCOUNT_ID
226
+ const apiToken = typeof env.CLOUDFLARE_API_TOKEN === 'string' ? env.CLOUDFLARE_API_TOKEN : process.env.CLOUDFLARE_API_TOKEN
227
+ env[ns.binding] = instrumentBinding(new AiSearchNamespaceBinding(db, ns.namespace, accountId, apiToken), {
228
+ type: 'ai_search',
229
+ name: ns.binding,
230
+ methods: ['create', 'get', 'list', 'delete', 'search', 'chatCompletions'],
231
+ })
232
+ }
233
+
234
+ // Artifacts — control plane (SQLite + bare git repos under dataDir/artifacts);
235
+ // the git-over-HTTP endpoint is served by main's Bun.serve at /__artifacts/git/*.
236
+ for (const artifacts of config.artifacts ?? []) {
237
+ const remoteBase = (artifactsBaseUrl ?? 'http://localhost:8787/__artifacts/git').replace(/\/$/, '')
238
+ env[artifacts.binding] = instrumentBinding(
239
+ new ArtifactsBinding(db, artifacts.namespace, path.join(dataDir, 'artifacts'), remoteBase),
240
+ { type: 'artifacts', name: artifacts.binding, methods: ['create', 'get', 'list', 'import', 'delete'] },
241
+ )
242
+ }
243
+
244
+ // Worker Loader — dynamic Workers, each its own nested Bun worker thread. Not wrapped
245
+ // in instrumentBinding: load()/get() return live WorkerStub handles synchronously, and
246
+ // the async span wrapper would turn them into Promises (breaking the
247
+ // `loader.get(id).getEntrypoint().fetch()` chaining the Cloudflare API requires).
248
+ for (const loader of config.worker_loaders ?? []) {
249
+ env[loader.binding] = new WorkerLoaderBinding(path.join(dataDir, 'worker-loader'))
250
+ }
251
+
252
+ // Flagship — SQLite-backed feature flags
253
+ if (config.flagship) {
254
+ env[config.flagship.binding] = instrumentBinding(new FlagshipBinding(db, config.flagship.app_id), {
255
+ type: 'flagship',
256
+ name: config.flagship.binding,
257
+ methods: [
258
+ 'getBooleanValue',
259
+ 'getStringValue',
260
+ 'getNumberValue',
261
+ 'getObjectValue',
262
+ 'getBooleanValueDetails',
263
+ 'getStringValueDetails',
264
+ 'getNumberValueDetails',
265
+ 'getObjectValueDetails',
266
+ ],
267
+ })
268
+ }
269
+
199
270
  if (config.version_metadata) {
200
271
  env[config.version_metadata.binding] = {
201
272
  id: 'local-dev',