lopata 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/dashboard/{chunk-jrv1mhg9.css → chunk-paesqsyf.css} +33 -17
  2. package/dist/dashboard/{chunk-5j5em7qa.js → chunk-ymq225fp.js} +190 -98
  3. package/dist/dashboard/index.html +1 -1
  4. package/package.json +13 -16
  5. package/src/api/dispatch.ts +2 -0
  6. package/src/api/generate-sql.ts +51 -0
  7. package/src/api/handlers/d1.ts +8 -0
  8. package/src/api/handlers/do.ts +8 -0
  9. package/src/api/handlers/email.ts +1 -1
  10. package/src/api/handlers/queue.ts +1 -1
  11. package/src/api/handlers/warnings.ts +8 -0
  12. package/src/api/index.ts +6 -1
  13. package/src/api/r2.ts +1 -1
  14. package/src/api/types.ts +2 -0
  15. package/src/bindings/browser.ts +0 -3
  16. package/src/bindings/cache.ts +1 -1
  17. package/src/bindings/container.ts +4 -4
  18. package/src/bindings/crypto-extras.ts +1 -1
  19. package/src/bindings/do-executor-inprocess.ts +4 -0
  20. package/src/bindings/do-executor-worker.ts +4 -0
  21. package/src/bindings/do-executor.ts +3 -0
  22. package/src/bindings/durable-object.ts +32 -2
  23. package/src/bindings/html-rewriter.ts +26 -1
  24. package/src/bindings/images.ts +237 -32
  25. package/src/bindings/queue.ts +17 -1
  26. package/src/bindings/scheduled.ts +141 -22
  27. package/src/bindings/service-binding.ts +10 -5
  28. package/src/bindings/static-assets.ts +13 -1
  29. package/src/bindings/workflow.ts +5 -2
  30. package/src/cli/dev.ts +2 -1
  31. package/src/cli.ts +0 -0
  32. package/src/config.ts +17 -3
  33. package/src/dashboard-serve.ts +1 -1
  34. package/src/db.ts +6 -0
  35. package/src/env.ts +40 -2
  36. package/src/execution-context.ts +5 -0
  37. package/src/generation-manager.ts +7 -3
  38. package/src/generation.ts +31 -2
  39. package/src/lopata-config.ts +7 -0
  40. package/src/plugin.ts +77 -4
  41. package/src/request-cf.ts +2 -0
  42. package/src/tracing/store.ts +1 -1
  43. package/src/tsconfig.json +1 -0
  44. package/src/vite-plugin/dev-server-plugin.ts +0 -1
  45. package/src/warnings.ts +30 -0
@@ -0,0 +1,51 @@
1
+ import type { Database } from 'bun:sqlite'
2
+ import type { LopataConfig } from '../lopata-config'
3
+
4
+ export async function generateSqlFromPrompt(db: Database, prompt: string, lopataConfig: LopataConfig | null): Promise<string> {
5
+ const aiConfig = lopataConfig?.ai
6
+ const apiKey = aiConfig?.apiKey ?? process.env.OPENROUTER_API_KEY
7
+ if (!apiKey) throw new Error('OPENROUTER_API_KEY environment variable is not set (or set ai.apiKey in lopata.config.ts)')
8
+ if (!prompt?.trim()) throw new Error('Missing prompt')
9
+
10
+ const model = aiConfig?.model ?? 'anthropic/claude-haiku-4.5'
11
+
12
+ let schema: string
13
+ try {
14
+ const tables = db.query<{ sql: string }, []>(
15
+ "SELECT sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
16
+ ).all()
17
+ schema = tables.map(t => t.sql).join(';\n')
18
+ } finally {
19
+ db.close()
20
+ }
21
+
22
+ const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
23
+ method: 'POST',
24
+ headers: {
25
+ 'Authorization': `Bearer ${apiKey}`,
26
+ 'Content-Type': 'application/json',
27
+ },
28
+ body: JSON.stringify({
29
+ model,
30
+ messages: [
31
+ {
32
+ role: 'system',
33
+ content:
34
+ `You are a SQL assistant. Given the following SQLite database schema, generate a SQL query for the user's request. Return ONLY the raw SQL query, no explanations, no markdown fences.\n\nSchema:\n${schema}`,
35
+ },
36
+ { role: 'user', content: prompt },
37
+ ],
38
+ }),
39
+ })
40
+
41
+ if (!res.ok) {
42
+ const body = await res.text()
43
+ throw new Error(`OpenRouter API error (${res.status}): ${body}`)
44
+ }
45
+
46
+ const data = await res.json() as { choices: { message: { content: string } }[] }
47
+ let sql = data.choices[0]?.message?.content ?? ''
48
+ sql = sql.replace(/^```(?:sql)?\n?/, '').replace(/\n?```$/, '').trim()
49
+
50
+ return sql
51
+ }
@@ -2,6 +2,7 @@ import { Database } from 'bun:sqlite'
2
2
  import { existsSync, readdirSync } from 'node:fs'
3
3
  import { join } from 'node:path'
4
4
  import { getDataDir } from '../../db'
5
+ import { generateSqlFromPrompt } from '../generate-sql'
5
6
  import type { D1Database as D1DatabaseInfo, D1Table, HandlerContext, QueryResult } from '../types'
6
7
  import { getAllConfigs } from '../types'
7
8
 
@@ -60,6 +61,13 @@ export const handlers = {
60
61
  }
61
62
  },
62
63
 
64
+ async 'd1.generateSql'({ dbName, prompt }: { dbName: string; prompt: string }, ctx: HandlerContext): Promise<{ sql: string }> {
65
+ const dbPath = join(getDataDir(), 'd1', `${dbName}.sqlite`)
66
+ if (!existsSync(dbPath)) throw new Error('Database not found')
67
+ const sql = await generateSqlFromPrompt(new Database(dbPath), prompt, ctx.lopataConfig)
68
+ return { sql }
69
+ },
70
+
63
71
  'd1.query'({ dbName, sql }: { dbName: string; sql: string }): QueryResult {
64
72
  if (!sql) throw new Error('Missing sql field')
65
73
 
@@ -2,6 +2,7 @@ import { Database } from 'bun:sqlite'
2
2
  import { existsSync } from 'node:fs'
3
3
  import { join } from 'node:path'
4
4
  import { getDatabase, getDataDir } from '../../db'
5
+ import { generateSqlFromPrompt } from '../generate-sql'
5
6
  import type { D1Table, DoDetail, DoInstance, DoNamespace, HandlerContext, OkResponse, QueryResult } from '../types'
6
7
  import { getAllConfigs, getDoNamespace } from '../types'
7
8
 
@@ -93,6 +94,13 @@ export const handlers = {
93
94
  return { ok: true }
94
95
  },
95
96
 
97
+ async 'do.generateSql'({ ns, id, prompt }: { ns: string; id: string; prompt: string }, ctx: HandlerContext): Promise<{ sql: string }> {
98
+ const dbPath = join(getDataDir(), 'do-sql', ns, `${id}.sqlite`)
99
+ if (!existsSync(dbPath)) throw new Error('SQL database not found for this instance')
100
+ const sql = await generateSqlFromPrompt(new Database(dbPath), prompt, ctx.lopataConfig)
101
+ return { sql }
102
+ },
103
+
96
104
  'do.sqlQuery'({ ns, id, sql }: { ns: string; id: string; sql: string }): QueryResult {
97
105
  if (!sql) throw new Error('Missing sql field')
98
106
 
@@ -27,7 +27,7 @@ export const handlers = {
27
27
 
28
28
  let rawStr: string
29
29
  try {
30
- rawStr = new TextDecoder().decode(row.raw as BufferSource)
30
+ rawStr = new TextDecoder().decode(row.raw as Uint8Array)
31
31
  } catch {
32
32
  rawStr = '<binary content>'
33
33
  }
@@ -42,7 +42,7 @@ export const handlers = {
42
42
  return rows.map(row => {
43
43
  let bodyStr: string
44
44
  try {
45
- bodyStr = new TextDecoder().decode(row.body as BufferSource)
45
+ bodyStr = new TextDecoder().decode(row.body as Uint8Array)
46
46
  } catch {
47
47
  bodyStr = `<binary>`
48
48
  }
@@ -0,0 +1,8 @@
1
+ import type { OptionalDep } from '../../warnings'
2
+ import { getOptionalDeps } from '../../warnings'
3
+
4
+ export const handlers = {
5
+ 'warnings.optionalDeps'(_input: {}): OptionalDep[] {
6
+ return getOptionalDeps()
7
+ },
8
+ }
package/src/api/index.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import type { WranglerConfig } from '../config'
2
2
  import type { GenerationManager } from '../generation-manager'
3
+ import type { LopataConfig } from '../lopata-config'
3
4
  import type { WorkerRegistry } from '../worker-registry'
4
5
  import { handlePreflight, withCors } from './cors'
5
6
  import { dispatch } from './dispatch'
6
7
  import { handleR2Download, handleR2Upload } from './r2'
7
8
  import type { HandlerContext } from './types'
8
9
 
9
- const ctx: HandlerContext = { config: null, manager: null, registry: null }
10
+ const ctx: HandlerContext = { config: null, manager: null, registry: null, lopataConfig: null }
10
11
 
11
12
  export function setDashboardConfig(config: WranglerConfig): void {
12
13
  ctx.config = config
@@ -20,6 +21,10 @@ export function setWorkerRegistry(registry: WorkerRegistry): void {
20
21
  ctx.registry = registry
21
22
  }
22
23
 
24
+ export function setLopataConfig(config: LopataConfig): void {
25
+ ctx.lopataConfig = config
26
+ }
27
+
23
28
  export function handleApiRequest(request: Request): Response | Promise<Response> {
24
29
  const url = new URL(request.url)
25
30
 
package/src/api/r2.ts CHANGED
@@ -53,7 +53,7 @@ export function handleR2Download(url: URL): Response {
53
53
  }
54
54
 
55
55
  const filename = key.split('/').pop() ?? key
56
- const response = new Response(file as unknown as BodyInit, {
56
+ const response = new Response(file as unknown as Bun.BodyInit, {
57
57
  headers: {
58
58
  'Content-Disposition': `attachment; filename="${filename}"`,
59
59
  'Content-Type': file.type || 'application/octet-stream',
package/src/api/types.ts CHANGED
@@ -364,12 +364,14 @@ export type { SpanData, SpanEventData, TraceDetail, TraceEvent, TraceSummary } f
364
364
 
365
365
  import type { WranglerConfig } from '../config'
366
366
  import type { GenerationManager } from '../generation-manager'
367
+ import type { LopataConfig } from '../lopata-config'
367
368
  import type { WorkerRegistry } from '../worker-registry'
368
369
 
369
370
  export interface HandlerContext {
370
371
  config: WranglerConfig | null
371
372
  manager: GenerationManager | null
372
373
  registry: WorkerRegistry | null
374
+ lopataConfig: LopataConfig | null
373
375
  }
374
376
 
375
377
  /** Collect configs from all workers (registry) or fall back to single config. */
@@ -26,12 +26,10 @@ export class BrowserBinding {
26
26
  /** Launch a new browser and return a puppeteer Browser instance. */
27
27
  async launch(opts?: { keep_alive?: number }): Promise<any> {
28
28
  if (this.config.wsEndpoint) {
29
- // @ts-expect-error — puppeteer-core is an optional dependency
30
29
  const puppeteer = await import('puppeteer-core')
31
30
  this._browser = await puppeteer.default.connect({ browserWSEndpoint: this.config.wsEndpoint })
32
31
  return this._browser
33
32
  }
34
- // @ts-expect-error — puppeteer is an optional dependency
35
33
  const puppeteer = await import('puppeteer')
36
34
  this._browser = await puppeteer.default.launch({
37
35
  headless: this.config.headless ?? true,
@@ -43,7 +41,6 @@ export class BrowserBinding {
43
41
  /** Connect to an existing browser session by sessionId. */
44
42
  async connect(sessionId: string): Promise<any> {
45
43
  if (this.config.wsEndpoint) {
46
- // @ts-expect-error — puppeteer-core is an optional dependency
47
44
  const puppeteer = await import('puppeteer-core')
48
45
  return puppeteer.default.connect({ browserWSEndpoint: this.config.wsEndpoint })
49
46
  }
@@ -95,7 +95,7 @@ export class SqliteCache {
95
95
 
96
96
  const headers = new Headers(JSON.parse(row.headers))
97
97
  headers.set('cf-cache-status', 'HIT')
98
- return new Response(row.body as unknown as BodyInit, { status: row.status, headers })
98
+ return new Response(row.body as unknown as Bun.BodyInit, { status: row.status, headers })
99
99
  }
100
100
 
101
101
  async put(request: Request | string, response: Response): Promise<void> {
@@ -24,7 +24,7 @@ export interface ContainerConfig {
24
24
  }
25
25
 
26
26
  interface TcpPort {
27
- fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>
27
+ fetch(input: Request | string | URL, init?: RequestInit): Promise<Response>
28
28
  connect(): never
29
29
  }
30
30
 
@@ -215,7 +215,7 @@ export class ContainerRuntime {
215
215
  /**
216
216
  * Forward an HTTP request to the container.
217
217
  */
218
- async fetch(input: RequestInfo | URL, init?: RequestInit, port?: number): Promise<Response> {
218
+ async fetch(input: Request | string | URL, init?: RequestInit, port?: number): Promise<Response> {
219
219
  const targetPort = port ?? this.defaultPort
220
220
  const hostPort = this._hostPorts.get(targetPort)
221
221
  if (!hostPort) {
@@ -429,7 +429,7 @@ export class ContainerContext {
429
429
  getTcpPort(port: number): TcpPort {
430
430
  const runtime = this._runtime
431
431
  return {
432
- fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
432
+ fetch(input: Request | string | URL, init?: RequestInit): Promise<Response> {
433
433
  return runtime.fetch(input, init, port)
434
434
  },
435
435
  connect(): never {
@@ -521,7 +521,7 @@ export class ContainerBase extends DurableObjectBase {
521
521
  requestOrUrl: Request | string | URL,
522
522
  portOrInit?: number | RequestInit,
523
523
  portParam?: number,
524
- ): { input: RequestInfo | URL; init?: RequestInit; port?: number } {
524
+ ): { input: Request | string | URL; init?: RequestInit; port?: number } {
525
525
  if (requestOrUrl instanceof Request) {
526
526
  // containerFetch(request, port?)
527
527
  const port = typeof portOrInit === 'number' ? portOrInit : portParam
@@ -26,7 +26,7 @@ export function cfTimingSafeEqual(a: ArrayBuffer | ArrayBufferView, b: ArrayBuff
26
26
  export class DigestStream extends WritableStream<ArrayBuffer | ArrayBufferView> {
27
27
  readonly digest: Promise<ArrayBuffer>
28
28
 
29
- constructor(algorithm: AlgorithmIdentifier) {
29
+ constructor(algorithm: string | { name: string }) {
30
30
  const algo = typeof algorithm === 'string' ? algorithm : algorithm.name
31
31
 
32
32
  // Map CF algorithm names to Bun.CryptoHasher names
@@ -116,6 +116,10 @@ export class InProcessExecutor implements DOExecutor {
116
116
  return this._state.getWebSockets().length
117
117
  }
118
118
 
119
+ isAborted(): boolean {
120
+ return this._state._isAborted()
121
+ }
122
+
119
123
  async dispose(): Promise<void> {
120
124
  if (this._containerRuntime) {
121
125
  await this._containerRuntime.cleanup()
@@ -333,6 +333,10 @@ export class WorkerExecutor implements DOExecutor {
333
333
  return this._wsCount
334
334
  }
335
335
 
336
+ isAborted(): boolean {
337
+ return false // Worker-thread DOs don't support abort
338
+ }
339
+
336
340
  async dispose(): Promise<void> {
337
341
  this._disposed = true
338
342
  if (this._worker) {
@@ -36,6 +36,9 @@ export interface DOExecutor {
36
36
  /** Count of accepted WebSockets */
37
37
  activeWebSocketCount(): number
38
38
 
39
+ /** Whether the instance has been aborted */
40
+ isAborted(): boolean
41
+
39
42
  /** Kill the instance */
40
43
  dispose(): Promise<void>
41
44
  }
@@ -499,6 +499,8 @@ export class DurableObjectStateImpl {
499
499
  private _instanceResolver: (() => DurableObjectBase | null) | null = null
500
500
  private _lockTail: Promise<void> = Promise.resolve()
501
501
  private _activeRequests = 0
502
+ private _aborted = false
503
+ private _abortReason: string | undefined
502
504
 
503
505
  constructor(id: DurableObjectIdImpl, db: Database, namespace: string, dataDir?: string, limits?: DurableObjectLimits) {
504
506
  this.id = id
@@ -536,6 +538,9 @@ export class DurableObjectStateImpl {
536
538
 
537
539
  /** @internal Acquire the serial-execution lock. Caller awaits this, then runs work in its own async stack. */
538
540
  async _lock(): Promise<() => void> {
541
+ if (this._aborted) {
542
+ throw new Error(this._abortReason ?? 'Durable Object has been aborted')
543
+ }
539
544
  let unlockNext: () => void
540
545
  const nextTail = new Promise<void>(r => {
541
546
  unlockNext = r
@@ -543,6 +548,10 @@ export class DurableObjectStateImpl {
543
548
  const ready = this._lockTail
544
549
  this._lockTail = nextTail
545
550
  await ready
551
+ if (this._aborted) {
552
+ unlockNext!()
553
+ throw new Error(this._abortReason ?? 'Durable Object has been aborted')
554
+ }
546
555
  this._activeRequests++
547
556
  return () => {
548
557
  this._activeRequests--
@@ -550,6 +559,20 @@ export class DurableObjectStateImpl {
550
559
  }
551
560
  }
552
561
 
562
+ /**
563
+ * Abort the Durable Object instance. Rejects all queued requests and
564
+ * marks the instance for eviction so it will be re-created fresh on next access.
565
+ */
566
+ abort(reason?: string): void {
567
+ this._aborted = true
568
+ this._abortReason = reason ?? 'Durable Object reset by abort()'
569
+ }
570
+
571
+ /** @internal Check if this state has been aborted */
572
+ _isAborted(): boolean {
573
+ return this._aborted
574
+ }
575
+
553
576
  /** @internal Set the instance resolver for WebSocket handler delegation */
554
577
  _setInstanceResolver(resolver: () => DurableObjectBase | null) {
555
578
  this._instanceResolver = resolver
@@ -855,9 +878,16 @@ export class DurableObjectNamespaceImpl {
855
878
  private _evictIdle() {
856
879
  const now = Date.now()
857
880
  for (const [idStr, lastActivity] of this._lastActivity) {
858
- if (now - lastActivity < this._evictionTimeoutMs) continue
859
881
  const executor = this._executors.get(idStr)
860
882
  if (!executor) continue
883
+ // Evict aborted instances immediately (once they have no active requests)
884
+ if (executor.isAborted() && !executor.isActive()) {
885
+ executor.dispose().catch(() => {})
886
+ this._executors.delete(idStr)
887
+ this._lastActivity.delete(idStr)
888
+ continue
889
+ }
890
+ if (now - lastActivity < this._evictionTimeoutMs) continue
861
891
  if (executor.isBlocked()) continue
862
892
  if (executor.isActive()) continue
863
893
  if (executor.activeWebSocketCount() > 0) continue
@@ -967,7 +997,7 @@ export class DurableObjectNamespaceImpl {
967
997
 
968
998
  // stub.fetch() — calls the DO's fetch() handler
969
999
  if (prop === 'fetch') {
970
- return async (input: RequestInfo | URL, init?: RequestInit) => {
1000
+ return async (input: Request | string | URL, init?: RequestInit) => {
971
1001
  const executor = self._getOrCreateExecutor(idStr, id)!
972
1002
  self._lastActivity.set(idStr, Date.now())
973
1003
  const request = input instanceof Request ? input : new Request(input instanceof URL ? input.href : input, init)
@@ -1,4 +1,21 @@
1
- import { type DocumentHandlers, type ElementHandlers, HTMLRewriter as RawHTMLRewriter } from 'html-rewriter-wasm'
1
+ import type { DocumentHandlers, ElementHandlers } from 'html-rewriter-wasm'
2
+
3
+ type RawHTMLRewriterType = new(sink: (chunk: Uint8Array) => void) => {
4
+ on(selector: string, handler: ElementHandlers): void
5
+ onDocument(handler: DocumentHandlers): void
6
+ write(chunk: Uint8Array): Promise<void>
7
+ end(): Promise<void>
8
+ free(): void
9
+ }
10
+
11
+ let RawHTMLRewriter: RawHTMLRewriterType | null = null
12
+ try {
13
+ ;({ HTMLRewriter: RawHTMLRewriter } = await import('html-rewriter-wasm'))
14
+ } catch {
15
+ // html-rewriter-wasm not installed — passthrough mode
16
+ }
17
+
18
+ let _warned = false
2
19
 
3
20
  /**
4
21
  * Cloudflare-compatible HTMLRewriter that wraps html-rewriter-wasm.
@@ -28,6 +45,14 @@ export class HTMLRewriter {
28
45
  })
29
46
  }
30
47
 
48
+ if (!RawHTMLRewriter) {
49
+ if (!_warned) {
50
+ console.warn('[lopata] html-rewriter-wasm is not installed — HTMLRewriter is a passthrough. Install it: bun add html-rewriter-wasm')
51
+ _warned = true
52
+ }
53
+ return response
54
+ }
55
+
31
56
  const elementHandlers = this.elementHandlers
32
57
  const documentHandlers = this.documentHandlers
33
58