lopata 0.20.0 → 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 (34) hide show
  1. package/dist/types/bindings/ai-search.d.ts +44 -0
  2. package/dist/types/bindings/artifacts-git-http.d.ts +19 -0
  3. package/dist/types/bindings/artifacts.d.ts +108 -0
  4. package/dist/types/bindings/flagship.d.ts +37 -0
  5. package/dist/types/bindings/vpc-network.d.ts +22 -0
  6. package/dist/types/bindings/worker-loader-entry.d.ts +71 -0
  7. package/dist/types/bindings/worker-loader.d.ts +81 -0
  8. package/dist/types/config.d.ts +23 -0
  9. package/dist/types/env.d.ts +3 -1
  10. package/dist/types/generation-manager.d.ts +6 -0
  11. package/dist/types/tsconfig.tsbuildinfo +1 -1
  12. package/dist/types/vite-plugin/dev-server-plugin.d.ts +8 -0
  13. package/dist/types/worker-thread/executor.d.ts +2 -0
  14. package/dist/types/worker-thread/protocol.d.ts +2 -0
  15. package/dist/types/worker-thread/thread-env.d.ts +3 -1
  16. package/package.json +1 -1
  17. package/src/bindings/ai-search.ts +185 -0
  18. package/src/bindings/artifacts-git-http.ts +249 -0
  19. package/src/bindings/artifacts.ts +453 -0
  20. package/src/bindings/flagship.ts +117 -0
  21. package/src/bindings/vpc-network.ts +40 -0
  22. package/src/bindings/worker-loader-entry.ts +143 -0
  23. package/src/bindings/worker-loader.ts +325 -0
  24. package/src/cli/dev.ts +25 -3
  25. package/src/config.ts +20 -0
  26. package/src/db.ts +54 -0
  27. package/src/env.ts +71 -0
  28. package/src/generation-manager.ts +5 -1
  29. package/src/generation.ts +20 -3
  30. package/src/vite-plugin/dev-server-plugin.ts +18 -2
  31. package/src/worker-thread/entry.ts +1 -0
  32. package/src/worker-thread/executor.ts +3 -0
  33. package/src/worker-thread/protocol.ts +2 -0
  34. package/src/worker-thread/thread-env.ts +68 -1
@@ -28,4 +28,12 @@ interface DevServerPluginOptions {
28
28
  * via link:), so dynamic imports here run through Bun's native loader.
29
29
  */
30
30
  export declare function devServerPlugin(options: DevServerPluginOptions): Plugin;
31
+ /**
32
+ * Convert Response headers to a node writeHead record. `set-cookie` must go
33
+ * through `getSetCookie()`: Headers iteration yields it once per cookie (and a
34
+ * keyed record would keep only the last one), so a multi-cookie response —
35
+ * e.g. better-auth's session_token + session_data — would silently lose
36
+ * cookies. Exported for tests.
37
+ */
38
+ export declare function buildNodeHeaders(response: Response): Record<string, string | string[]>;
31
39
  export {};
@@ -24,6 +24,8 @@ export interface WorkerThreadExecutorOptions {
24
24
  executablePath?: string;
25
25
  headless?: boolean;
26
26
  };
27
+ /** Base URL for the Artifacts git remote (main's `/__artifacts/git` endpoint). */
28
+ artifactsBaseUrl?: string;
27
29
  /** Main-thread env holding the stateful binding instances the worker calls into via RPC. */
28
30
  mainEnv: Record<string, unknown>;
29
31
  }
@@ -92,6 +92,8 @@ export interface WorkerInitConfig {
92
92
  executablePath?: string;
93
93
  headless?: boolean;
94
94
  };
95
+ /** Base URL for the Artifacts git remote (main's `/__artifacts/git` endpoint). */
96
+ artifactsBaseUrl?: string;
95
97
  }
96
98
  /** Names of the worker handlers we know how to invoke via RPC. */
97
99
  export type WorkerHandlerName = 'fetch' | 'scheduled' | 'email' | 'queue';
@@ -26,6 +26,8 @@ export interface ThreadEnvOptions {
26
26
  executablePath?: string;
27
27
  headless?: boolean;
28
28
  };
29
+ /** Base URL for the Artifacts git remote (main's `/__artifacts/git` endpoint). */
30
+ artifactsBaseUrl?: string;
29
31
  }
30
32
  export interface ThreadEnvBuilt {
31
33
  env: Record<string, unknown>;
@@ -39,4 +41,4 @@ export interface ThreadEnvBuilt {
39
41
  binding: SqliteWorkflowBinding;
40
42
  }[];
41
43
  }
42
- export declare function buildThreadEnv({ config, baseDir, dataDir, rpc, envWsBridge, browserConfig }: ThreadEnvOptions): ThreadEnvBuilt;
44
+ export declare function buildThreadEnv({ config, baseDir, dataDir, rpc, envWsBridge, browserConfig, artifactsBaseUrl }: ThreadEnvOptions): ThreadEnvBuilt;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lopata",
3
- "version": "0.20.0",
3
+ "version": "0.20.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Local implementation of the Cloudflare AI Search namespace binding
3
+ * (`ai_search_namespaces`).
4
+ *
5
+ * Running vector search + RAG locally is impractical, so this binding
6
+ * proxies to the real Cloudflare AI Search REST API — same strategy as
7
+ * `AiBinding`. Every proxied call is logged to SQLite for the dashboard.
8
+ *
9
+ * Requires `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` in `.dev.vars`
10
+ * or the environment.
11
+ */
12
+ import { randomUUIDv7 } from 'bun'
13
+ import type { Database } from 'bun:sqlite'
14
+
15
+ const MAX_LOG_SIZE = 1024
16
+
17
+ function truncate(value: unknown): string {
18
+ const str = typeof value === 'string' ? value : JSON.stringify(value)
19
+ if (!str) return ''
20
+ return str.length > MAX_LOG_SIZE ? str.slice(0, MAX_LOG_SIZE) + '…' : str
21
+ }
22
+
23
+ interface CreateInstanceOptions {
24
+ id: string
25
+ type?: string
26
+ source?: unknown
27
+ }
28
+
29
+ interface SearchOptions {
30
+ messages: { role: string; content: string }[]
31
+ ai_search_options?: {
32
+ instance_ids?: string[]
33
+ [key: string]: unknown
34
+ }
35
+ stream?: boolean
36
+ [key: string]: unknown
37
+ }
38
+
39
+ export class AiSearchInstance {
40
+ private readonly binding: AiSearchNamespaceBinding
41
+ readonly id: string
42
+ readonly metadata: Record<string, unknown>
43
+
44
+ constructor(binding: AiSearchNamespaceBinding, id: string, metadata: Record<string, unknown> = {}) {
45
+ this.binding = binding
46
+ this.id = id
47
+ this.metadata = metadata
48
+ }
49
+
50
+ async search(options: SearchOptions): Promise<unknown> {
51
+ return this.binding._proxyInstance('search', this.id, options)
52
+ }
53
+
54
+ async chatCompletions(options: SearchOptions): Promise<unknown> {
55
+ return this.binding._proxyInstance('chatCompletions', this.id, options)
56
+ }
57
+ }
58
+
59
+ export class AiSearchNamespaceBinding {
60
+ private readonly db: Database
61
+ private readonly namespace: string
62
+ private readonly accountId?: string
63
+ private readonly apiToken?: string
64
+
65
+ constructor(db: Database, namespace: string, accountId?: string, apiToken?: string) {
66
+ this.db = db
67
+ this.namespace = namespace
68
+ this.accountId = accountId
69
+ this.apiToken = apiToken
70
+ }
71
+
72
+ private ensureCredentials(): { accountId: string; apiToken: string } {
73
+ if (!this.accountId || !this.apiToken) {
74
+ throw new Error(
75
+ 'AI Search requires CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN in .dev.vars',
76
+ )
77
+ }
78
+ return { accountId: this.accountId, apiToken: this.apiToken }
79
+ }
80
+
81
+ async create(opts: CreateInstanceOptions): Promise<AiSearchInstance> {
82
+ const body = await this.proxy('POST', '/ai-search/instances', opts, 'create')
83
+ const result = (body as { result?: Record<string, unknown> }).result ?? {}
84
+ return new AiSearchInstance(this, opts.id, result)
85
+ }
86
+
87
+ async get(id: string): Promise<AiSearchInstance> {
88
+ const body = await this.proxy('GET', `/ai-search/instances/${encodeURIComponent(id)}`, undefined, 'get')
89
+ const result = (body as { result?: Record<string, unknown> }).result ?? {}
90
+ return new AiSearchInstance(this, id, result)
91
+ }
92
+
93
+ async list(opts?: Record<string, string>): Promise<unknown> {
94
+ const query = opts ? '?' + new URLSearchParams(opts).toString() : ''
95
+ return this.proxy('GET', `/ai-search/instances${query}`, undefined, 'list')
96
+ }
97
+
98
+ async delete(id: string): Promise<boolean> {
99
+ await this.proxy('DELETE', `/ai-search/instances/${encodeURIComponent(id)}`, undefined, 'delete')
100
+ return true
101
+ }
102
+
103
+ async search(options: SearchOptions): Promise<unknown> {
104
+ return this.proxy('POST', `/ai-search/namespaces/${encodeURIComponent(this.namespace)}/search`, options, 'search')
105
+ }
106
+
107
+ async chatCompletions(options: SearchOptions): Promise<unknown> {
108
+ return this.proxy(
109
+ 'POST',
110
+ `/ai-search/namespaces/${encodeURIComponent(this.namespace)}/chat/completions`,
111
+ options,
112
+ 'chatCompletions',
113
+ )
114
+ }
115
+
116
+ /** @internal — called by AiSearchInstance */
117
+ async _proxyInstance(method: 'search' | 'chatCompletions', instanceId: string, options: SearchOptions): Promise<unknown> {
118
+ const path = method === 'search'
119
+ ? `/ai-search/instances/${encodeURIComponent(instanceId)}/search`
120
+ : `/ai-search/instances/${encodeURIComponent(instanceId)}/chat/completions`
121
+ return this.proxy('POST', path, options, `instance.${method}`)
122
+ }
123
+
124
+ private async proxy(
125
+ httpMethod: string,
126
+ apiPath: string,
127
+ body: unknown,
128
+ operation: string,
129
+ ): Promise<unknown> {
130
+ const { accountId, apiToken } = this.ensureCredentials()
131
+ const id = randomUUIDv7()
132
+ const start = Date.now()
133
+ let status = 'ok'
134
+ let error: string | undefined
135
+ let outputSummary = ''
136
+
137
+ try {
138
+ const url = `https://api.cloudflare.com/client/v4/accounts/${accountId}${apiPath}`
139
+ const response = await fetch(url, {
140
+ method: httpMethod,
141
+ headers: {
142
+ Authorization: `Bearer ${apiToken}`,
143
+ 'Content-Type': 'application/json',
144
+ },
145
+ body: body === undefined ? undefined : JSON.stringify(body),
146
+ })
147
+
148
+ if (!response.ok) {
149
+ const text = await response.text()
150
+ status = 'error'
151
+ error = `HTTP ${response.status}: ${text}`
152
+ throw new Error(error)
153
+ }
154
+
155
+ if ((body as SearchOptions | undefined)?.stream) {
156
+ outputSummary = '<streaming>'
157
+ return response.body
158
+ }
159
+
160
+ const ct = response.headers.get('content-type') ?? ''
161
+ if (ct.includes('application/json')) {
162
+ const json = await response.json()
163
+ outputSummary = truncate(json)
164
+ return json
165
+ }
166
+ const text = await response.text()
167
+ outputSummary = truncate(text)
168
+ return text
169
+ } catch (err) {
170
+ if (status !== 'error') {
171
+ status = 'error'
172
+ error = err instanceof Error ? err.message : String(err)
173
+ }
174
+ throw err
175
+ } finally {
176
+ const duration = Date.now() - start
177
+ this.db
178
+ .prepare(
179
+ `INSERT INTO ai_search_requests (id, namespace, operation, input_summary, output_summary, duration_ms, status, error, created_at)
180
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
181
+ )
182
+ .run(id, this.namespace, operation, truncate(body), outputSummary, duration, status, error ?? null, start)
183
+ }
184
+ }
185
+ }
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Git HTTP protocol handler for the Artifacts binding.
3
+ *
4
+ * Proxies `/__artifacts/git/<repo-id>.git/<path>` requests to the `git http-backend`
5
+ * CGI binary. Authentication is via HTTP Basic (username ignored, password = token
6
+ * from `artifacts_tokens`). Read operations (`git-upload-pack`) accept any non-revoked
7
+ * token; write operations (`git-receive-pack`) require a `write`-scoped token.
8
+ *
9
+ * Repos marked read-only (`read_only = 1`) reject write requests regardless of scope.
10
+ */
11
+ import type { Database } from 'bun:sqlite'
12
+ import { existsSync } from 'node:fs'
13
+ import { join } from 'node:path'
14
+
15
+ interface RepoRow {
16
+ id: string
17
+ read_only: number
18
+ }
19
+
20
+ interface TokenRow {
21
+ scope: string
22
+ expires_at: number | null
23
+ revoked_at: number | null
24
+ }
25
+
26
+ const GIT_PATH_RE = /^\/__artifacts\/git\/([^/]+)\.git(\/.*)?$/
27
+
28
+ export interface ArtifactsGitHandlerOptions {
29
+ db: Database
30
+ artifactsDir: string
31
+ gitBinary?: string
32
+ /** When true (tests), skip token validation. */
33
+ skipAuth?: boolean
34
+ }
35
+
36
+ export async function handleArtifactsGitRequest(
37
+ request: Request,
38
+ opts: ArtifactsGitHandlerOptions,
39
+ ): Promise<Response | null> {
40
+ const url = new URL(request.url)
41
+ const match = url.pathname.match(GIT_PATH_RE)
42
+ if (!match) return null
43
+
44
+ const repoId = match[1]!
45
+ const subPath = match[2] ?? '/'
46
+ const repo = opts.db
47
+ .query<RepoRow, [string]>('SELECT id, read_only FROM artifacts_repos WHERE id = ?')
48
+ .get(repoId)
49
+ if (!repo) {
50
+ return new Response('Not found', { status: 404 })
51
+ }
52
+
53
+ const repoDir = join(opts.artifactsDir, `${repoId}.git`)
54
+ if (!existsSync(repoDir)) {
55
+ return new Response('Repo directory missing', { status: 500 })
56
+ }
57
+
58
+ // Decide if this is a write request
59
+ const isWrite = isWriteRequest(request.method, subPath, url.searchParams.get('service'))
60
+ if (isWrite && repo.read_only) {
61
+ return new Response('Repo is read-only', { status: 403 })
62
+ }
63
+
64
+ if (!opts.skipAuth) {
65
+ const authResult = authenticate(request, opts.db, repoId, isWrite)
66
+ if (!authResult.ok) {
67
+ return new Response(authResult.message, {
68
+ status: 401,
69
+ headers: {
70
+ 'WWW-Authenticate': 'Basic realm="Artifacts"',
71
+ },
72
+ })
73
+ }
74
+ }
75
+
76
+ return proxyToGitHttpBackend(request, subPath, repoDir, opts.gitBinary ?? 'git')
77
+ }
78
+
79
+ function isWriteRequest(method: string, subPath: string, service: string | null): boolean {
80
+ if (method === 'POST' && subPath === '/git-receive-pack') return true
81
+ if (method === 'GET' && subPath === '/info/refs' && service === 'git-receive-pack') return true
82
+ return false
83
+ }
84
+
85
+ interface AuthResult {
86
+ ok: boolean
87
+ message: string
88
+ }
89
+
90
+ function authenticate(request: Request, db: Database, repoId: string, writeNeeded: boolean): AuthResult {
91
+ const authHeader = request.headers.get('authorization')
92
+ if (!authHeader) {
93
+ return { ok: false, message: 'Authentication required' }
94
+ }
95
+ let token: string | null = null
96
+ if (authHeader.startsWith('Bearer ')) {
97
+ token = authHeader.slice(7).trim()
98
+ } else if (authHeader.startsWith('Basic ')) {
99
+ try {
100
+ const decoded = atob(authHeader.slice(6).trim())
101
+ const colonIdx = decoded.indexOf(':')
102
+ token = colonIdx === -1 ? decoded : decoded.slice(colonIdx + 1)
103
+ } catch {
104
+ return { ok: false, message: 'Invalid Basic auth encoding' }
105
+ }
106
+ }
107
+ if (!token) {
108
+ return { ok: false, message: 'Missing token' }
109
+ }
110
+ const row = db
111
+ .query<TokenRow, [string, string]>(
112
+ 'SELECT scope, expires_at, revoked_at FROM artifacts_tokens WHERE repo_id = ? AND plaintext = ?',
113
+ )
114
+ .get(repoId, token)
115
+ if (!row) {
116
+ return { ok: false, message: 'Invalid token' }
117
+ }
118
+ if (row.revoked_at != null) {
119
+ return { ok: false, message: 'Token revoked' }
120
+ }
121
+ if (row.expires_at != null && row.expires_at < Date.now()) {
122
+ return { ok: false, message: 'Token expired' }
123
+ }
124
+ if (writeNeeded && row.scope !== 'write') {
125
+ return { ok: false, message: 'Token lacks write scope' }
126
+ }
127
+ return { ok: true, message: 'ok' }
128
+ }
129
+
130
+ async function proxyToGitHttpBackend(
131
+ request: Request,
132
+ subPath: string,
133
+ repoDir: string,
134
+ gitBinary: string,
135
+ ): Promise<Response> {
136
+ const url = new URL(request.url)
137
+
138
+ // CGI env variables required by git http-backend
139
+ const env: Record<string, string> = {
140
+ // Point GIT_PROJECT_ROOT at the repo parent so PATH_INFO can be "/<id>.git/..."
141
+ // But easier: point directly and use PATH_TRANSLATED + GIT_HTTP_EXPORT_ALL.
142
+ // We use PATH_TRANSLATED which tells http-backend exactly which repo dir to serve.
143
+ PATH_TRANSLATED: `${repoDir}${subPath}`,
144
+ GIT_HTTP_EXPORT_ALL: '1',
145
+ REQUEST_METHOD: request.method,
146
+ QUERY_STRING: url.search.startsWith('?') ? url.search.slice(1) : url.search,
147
+ CONTENT_TYPE: request.headers.get('content-type') ?? '',
148
+ REMOTE_USER: 'artifacts',
149
+ REMOTE_ADDR: '127.0.0.1',
150
+ // Let git detect encoding
151
+ HTTP_CONTENT_ENCODING: request.headers.get('content-encoding') ?? '',
152
+ HTTP_USER_AGENT: request.headers.get('user-agent') ?? 'lopata-artifacts',
153
+ }
154
+
155
+ const proc = Bun.spawn([gitBinary, 'http-backend'], {
156
+ env,
157
+ stdin: 'pipe',
158
+ stdout: 'pipe',
159
+ stderr: 'pipe',
160
+ })
161
+
162
+ // Pipe request body into the CGI's stdin (if any)
163
+ if (request.body) {
164
+ request.body.pipeTo(
165
+ new WritableStream({
166
+ async write(chunk) {
167
+ proc.stdin.write(chunk)
168
+ },
169
+ close() {
170
+ proc.stdin.end()
171
+ },
172
+ abort() {
173
+ proc.stdin.end()
174
+ },
175
+ }),
176
+ ).catch(() => {})
177
+ } else {
178
+ proc.stdin.end()
179
+ }
180
+
181
+ // Read full stdout (git http-backend writes CGI-formatted response: headers + body)
182
+ const stdoutBytes = new Uint8Array(await new Response(proc.stdout).arrayBuffer())
183
+ const exitCode = await proc.exited
184
+ if (exitCode !== 0) {
185
+ const stderr = await new Response(proc.stderr).text()
186
+ return new Response(`git http-backend failed (${exitCode}): ${stderr}`, { status: 500 })
187
+ }
188
+
189
+ return parseCgiResponse(stdoutBytes)
190
+ }
191
+
192
+ function parseCgiResponse(raw: Uint8Array): Response {
193
+ // Find header/body separator: \r\n\r\n or \n\n
194
+ let headerEnd = indexOfDouble(raw, 0x0d, 0x0a) // \r\n\r\n
195
+ let separatorLen = 4
196
+ if (headerEnd === -1) {
197
+ headerEnd = indexOfDouble(raw, 0x0a, 0x0a) // \n\n
198
+ separatorLen = 2
199
+ }
200
+
201
+ let headerBytes: Uint8Array
202
+ let body: Uint8Array
203
+ if (headerEnd === -1) {
204
+ headerBytes = new Uint8Array(0)
205
+ body = raw
206
+ } else {
207
+ headerBytes = raw.slice(0, headerEnd)
208
+ body = raw.slice(headerEnd + separatorLen)
209
+ }
210
+
211
+ const headers = new Headers()
212
+ let status = 200
213
+ let statusText = 'OK'
214
+ const headerText = new TextDecoder().decode(headerBytes)
215
+ for (const line of headerText.split(/\r?\n/)) {
216
+ if (!line) continue
217
+ const colon = line.indexOf(':')
218
+ if (colon === -1) continue
219
+ const key = line.slice(0, colon).trim().toLowerCase()
220
+ const value = line.slice(colon + 1).trim()
221
+ if (key === 'status') {
222
+ const m = value.match(/^(\d+)\s*(.*)$/)
223
+ if (m) {
224
+ status = Number.parseInt(m[1]!, 10)
225
+ statusText = m[2] ?? ''
226
+ }
227
+ } else {
228
+ headers.append(key, value)
229
+ }
230
+ }
231
+
232
+ return new Response(body, { status, statusText, headers })
233
+ }
234
+
235
+ function indexOfDouble(buf: Uint8Array, a: number, b: number): number {
236
+ // Find position of a,b,a,b (4-byte) or a,a (2-byte) pattern — caller chose pattern length.
237
+ // For \r\n\r\n: a=\r=0x0d, b=\n=0x0a, need 4 bytes
238
+ // For \n\n: a=\n=0x0a, b=\n=0x0a, need 2 bytes; simpler to special-case
239
+ if (a === b) {
240
+ for (let i = 0; i + 1 < buf.length; i++) {
241
+ if (buf[i] === a && buf[i + 1] === b) return i
242
+ }
243
+ return -1
244
+ }
245
+ for (let i = 0; i + 3 < buf.length; i++) {
246
+ if (buf[i] === a && buf[i + 1] === b && buf[i + 2] === a && buf[i + 3] === b) return i
247
+ }
248
+ return -1
249
+ }