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.
- package/dist/types/bindings/ai-search.d.ts +44 -0
- package/dist/types/bindings/analytics-engine-sql.d.ts +107 -0
- package/dist/types/bindings/artifacts-git-http.d.ts +19 -0
- package/dist/types/bindings/artifacts.d.ts +108 -0
- package/dist/types/bindings/flagship.d.ts +37 -0
- package/dist/types/bindings/vpc-network.d.ts +22 -0
- package/dist/types/bindings/worker-loader-entry.d.ts +71 -0
- package/dist/types/bindings/worker-loader.d.ts +81 -0
- package/dist/types/config.d.ts +23 -0
- package/dist/types/env.d.ts +3 -1
- package/dist/types/generation-manager.d.ts +6 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/dist/types/vite-plugin/dev-server-plugin.d.ts +8 -0
- package/dist/types/worker-thread/executor.d.ts +2 -0
- package/dist/types/worker-thread/protocol.d.ts +2 -0
- package/dist/types/worker-thread/thread-env.d.ts +3 -1
- package/package.json +1 -1
- package/src/bindings/ai-search.ts +185 -0
- package/src/bindings/analytics-engine-sql.ts +1138 -0
- package/src/bindings/artifacts-git-http.ts +249 -0
- package/src/bindings/artifacts.ts +453 -0
- package/src/bindings/do-worker-env.ts +4 -0
- package/src/bindings/flagship.ts +117 -0
- package/src/bindings/vpc-network.ts +40 -0
- package/src/bindings/worker-loader-entry.ts +143 -0
- package/src/bindings/worker-loader.ts +325 -0
- package/src/cli/dev.ts +25 -3
- package/src/config.ts +20 -0
- package/src/db.ts +54 -0
- package/src/env.ts +71 -0
- package/src/generation-manager.ts +5 -1
- package/src/generation.ts +20 -3
- package/src/plugin.ts +37 -0
- package/src/vite-plugin/dev-server-plugin.ts +18 -2
- package/src/worker-thread/entry.ts +1 -0
- package/src/worker-thread/executor.ts +3 -0
- package/src/worker-thread/protocol.ts +2 -0
- 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 =
|
|
310
|
-
const hostname =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()
|
|
@@ -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',
|