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
@@ -0,0 +1,453 @@
1
+ /**
2
+ * Local implementation of the Cloudflare Artifacts binding.
3
+ *
4
+ * Artifacts on Cloudflare is a control-plane API (create/fork/delete repos,
5
+ * issue tokens) + a Git-over-HTTPS endpoint for content access. The binding
6
+ * only exposes the control plane; actual blob/tree/commit I/O happens via a
7
+ * regular Git client against the returned `remote` URL.
8
+ *
9
+ * In local dev we mirror this split:
10
+ * - Repo metadata and tokens live in SQLite (`artifacts_repos`, `artifacts_tokens`).
11
+ * - Each repo is a bare Git repository on disk at `.lopata/artifacts/<repo-id>.git`.
12
+ * - The lopata dev server exposes `/__artifacts/git/<repo-id>.git/*` backed by
13
+ * `git http-backend` (see `artifacts-git-http.ts`).
14
+ */
15
+ import { randomUUIDv7 } from 'bun'
16
+ import type { Database } from 'bun:sqlite'
17
+ import { existsSync, mkdirSync, rmSync } from 'node:fs'
18
+ import { join } from 'node:path'
19
+
20
+ export type ArtifactsTokenScope = 'read' | 'write'
21
+
22
+ export interface CreateRepoOpts {
23
+ readOnly?: boolean
24
+ description?: string
25
+ setDefaultBranch?: string
26
+ }
27
+
28
+ export interface CreateRepoResult {
29
+ id: string
30
+ name: string
31
+ description: string | null
32
+ defaultBranch: string
33
+ remote: string
34
+ token: string
35
+ }
36
+
37
+ export interface TokenResult {
38
+ id: string
39
+ plaintext: string
40
+ scope: ArtifactsTokenScope
41
+ expiresAt: string | null
42
+ }
43
+
44
+ export interface ListReposOpts {
45
+ limit?: number
46
+ cursor?: string
47
+ }
48
+
49
+ export interface RepoListResult {
50
+ repos: { id: string; name: string; remote: string; defaultBranch: string; description: string | null }[]
51
+ cursor: string | null
52
+ }
53
+
54
+ export interface TokenListResult {
55
+ total: number
56
+ tokens: { id: string; scope: ArtifactsTokenScope; expiresAt: string | null; createdAt: string }[]
57
+ }
58
+
59
+ export interface ImportParams {
60
+ source: { url: string; branch?: string; depth?: number }
61
+ target: { name: string; opts?: CreateRepoOpts }
62
+ }
63
+
64
+ export interface ForkOpts {
65
+ description?: string
66
+ readOnly?: boolean
67
+ defaultBranchOnly?: boolean
68
+ }
69
+
70
+ interface RepoRow {
71
+ id: string
72
+ namespace: string
73
+ name: string
74
+ description: string | null
75
+ default_branch: string
76
+ read_only: number
77
+ forked_from: string | null
78
+ created_at: number
79
+ }
80
+
81
+ interface TokenRow {
82
+ id: string
83
+ repo_id: string
84
+ plaintext: string
85
+ scope: string
86
+ expires_at: number | null
87
+ created_at: number
88
+ revoked_at: number | null
89
+ }
90
+
91
+ const VALID_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/
92
+
93
+ function assertValidName(name: string) {
94
+ if (!VALID_NAME.test(name)) {
95
+ throw new Error(`Invalid repo name "${name}" — must match ${VALID_NAME}`)
96
+ }
97
+ }
98
+
99
+ function tsToIso(ts: number | null): string | null {
100
+ return ts == null ? null : new Date(ts).toISOString()
101
+ }
102
+
103
+ export class ArtifactsBinding {
104
+ private readonly db: Database
105
+ private readonly namespace: string
106
+ private readonly artifactsDir: string
107
+ private readonly remoteBase: string
108
+ private readonly gitBinary: string
109
+
110
+ constructor(
111
+ db: Database,
112
+ namespace: string,
113
+ artifactsDir: string,
114
+ remoteBase: string,
115
+ gitBinary = 'git',
116
+ ) {
117
+ this.db = db
118
+ this.namespace = namespace
119
+ this.artifactsDir = artifactsDir
120
+ this.remoteBase = remoteBase.replace(/\/$/, '')
121
+ this.gitBinary = gitBinary
122
+ mkdirSync(this.artifactsDir, { recursive: true })
123
+ }
124
+
125
+ private repoDir(id: string): string {
126
+ return join(this.artifactsDir, `${id}.git`)
127
+ }
128
+
129
+ private buildRemote(id: string, token?: string): string {
130
+ const url = new URL(`${this.remoteBase}/${id}.git`)
131
+ if (token) {
132
+ url.username = 'artifacts'
133
+ url.password = token
134
+ }
135
+ return url.toString()
136
+ }
137
+
138
+ async create(name: string, opts: CreateRepoOpts = {}): Promise<CreateRepoResult> {
139
+ assertValidName(name)
140
+ const existing = this.db
141
+ .query<RepoRow, [string, string]>('SELECT * FROM artifacts_repos WHERE namespace = ? AND name = ?')
142
+ .get(this.namespace, name)
143
+ if (existing) {
144
+ throw new Error(`Artifacts repo "${name}" already exists in namespace "${this.namespace}"`)
145
+ }
146
+
147
+ const id = randomUUIDv7()
148
+ const defaultBranch = opts.setDefaultBranch ?? 'main'
149
+ const description = opts.description ?? null
150
+ const readOnly = opts.readOnly ? 1 : 0
151
+
152
+ const dir = this.repoDir(id)
153
+ await initBareRepo(this.gitBinary, dir, defaultBranch)
154
+
155
+ this.db.run(
156
+ `INSERT INTO artifacts_repos (id, namespace, name, description, default_branch, read_only, created_at)
157
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
158
+ [id, this.namespace, name, description, defaultBranch, readOnly, Date.now()],
159
+ )
160
+
161
+ const token = this.issueToken(id, 'write')
162
+
163
+ return {
164
+ id,
165
+ name,
166
+ description,
167
+ defaultBranch,
168
+ remote: this.buildRemote(id),
169
+ token: token.plaintext,
170
+ }
171
+ }
172
+
173
+ async get(name: string): Promise<ArtifactsRepo> {
174
+ const row = this.db
175
+ .query<RepoRow, [string, string]>('SELECT * FROM artifacts_repos WHERE namespace = ? AND name = ?')
176
+ .get(this.namespace, name)
177
+ if (!row) {
178
+ throw new Error(`Artifacts repo "${name}" not found in namespace "${this.namespace}"`)
179
+ }
180
+ return new ArtifactsRepo(this, row, this.buildRemote(row.id))
181
+ }
182
+
183
+ async list(opts: ListReposOpts = {}): Promise<RepoListResult> {
184
+ const limit = Math.min(Math.max(opts.limit ?? 100, 1), 1000)
185
+ const cursor = opts.cursor ? Number.parseInt(opts.cursor, 10) : 0
186
+ const rows = this.db
187
+ .query<RepoRow, [string, number, number]>(
188
+ 'SELECT * FROM artifacts_repos WHERE namespace = ? AND created_at >= ? ORDER BY created_at ASC LIMIT ?',
189
+ )
190
+ .all(this.namespace, cursor, limit + 1)
191
+
192
+ let nextCursor: string | null = null
193
+ const page = rows.length > limit ? rows.slice(0, limit) : rows
194
+ if (rows.length > limit) {
195
+ nextCursor = String(rows[limit]!.created_at)
196
+ }
197
+
198
+ return {
199
+ repos: page.map(r => ({
200
+ id: r.id,
201
+ name: r.name,
202
+ remote: this.buildRemote(r.id),
203
+ defaultBranch: r.default_branch,
204
+ description: r.description,
205
+ })),
206
+ cursor: nextCursor,
207
+ }
208
+ }
209
+
210
+ async import(params: ImportParams): Promise<CreateRepoResult> {
211
+ const { source, target } = params
212
+ assertValidName(target.name)
213
+ if (!/^https?:\/\//.test(source.url) && !source.url.startsWith('git@')) {
214
+ throw new Error(`Artifacts import: unsupported source URL "${source.url}"`)
215
+ }
216
+
217
+ const existing = this.db
218
+ .query<RepoRow, [string, string]>('SELECT * FROM artifacts_repos WHERE namespace = ? AND name = ?')
219
+ .get(this.namespace, target.name)
220
+ if (existing) {
221
+ throw new Error(`Artifacts repo "${target.name}" already exists in namespace "${this.namespace}"`)
222
+ }
223
+
224
+ const id = randomUUIDv7()
225
+ const dir = this.repoDir(id)
226
+
227
+ const args = ['clone', '--bare']
228
+ if (source.depth) args.push('--depth', String(source.depth))
229
+ if (source.branch) args.push('--branch', source.branch, '--single-branch')
230
+ args.push(source.url, dir)
231
+
232
+ const proc = Bun.spawn([this.gitBinary, ...args], { stdout: 'pipe', stderr: 'pipe' })
233
+ const exit = await proc.exited
234
+ if (exit !== 0) {
235
+ const stderr = await new Response(proc.stderr).text()
236
+ try {
237
+ rmSync(dir, { recursive: true, force: true })
238
+ } catch {}
239
+ throw new Error(`git clone failed (${exit}): ${stderr}`)
240
+ }
241
+
242
+ // Determine default branch from the cloned repo's HEAD
243
+ const defaultBranch = await readDefaultBranch(this.gitBinary, dir) ?? source.branch ?? 'main'
244
+ const description = target.opts?.description ?? null
245
+ const readOnly = target.opts?.readOnly ? 1 : 0
246
+
247
+ this.db.run(
248
+ `INSERT INTO artifacts_repos (id, namespace, name, description, default_branch, read_only, created_at)
249
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
250
+ [id, this.namespace, target.name, description, defaultBranch, readOnly, Date.now()],
251
+ )
252
+
253
+ const token = this.issueToken(id, 'write')
254
+ return {
255
+ id,
256
+ name: target.name,
257
+ description,
258
+ defaultBranch,
259
+ remote: this.buildRemote(id),
260
+ token: token.plaintext,
261
+ }
262
+ }
263
+
264
+ async delete(name: string): Promise<boolean> {
265
+ const row = this.db
266
+ .query<RepoRow, [string, string]>('SELECT * FROM artifacts_repos WHERE namespace = ? AND name = ?')
267
+ .get(this.namespace, name)
268
+ if (!row) return false
269
+ this.db.run('DELETE FROM artifacts_tokens WHERE repo_id = ?', [row.id])
270
+ this.db.run('DELETE FROM artifacts_repos WHERE id = ?', [row.id])
271
+ const dir = this.repoDir(row.id)
272
+ if (existsSync(dir)) rmSync(dir, { recursive: true, force: true })
273
+ return true
274
+ }
275
+
276
+ /** @internal — also called by `ArtifactsRepo.fork()` */
277
+ async _forkRepo(source: RepoRow, newName: string, opts: ForkOpts = {}): Promise<CreateRepoResult> {
278
+ assertValidName(newName)
279
+ const existing = this.db
280
+ .query<RepoRow, [string, string]>('SELECT * FROM artifacts_repos WHERE namespace = ? AND name = ?')
281
+ .get(this.namespace, newName)
282
+ if (existing) {
283
+ throw new Error(`Artifacts repo "${newName}" already exists in namespace "${this.namespace}"`)
284
+ }
285
+
286
+ const id = randomUUIDv7()
287
+ const srcDir = this.repoDir(source.id)
288
+ const dstDir = this.repoDir(id)
289
+
290
+ const cloneArgs = ['clone', '--bare']
291
+ if (opts.defaultBranchOnly) cloneArgs.push('--single-branch', '--branch', source.default_branch)
292
+ cloneArgs.push(srcDir, dstDir)
293
+
294
+ const proc = Bun.spawn([this.gitBinary, ...cloneArgs], { stdout: 'pipe', stderr: 'pipe' })
295
+ const exit = await proc.exited
296
+ if (exit !== 0) {
297
+ const stderr = await new Response(proc.stderr).text()
298
+ try {
299
+ rmSync(dstDir, { recursive: true, force: true })
300
+ } catch {}
301
+ throw new Error(`git clone (fork) failed (${exit}): ${stderr}`)
302
+ }
303
+
304
+ const description = opts.description ?? null
305
+ const readOnly = opts.readOnly ? 1 : 0
306
+ this.db.run(
307
+ `INSERT INTO artifacts_repos (id, namespace, name, description, default_branch, read_only, forked_from, created_at)
308
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
309
+ [id, this.namespace, newName, description, source.default_branch, readOnly, source.id, Date.now()],
310
+ )
311
+
312
+ const token = this.issueToken(id, 'write')
313
+ return {
314
+ id,
315
+ name: newName,
316
+ description,
317
+ defaultBranch: source.default_branch,
318
+ remote: this.buildRemote(id),
319
+ token: token.plaintext,
320
+ }
321
+ }
322
+
323
+ /** @internal — called by `ArtifactsRepo.createToken()`, `ArtifactsBinding.create()`. */
324
+ issueToken(repoId: string, scope: ArtifactsTokenScope, ttlSeconds?: number): TokenResult {
325
+ const id = randomUUIDv7()
326
+ const plaintext = generateTokenString()
327
+ const createdAt = Date.now()
328
+ const expiresAt = ttlSeconds ? createdAt + ttlSeconds * 1000 : null
329
+ this.db.run(
330
+ `INSERT INTO artifacts_tokens (id, repo_id, plaintext, scope, expires_at, created_at)
331
+ VALUES (?, ?, ?, ?, ?, ?)`,
332
+ [id, repoId, plaintext, scope, expiresAt, createdAt],
333
+ )
334
+ return {
335
+ id,
336
+ plaintext,
337
+ scope,
338
+ expiresAt: tsToIso(expiresAt),
339
+ }
340
+ }
341
+
342
+ /** @internal */
343
+ listTokens(repoId: string): TokenListResult {
344
+ const rows = this.db
345
+ .query<TokenRow, [string]>(
346
+ 'SELECT * FROM artifacts_tokens WHERE repo_id = ? AND revoked_at IS NULL ORDER BY created_at ASC',
347
+ )
348
+ .all(repoId)
349
+ const tokens = rows.map(r => ({
350
+ id: r.id,
351
+ scope: r.scope as ArtifactsTokenScope,
352
+ expiresAt: tsToIso(r.expires_at),
353
+ createdAt: new Date(r.created_at).toISOString(),
354
+ }))
355
+ return { total: tokens.length, tokens }
356
+ }
357
+
358
+ /** @internal */
359
+ revokeTokenById(repoId: string, tokenOrId: string): boolean {
360
+ // Accept either the plaintext token or the id
361
+ const now = Date.now()
362
+ const info = this.db.run(
363
+ 'UPDATE artifacts_tokens SET revoked_at = ? WHERE repo_id = ? AND revoked_at IS NULL AND (id = ? OR plaintext = ?)',
364
+ [now, repoId, tokenOrId, tokenOrId],
365
+ )
366
+ return info.changes > 0
367
+ }
368
+ }
369
+
370
+ export class ArtifactsRepo {
371
+ private readonly binding: ArtifactsBinding
372
+ private readonly row: RepoRow
373
+ readonly remote: string
374
+
375
+ constructor(binding: ArtifactsBinding, row: RepoRow, remote: string) {
376
+ this.binding = binding
377
+ this.row = row
378
+ this.remote = remote
379
+ }
380
+
381
+ get id(): string {
382
+ return this.row.id
383
+ }
384
+
385
+ get name(): string {
386
+ return this.row.name
387
+ }
388
+
389
+ get defaultBranch(): string {
390
+ return this.row.default_branch
391
+ }
392
+
393
+ get description(): string | null {
394
+ return this.row.description
395
+ }
396
+
397
+ async createToken(scope: ArtifactsTokenScope = 'write', ttl?: number): Promise<TokenResult> {
398
+ return this.binding.issueToken(this.row.id, scope, ttl)
399
+ }
400
+
401
+ async listTokens(): Promise<TokenListResult> {
402
+ return this.binding.listTokens(this.row.id)
403
+ }
404
+
405
+ async revokeToken(tokenOrId: string): Promise<boolean> {
406
+ return this.binding.revokeTokenById(this.row.id, tokenOrId)
407
+ }
408
+
409
+ async fork(name: string, opts: ForkOpts = {}): Promise<CreateRepoResult> {
410
+ return this.binding._forkRepo(this.row, name, opts)
411
+ }
412
+ }
413
+
414
+ // ─── helpers ─────────────────────────────────────────────────────────
415
+
416
+ function generateTokenString(): string {
417
+ // 32 bytes → 64 hex chars
418
+ const bytes = crypto.getRandomValues(new Uint8Array(32))
419
+ return 'art_' + Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
420
+ }
421
+
422
+ async function initBareRepo(gitBinary: string, dir: string, defaultBranch: string): Promise<void> {
423
+ mkdirSync(dir, { recursive: true })
424
+ const proc = Bun.spawn(
425
+ [gitBinary, 'init', '--bare', '--initial-branch', defaultBranch, dir],
426
+ { stdout: 'pipe', stderr: 'pipe' },
427
+ )
428
+ const exit = await proc.exited
429
+ if (exit !== 0) {
430
+ const stderr = await new Response(proc.stderr).text()
431
+ try {
432
+ rmSync(dir, { recursive: true, force: true })
433
+ } catch {}
434
+ throw new Error(`git init --bare failed (${exit}): ${stderr}`)
435
+ }
436
+ // Allow HTTP access without explicit "git config http.receivepack true"
437
+ const cfg = Bun.spawn(
438
+ [gitBinary, '-C', dir, 'config', 'http.receivepack', 'true'],
439
+ { stdout: 'pipe', stderr: 'pipe' },
440
+ )
441
+ await cfg.exited
442
+ }
443
+
444
+ async function readDefaultBranch(gitBinary: string, dir: string): Promise<string | null> {
445
+ const proc = Bun.spawn(
446
+ [gitBinary, '-C', dir, 'symbolic-ref', '--short', 'HEAD'],
447
+ { stdout: 'pipe', stderr: 'pipe' },
448
+ )
449
+ const exit = await proc.exited
450
+ if (exit !== 0) return null
451
+ const out = (await new Response(proc.stdout).text()).trim()
452
+ return out || null
453
+ }
@@ -0,0 +1,117 @@
1
+ import type { Database } from 'bun:sqlite'
2
+
3
+ export type FlagType = 'boolean' | 'string' | 'number' | 'object'
4
+
5
+ export interface FlagDetails<T> {
6
+ value: T
7
+ variant?: string
8
+ reason: 'STATIC' | 'DEFAULT' | 'ERROR' | 'TARGETING_MATCH'
9
+ errorCode?: string
10
+ }
11
+
12
+ type EvaluationContext = Record<string, unknown> | undefined
13
+
14
+ interface FlagRow {
15
+ type: string
16
+ value: string
17
+ variant: string | null
18
+ }
19
+
20
+ /**
21
+ * Local implementation of the Cloudflare Flagship feature-flag binding.
22
+ * Flag values are stored in SQLite (`flagship_flags` table). When a flag is
23
+ * not found, the caller's `defaultValue` is returned with reason `DEFAULT`.
24
+ * Local dev does not implement targeting rules — the `context` argument is
25
+ * accepted for API compatibility but ignored during evaluation.
26
+ */
27
+ export class FlagshipBinding {
28
+ private db: Database
29
+ private appId: string
30
+
31
+ constructor(db: Database, appId: string) {
32
+ this.db = db
33
+ this.appId = appId
34
+ }
35
+
36
+ async getBooleanValue(key: string, defaultValue: boolean, context?: EvaluationContext): Promise<boolean> {
37
+ return (await this.getBooleanValueDetails(key, defaultValue, context)).value
38
+ }
39
+
40
+ async getStringValue(key: string, defaultValue: string, context?: EvaluationContext): Promise<string> {
41
+ return (await this.getStringValueDetails(key, defaultValue, context)).value
42
+ }
43
+
44
+ async getNumberValue(key: string, defaultValue: number, context?: EvaluationContext): Promise<number> {
45
+ return (await this.getNumberValueDetails(key, defaultValue, context)).value
46
+ }
47
+
48
+ async getObjectValue<T>(key: string, defaultValue: T, context?: EvaluationContext): Promise<T> {
49
+ return (await this.getObjectValueDetails(key, defaultValue, context)).value
50
+ }
51
+
52
+ async getBooleanValueDetails(key: string, defaultValue: boolean, _context?: EvaluationContext): Promise<FlagDetails<boolean>> {
53
+ return this.evaluate('boolean', key, defaultValue, parseBoolean)
54
+ }
55
+
56
+ async getStringValueDetails(key: string, defaultValue: string, _context?: EvaluationContext): Promise<FlagDetails<string>> {
57
+ return this.evaluate('string', key, defaultValue, (v) => v)
58
+ }
59
+
60
+ async getNumberValueDetails(key: string, defaultValue: number, _context?: EvaluationContext): Promise<FlagDetails<number>> {
61
+ return this.evaluate('number', key, defaultValue, (v) => Number(v))
62
+ }
63
+
64
+ async getObjectValueDetails<T>(key: string, defaultValue: T, _context?: EvaluationContext): Promise<FlagDetails<T>> {
65
+ return this.evaluate('object', key, defaultValue, (v) => JSON.parse(v) as T)
66
+ }
67
+
68
+ private evaluate<T>(expectedType: FlagType, key: string, defaultValue: T, parse: (raw: string) => T): FlagDetails<T> {
69
+ const row = this.db
70
+ .query<FlagRow, [string, string]>('SELECT type, value, variant FROM flagship_flags WHERE app_id = ? AND flag_key = ?')
71
+ .get(this.appId, key)
72
+ if (!row) {
73
+ return { value: defaultValue, reason: 'DEFAULT' }
74
+ }
75
+ if (row.type !== expectedType) {
76
+ return { value: defaultValue, reason: 'ERROR', errorCode: 'TYPE_MISMATCH' }
77
+ }
78
+ try {
79
+ const parsed = parse(row.value)
80
+ return { value: parsed, variant: row.variant ?? undefined, reason: 'STATIC' }
81
+ } catch {
82
+ return { value: defaultValue, reason: 'ERROR', errorCode: 'PARSE_ERROR' }
83
+ }
84
+ }
85
+ }
86
+
87
+ function parseBoolean(raw: string): boolean {
88
+ const v = raw.toLowerCase()
89
+ if (v === 'true' || v === '1') return true
90
+ if (v === 'false' || v === '0') return false
91
+ throw new Error(`invalid boolean flag value: ${raw}`)
92
+ }
93
+
94
+ /**
95
+ * Programmatic helper to seed / override a flag value in the local store.
96
+ * Used by tests and (later) the dashboard.
97
+ */
98
+ export function setFlagValue(
99
+ db: Database,
100
+ appId: string,
101
+ key: string,
102
+ type: FlagType,
103
+ value: unknown,
104
+ variant?: string,
105
+ ): void {
106
+ const raw = type === 'object' ? JSON.stringify(value) : String(value)
107
+ db.run(
108
+ `INSERT INTO flagship_flags (app_id, flag_key, type, value, variant, updated_at)
109
+ VALUES (?, ?, ?, ?, ?, ?)
110
+ ON CONFLICT(app_id, flag_key) DO UPDATE SET type = excluded.type, value = excluded.value, variant = excluded.variant, updated_at = excluded.updated_at`,
111
+ [appId, key, type, raw, variant ?? null, Date.now()],
112
+ )
113
+ }
114
+
115
+ export function deleteFlag(db: Database, appId: string, key: string): void {
116
+ db.run('DELETE FROM flagship_flags WHERE app_id = ? AND flag_key = ?', [appId, key])
117
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Local implementation of the Cloudflare VPC Networks binding (`vpc_networks`).
3
+ *
4
+ * On Cloudflare, this binding exposes a Fetcher-like `fetch()` method that
5
+ * routes the request through the bound tunnel or Cloudflare Mesh. The URL
6
+ * passed to `fetch()` determines the destination host/port.
7
+ *
8
+ * In local dev there is no tunnel overlay network, so we pass the request
9
+ * straight to the host system's stack — the caller is expected to arrange
10
+ * for the destination to be reachable locally (e.g. a dev service on a
11
+ * known port, or a VPN/WireGuard tunnel on the host).
12
+ */
13
+
14
+ export interface VpcNetworkConfig {
15
+ networkId: string
16
+ bindingName: string
17
+ }
18
+
19
+ export class VpcNetworkBinding {
20
+ private config: VpcNetworkConfig
21
+
22
+ constructor(config: VpcNetworkConfig) {
23
+ this.config = config
24
+ }
25
+
26
+ get networkId(): string {
27
+ return this.config.networkId
28
+ }
29
+
30
+ async fetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {
31
+ const request = input instanceof Request ? new Request(input, init) : new Request(input.toString(), init)
32
+ const url = new URL(request.url)
33
+ if (!url.host) {
34
+ throw new Error(
35
+ `VPC Network binding "${this.config.bindingName}" requires an absolute URL (with host), got: ${request.url}`,
36
+ )
37
+ }
38
+ return fetch(request)
39
+ }
40
+ }