orez 0.2.20 → 0.2.25

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 (58) hide show
  1. package/dist/browser.d.ts +5 -0
  2. package/dist/browser.d.ts.map +1 -1
  3. package/dist/browser.js +1 -0
  4. package/dist/browser.js.map +1 -1
  5. package/dist/cf-do/test-protocol.d.ts +11 -0
  6. package/dist/cf-do/test-protocol.d.ts.map +1 -0
  7. package/dist/cf-do/test-protocol.js +137 -0
  8. package/dist/cf-do/test-protocol.js.map +1 -0
  9. package/dist/cf-do/worker.d.ts +65 -0
  10. package/dist/cf-do/worker.d.ts.map +1 -0
  11. package/dist/cf-do/worker.js +440 -0
  12. package/dist/cf-do/worker.js.map +1 -0
  13. package/dist/config.d.ts +4 -0
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/config.js +1 -0
  16. package/dist/config.js.map +1 -1
  17. package/dist/index.d.ts +2 -3
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +60 -28
  20. package/dist/index.js.map +1 -1
  21. package/dist/pg-proxy-do-backend.d.ts +49 -0
  22. package/dist/pg-proxy-do-backend.d.ts.map +1 -0
  23. package/dist/pg-proxy-do-backend.js +713 -0
  24. package/dist/pg-proxy-do-backend.js.map +1 -0
  25. package/dist/pglite-ipc.d.ts +3 -0
  26. package/dist/pglite-ipc.d.ts.map +1 -1
  27. package/dist/pglite-ipc.js +34 -12
  28. package/dist/pglite-ipc.js.map +1 -1
  29. package/dist/pglite-web-proxy.d.ts +3 -0
  30. package/dist/pglite-web-proxy.d.ts.map +1 -1
  31. package/dist/pglite-web-proxy.js +50 -7
  32. package/dist/pglite-web-proxy.js.map +1 -1
  33. package/dist/query-rewrites.d.ts +2 -0
  34. package/dist/query-rewrites.d.ts.map +1 -0
  35. package/dist/query-rewrites.js +140 -0
  36. package/dist/query-rewrites.js.map +1 -0
  37. package/dist/worker/browser-admin.d.ts +13 -0
  38. package/dist/worker/browser-admin.d.ts.map +1 -0
  39. package/dist/worker/browser-admin.js +33 -0
  40. package/dist/worker/browser-admin.js.map +1 -0
  41. package/dist/worker/browser-embed.d.ts +12 -12
  42. package/dist/worker/browser-embed.d.ts.map +1 -1
  43. package/dist/worker/browser-embed.js +7 -0
  44. package/dist/worker/browser-embed.js.map +1 -1
  45. package/package.json +2 -2
  46. package/src/browser.ts +7 -0
  47. package/src/config.ts +5 -0
  48. package/src/index.ts +66 -33
  49. package/src/pg-proxy-do-backend.ts +840 -0
  50. package/src/pglite-ipc.test.ts +17 -0
  51. package/src/pglite-ipc.ts +31 -12
  52. package/src/pglite-web-proxy.test.ts +57 -0
  53. package/src/pglite-web-proxy.ts +48 -7
  54. package/src/query-rewrites.test.ts +30 -0
  55. package/src/query-rewrites.ts +152 -0
  56. package/src/worker/browser-admin.ts +52 -0
  57. package/src/worker/browser-embed-admin.test.ts +75 -0
  58. package/src/worker/browser-embed.ts +21 -12
@@ -96,4 +96,21 @@ describe('PGliteWorkerProxy', () => {
96
96
  test('error propagation with SQL code', async () => {
97
97
  await expect(proxy.exec('SELECT * FROM nonexistent_table')).rejects.toThrow()
98
98
  })
99
+
100
+ test('rejects requests after worker exits', async () => {
101
+ const local = new PGliteWorkerProxy({
102
+ dataDir: 'memory://',
103
+ name: 'exit-test',
104
+ withExtensions: false,
105
+ debug: 0,
106
+ pgliteOptions: {},
107
+ })
108
+ await local.waitReady
109
+
110
+ await (local as any).worker.terminate()
111
+
112
+ await expect(local.query('SELECT 1')).rejects.toThrow(
113
+ /worker exited|worker is closed/
114
+ )
115
+ })
99
116
  })
package/src/pglite-ipc.ts CHANGED
@@ -51,6 +51,8 @@ export class PGliteWorkerProxy {
51
51
  private pending = new Map<number, PendingRequest>()
52
52
  private nextId = 1
53
53
  private notificationCallbacks = new Map<string, Set<(payload: string) => void>>()
54
+ private closed = false
55
+ private failure: Error | null = null
54
56
  readonly name: string
55
57
 
56
58
  /** resolves when the worker's PGlite instance is ready */
@@ -89,24 +91,31 @@ export class PGliteWorkerProxy {
89
91
 
90
92
  // handle unexpected worker crashes
91
93
  this.worker.on('error', (err) => {
94
+ const failure = new Error(`worker crashed: ${err.message}`)
92
95
  log.pglite(`worker ${config.name} error: ${err.message}`)
93
- for (const [, req] of this.pending) {
94
- req.reject(new Error(`worker crashed: ${err.message}`))
95
- }
96
- this.pending.clear()
96
+ this.failPending(failure)
97
97
  })
98
98
 
99
99
  this.worker.on('exit', (code) => {
100
+ if (this.closed) return
100
101
  if (code !== 0) {
102
+ const failure = new Error(`worker exited with code ${code}`)
101
103
  log.pglite(`worker ${config.name} exited with code ${code}`)
102
- for (const [, req] of this.pending) {
103
- req.reject(new Error(`worker exited with code ${code}`))
104
- }
105
- this.pending.clear()
104
+ this.failPending(failure)
105
+ return
106
106
  }
107
+ this.failPending(new Error('worker exited unexpectedly'))
107
108
  })
108
109
  }
109
110
 
111
+ private failPending(error: Error) {
112
+ if (!this.failure) this.failure = error
113
+ for (const [, req] of this.pending) {
114
+ req.reject(error)
115
+ }
116
+ this.pending.clear()
117
+ }
118
+
110
119
  private installMessageHandler() {
111
120
  this.worker.on(
112
121
  'message',
@@ -139,14 +148,22 @@ export class PGliteWorkerProxy {
139
148
  }
140
149
 
141
150
  private send(msg: Record<string, unknown>, transfer?: ArrayBuffer[]): Promise<any> {
151
+ if (this.failure) return Promise.reject(this.failure)
152
+ if (this.closed) return Promise.reject(new Error('worker is closed'))
153
+
142
154
  const id = this.nextId++
143
155
  msg.id = id
144
156
  return new Promise((resolve, reject) => {
145
157
  this.pending.set(id, { resolve, reject })
146
- if (transfer?.length) {
147
- this.worker.postMessage(msg, transfer)
148
- } else {
149
- this.worker.postMessage(msg)
158
+ try {
159
+ if (transfer?.length) {
160
+ this.worker.postMessage(msg, transfer)
161
+ } else {
162
+ this.worker.postMessage(msg)
163
+ }
164
+ } catch (err) {
165
+ this.pending.delete(id)
166
+ reject(err instanceof Error ? err : new Error(String(err)))
150
167
  }
151
168
  })
152
169
  }
@@ -242,6 +259,8 @@ export class PGliteWorkerProxy {
242
259
  } catch {
243
260
  // worker may already be gone
244
261
  }
262
+ this.closed = true
263
+ this.failPending(new Error('worker is closed'))
245
264
  await this.worker.terminate()
246
265
  }
247
266
  }
@@ -0,0 +1,57 @@
1
+ import { describe, expect, test } from 'vitest'
2
+
3
+ import { PGliteWebProxy } from './pglite-web-proxy.js'
4
+
5
+ class FakeWorker {
6
+ messages: unknown[] = []
7
+ terminated = false
8
+ private listeners = new Map<string, Set<(event: any) => void>>()
9
+
10
+ addEventListener(type: string, handler: (event: any) => void) {
11
+ let handlers = this.listeners.get(type)
12
+ if (!handlers) {
13
+ handlers = new Set()
14
+ this.listeners.set(type, handlers)
15
+ }
16
+ handlers.add(handler)
17
+ }
18
+
19
+ removeEventListener(type: string, handler: (event: any) => void) {
20
+ this.listeners.get(type)?.delete(handler)
21
+ }
22
+
23
+ postMessage(message: unknown) {
24
+ this.messages.push(message)
25
+ }
26
+
27
+ terminate() {
28
+ this.terminated = true
29
+ }
30
+
31
+ dispatch(type: string, event: any) {
32
+ for (const handler of this.listeners.get(type) ?? []) {
33
+ handler(event)
34
+ }
35
+ }
36
+ }
37
+
38
+ describe('PGliteWebProxy', () => {
39
+ test('rejects pending and future requests when the worker errors', async () => {
40
+ const worker = new FakeWorker()
41
+ const proxy = new PGliteWebProxy(worker as unknown as Worker, 'postgres')
42
+
43
+ worker.dispatch('message', { data: { type: 'ready' } })
44
+ await proxy.waitReady
45
+
46
+ const pending = proxy.query('SELECT 1')
47
+ expect(worker.messages).toHaveLength(1)
48
+
49
+ worker.dispatch('error', {
50
+ error: new Error('pglite worker crashed'),
51
+ message: 'pglite worker crashed',
52
+ })
53
+
54
+ await expect(pending).rejects.toThrow('pglite worker crashed')
55
+ await expect(proxy.query('SELECT 2')).rejects.toThrow('pglite worker crashed')
56
+ })
57
+ })
@@ -34,6 +34,7 @@ export class PGliteWebProxy {
34
34
  private pending = new Map<number, PendingRequest>()
35
35
  private nextId = 1
36
36
  private notificationCallbacks = new Map<string, Set<(payload: string) => void>>()
37
+ private failure: Error | null = null
37
38
  readonly name: string
38
39
 
39
40
  readonly waitReady: Promise<void>
@@ -66,12 +67,26 @@ export class PGliteWebProxy {
66
67
 
67
68
  this.worker.addEventListener('message', onMessage)
68
69
  this.worker.addEventListener('error', (ev) => {
69
- rejectReady(new Error(String(ev)))
70
+ const error = this.errorFromEvent(ev, 'worker failed during startup')
71
+ this.failPending(error)
72
+ rejectReady(error)
73
+ })
74
+ this.worker.addEventListener('messageerror', (ev) => {
75
+ const error = this.errorFromEvent(ev, 'worker message error during startup')
76
+ this.failPending(error)
77
+ rejectReady(error)
70
78
  })
71
79
  })
72
80
  }
73
81
 
74
82
  private installMessageHandler() {
83
+ this.worker.addEventListener('error', (ev) => {
84
+ this.failPending(this.errorFromEvent(ev, 'worker failed'))
85
+ })
86
+ this.worker.addEventListener('messageerror', (ev) => {
87
+ this.failPending(this.errorFromEvent(ev, 'worker message error'))
88
+ })
89
+
75
90
  this.worker.addEventListener('message', (ev: MessageEvent) => {
76
91
  const msg = ev.data
77
92
  if (!msg || typeof msg !== 'object') return
@@ -102,15 +117,40 @@ export class PGliteWebProxy {
102
117
  })
103
118
  }
104
119
 
120
+ private errorFromEvent(ev: Event, fallback: string): Error {
121
+ const maybeError = ev as ErrorEvent
122
+ if (maybeError.error instanceof Error) return maybeError.error
123
+ if (maybeError.message) return new Error(maybeError.message)
124
+ return new Error(fallback)
125
+ }
126
+
127
+ private failPending(error: Error) {
128
+ if (!this.failure) this.failure = error
129
+ this.closed = true
130
+ this.ready = false
131
+ for (const [, req] of this.pending) {
132
+ req.reject(error)
133
+ }
134
+ this.pending.clear()
135
+ }
136
+
105
137
  private send(msg: Record<string, unknown>, transfer?: Transferable[]): Promise<any> {
138
+ if (this.failure) return Promise.reject(this.failure)
139
+ if (this.closed) return Promise.reject(new Error('worker is closed'))
140
+
106
141
  const id = this.nextId++
107
142
  msg.id = id
108
143
  return new Promise((resolve, reject) => {
109
144
  this.pending.set(id, { resolve, reject })
110
- if (transfer?.length) {
111
- this.worker.postMessage(msg, transfer)
112
- } else {
113
- this.worker.postMessage(msg)
145
+ try {
146
+ if (transfer?.length) {
147
+ this.worker.postMessage(msg, transfer)
148
+ } else {
149
+ this.worker.postMessage(msg)
150
+ }
151
+ } catch (err) {
152
+ this.pending.delete(id)
153
+ reject(err instanceof Error ? err : new Error(String(err)))
114
154
  }
115
155
  })
116
156
  }
@@ -170,11 +210,12 @@ export class PGliteWebProxy {
170
210
  }
171
211
 
172
212
  async close(): Promise<void> {
173
- this.closed = true
174
- this.ready = false
175
213
  try {
176
214
  await this.send({ type: 'close' })
177
215
  } catch {}
216
+ this.closed = true
217
+ this.ready = false
218
+ this.failPending(new Error('worker is closed'))
178
219
  this.worker.terminate()
179
220
  }
180
221
  }
@@ -0,0 +1,30 @@
1
+ import { describe, expect, test } from 'vitest'
2
+
3
+ import { rewritePgColumnSizeTotalBytesQuery } from './query-rewrites.js'
4
+
5
+ describe('rewritePgColumnSizeTotalBytesQuery', () => {
6
+ test('rewrites zero totalBytes pg_column_size sums into scalar subselects', () => {
7
+ const query =
8
+ 'SELECT (SUM(COALESCE(pg_column_size("id"), 0)) + SUM(COALESCE(pg_column_size("parts"), 0)) + SUM(COALESCE(pg_column_size("threadId"), 0))) AS "totalBytes" FROM "public"."message" '
9
+
10
+ expect(rewritePgColumnSizeTotalBytesQuery(query)).toBe(
11
+ 'SELECT (SELECT SUM(COALESCE(pg_column_size("id"), 0)) FROM "public"."message") + (SELECT SUM(COALESCE(pg_column_size("parts"), 0)) FROM "public"."message") + (SELECT SUM(COALESCE(pg_column_size("threadId"), 0)) FROM "public"."message") AS "totalBytes"'
12
+ )
13
+ })
14
+
15
+ test('preserves row filters on every scalar subselect', () => {
16
+ const query =
17
+ 'SELECT (SUM(COALESCE(pg_column_size("id"), 0)) + SUM(COALESCE(pg_column_size("parts"), 0))) AS "totalBytes" FROM "public"."message" WHERE "projectId" = \'proj_1\' OR "role" = \'user\';'
18
+
19
+ expect(rewritePgColumnSizeTotalBytesQuery(query)).toBe(
20
+ 'SELECT (SELECT SUM(COALESCE(pg_column_size("id"), 0)) FROM "public"."message" WHERE "projectId" = \'proj_1\' OR "role" = \'user\') + (SELECT SUM(COALESCE(pg_column_size("parts"), 0)) FROM "public"."message" WHERE "projectId" = \'proj_1\' OR "role" = \'user\') AS "totalBytes"'
21
+ )
22
+ })
23
+
24
+ test('leaves non-matching SQL unchanged', () => {
25
+ const query =
26
+ 'SELECT (SUM(COALESCE(pg_column_size("id"), 0)) + count(*)) AS "totalBytes" FROM "public"."message"'
27
+
28
+ expect(rewritePgColumnSizeTotalBytesQuery(query)).toBe(query)
29
+ })
30
+ })
@@ -0,0 +1,152 @@
1
+ const TOTAL_BYTES_ALIAS_RE = /^AS\s+"totalBytes"\s+/i
2
+ const TOTAL_BYTES_TERM_RE =
3
+ /^SUM\s*\(\s*COALESCE\s*\(\s*pg_column_size\s*\(\s*((?:"(?:[^"]|"")+")|(?:[a-z_][a-z0-9_$]*))\s*\)\s*,\s*0\s*\)\s*\)$/i
4
+
5
+ function findMatchingParen(sql: string, openIndex: number): number {
6
+ let depth = 0
7
+ let inSingleQuote = false
8
+ let inDoubleQuote = false
9
+
10
+ for (let i = openIndex; i < sql.length; i++) {
11
+ const ch = sql[i]
12
+ const next = sql[i + 1]
13
+
14
+ if (inSingleQuote) {
15
+ if (ch === "'" && next === "'") {
16
+ i++
17
+ } else if (ch === "'") {
18
+ inSingleQuote = false
19
+ }
20
+ continue
21
+ }
22
+
23
+ if (inDoubleQuote) {
24
+ if (ch === '"' && next === '"') {
25
+ i++
26
+ } else if (ch === '"') {
27
+ inDoubleQuote = false
28
+ }
29
+ continue
30
+ }
31
+
32
+ if (ch === "'") {
33
+ inSingleQuote = true
34
+ continue
35
+ }
36
+ if (ch === '"') {
37
+ inDoubleQuote = true
38
+ continue
39
+ }
40
+ if (ch === '(') {
41
+ depth++
42
+ continue
43
+ }
44
+ if (ch === ')') {
45
+ depth--
46
+ if (depth === 0) return i
47
+ }
48
+ }
49
+
50
+ return -1
51
+ }
52
+
53
+ function splitTopLevelAddends(expr: string): string[] | null {
54
+ const terms: string[] = []
55
+ let depth = 0
56
+ let start = 0
57
+ let inSingleQuote = false
58
+ let inDoubleQuote = false
59
+
60
+ for (let i = 0; i < expr.length; i++) {
61
+ const ch = expr[i]
62
+ const next = expr[i + 1]
63
+
64
+ if (inSingleQuote) {
65
+ if (ch === "'" && next === "'") {
66
+ i++
67
+ } else if (ch === "'") {
68
+ inSingleQuote = false
69
+ }
70
+ continue
71
+ }
72
+
73
+ if (inDoubleQuote) {
74
+ if (ch === '"' && next === '"') {
75
+ i++
76
+ } else if (ch === '"') {
77
+ inDoubleQuote = false
78
+ }
79
+ continue
80
+ }
81
+
82
+ if (ch === "'") {
83
+ inSingleQuote = true
84
+ continue
85
+ }
86
+ if (ch === '"') {
87
+ inDoubleQuote = true
88
+ continue
89
+ }
90
+ if (ch === '(') {
91
+ depth++
92
+ continue
93
+ }
94
+ if (ch === ')') {
95
+ depth--
96
+ if (depth < 0) return null
97
+ continue
98
+ }
99
+ if (ch === '+' && depth === 0) {
100
+ terms.push(expr.slice(start, i).trim())
101
+ start = i + 1
102
+ }
103
+ }
104
+
105
+ if (depth !== 0 || inSingleQuote || inDoubleQuote) return null
106
+
107
+ terms.push(expr.slice(start).trim())
108
+ return terms
109
+ }
110
+
111
+ function stripTrailingSemicolon(sql: string): string {
112
+ const trimmedEnd = sql.trimEnd()
113
+ return trimmedEnd.endsWith(';') ? trimmedEnd.slice(0, -1) : sql
114
+ }
115
+
116
+ export function rewritePgColumnSizeTotalBytesQuery(query: string): string {
117
+ const leadingWhitespace = query.match(/^\s*/)?.[0] ?? ''
118
+ const trimmedStart = query.slice(leadingWhitespace.length)
119
+ if (!/^SELECT\b/i.test(trimmedStart)) return query
120
+
121
+ const afterSelect = trimmedStart.slice('SELECT'.length).trimStart()
122
+ if (!afterSelect.startsWith('(')) return query
123
+
124
+ const openIndex = trimmedStart.indexOf(afterSelect)
125
+ const closeIndex = findMatchingParen(trimmedStart, openIndex)
126
+ if (closeIndex < 0) return query
127
+
128
+ const expression = trimmedStart.slice(openIndex + 1, closeIndex)
129
+ const afterExpression = trimmedStart.slice(closeIndex + 1).trimStart()
130
+ const aliasMatch = afterExpression.match(TOTAL_BYTES_ALIAS_RE)
131
+ if (!aliasMatch) return query
132
+
133
+ const fromClause = stripTrailingSemicolon(
134
+ afterExpression.slice(aliasMatch[0].length).trim()
135
+ )
136
+ if (!/^FROM\b/i.test(fromClause)) return query
137
+
138
+ const terms = splitTopLevelAddends(expression)
139
+ if (!terms || terms.length === 0) return query
140
+
141
+ const columns: string[] = []
142
+ for (const term of terms) {
143
+ const match = term.match(TOTAL_BYTES_TERM_RE)
144
+ if (!match) return query
145
+ columns.push(match[1])
146
+ }
147
+
148
+ const rewrittenTerms = columns.map(
149
+ (column) => `(SELECT SUM(COALESCE(pg_column_size(${column}), 0)) ${fromClause})`
150
+ )
151
+ return `${leadingWhitespace}SELECT ${rewrittenTerms.join(' + ')} AS "totalBytes"`
152
+ }
@@ -0,0 +1,52 @@
1
+ export interface HttpRequest {
2
+ method: string
3
+ url: string
4
+ headers?: Record<string, string>
5
+ body?: string | null
6
+ }
7
+
8
+ export interface HttpResponse {
9
+ status: number
10
+ headers: Record<string, string>
11
+ body: string
12
+ }
13
+
14
+ const ADMIN_HEADERS = {
15
+ 'access-control-allow-origin': '*',
16
+ 'access-control-allow-methods': 'GET, OPTIONS',
17
+ 'access-control-allow-headers': '*',
18
+ 'content-type': 'application/json',
19
+ }
20
+
21
+ function jsonResponse(status: number, body: unknown): HttpResponse {
22
+ return {
23
+ status,
24
+ headers: ADMIN_HEADERS,
25
+ body: JSON.stringify(body),
26
+ }
27
+ }
28
+
29
+ export function handleDisabledBrowserAdminRequest(
30
+ request: HttpRequest
31
+ ): HttpResponse | null {
32
+ const url = new URL(request.url, 'http://localhost')
33
+ if (!url.pathname.startsWith('/__orez/')) return null
34
+
35
+ const method = request.method.toUpperCase()
36
+ if (method === 'OPTIONS') {
37
+ return { status: 200, headers: ADMIN_HEADERS, body: '' }
38
+ }
39
+ if (method !== 'GET') {
40
+ return jsonResponse(405, { error: 'method not allowed', admin: 'disabled' })
41
+ }
42
+
43
+ if (url.pathname === '/__orez/api/logs') {
44
+ return jsonResponse(200, { entries: [], cursor: 0, admin: 'disabled' })
45
+ }
46
+
47
+ if (url.pathname === '/__orez/api/status') {
48
+ return jsonResponse(200, { ready: true, admin: 'disabled' })
49
+ }
50
+
51
+ return jsonResponse(404, { error: 'not found', admin: 'disabled' })
52
+ }
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { handleDisabledBrowserAdminRequest } from './browser-admin.js'
4
+
5
+ describe('disabled browser admin api', () => {
6
+ it('ignores non-admin routes', () => {
7
+ expect(
8
+ handleDisabledBrowserAdminRequest({
9
+ method: 'GET',
10
+ url: '/sync/v1/connect',
11
+ })
12
+ ).toBeNull()
13
+ })
14
+
15
+ it('serves empty logs for disabled browser admin', () => {
16
+ const response = handleDisabledBrowserAdminRequest({
17
+ method: 'GET',
18
+ url: '/__orez/api/logs?limit=100',
19
+ })
20
+
21
+ expect(response?.status).toBe(200)
22
+ expect(response?.headers['content-type']).toBe('application/json')
23
+ expect(JSON.parse(response?.body ?? '')).toEqual({
24
+ entries: [],
25
+ cursor: 0,
26
+ admin: 'disabled',
27
+ })
28
+ })
29
+
30
+ it('serves status for disabled browser admin', () => {
31
+ const response = handleDisabledBrowserAdminRequest({
32
+ method: 'GET',
33
+ url: 'http://localhost:7849/__orez/api/status',
34
+ })
35
+
36
+ expect(response?.status).toBe(200)
37
+ expect(JSON.parse(response?.body ?? '')).toEqual({
38
+ ready: true,
39
+ admin: 'disabled',
40
+ })
41
+ })
42
+
43
+ it('handles preflight and disallows writes', () => {
44
+ expect(
45
+ handleDisabledBrowserAdminRequest({
46
+ method: 'OPTIONS',
47
+ url: '/__orez/api/logs',
48
+ })?.status
49
+ ).toBe(200)
50
+
51
+ const response = handleDisabledBrowserAdminRequest({
52
+ method: 'POST',
53
+ url: '/__orez/api/actions/restart-zero',
54
+ })
55
+
56
+ expect(response?.status).toBe(405)
57
+ expect(JSON.parse(response?.body ?? '')).toEqual({
58
+ error: 'method not allowed',
59
+ admin: 'disabled',
60
+ })
61
+ })
62
+
63
+ it('keeps unknown admin routes explicit', () => {
64
+ const response = handleDisabledBrowserAdminRequest({
65
+ method: 'GET',
66
+ url: '/__orez/api/actions/restart-zero',
67
+ })
68
+
69
+ expect(response?.status).toBe(404)
70
+ expect(JSON.parse(response?.body ?? '')).toEqual({
71
+ error: 'not found',
72
+ admin: 'disabled',
73
+ })
74
+ })
75
+ })
@@ -36,8 +36,13 @@ import EventEmitter from 'node:events'
36
36
  // @ts-expect-error — internal zero-cache module, no type declarations
37
37
  import { runWorker as _runWorker } from '@rocicorp/zero/out/zero-cache/src/server/runner/run-worker.js'
38
38
 
39
+ import { handleDisabledBrowserAdminRequest } from './browser-admin.js'
40
+
41
+ import type { HttpRequest, HttpResponse } from './browser-admin.js'
39
42
  import type { PGlite } from '@electric-sql/pglite'
40
43
 
44
+ export type { HttpRequest, HttpResponse } from './browser-admin.js'
45
+
41
46
  const runWorkerFn = _runWorker as (
42
47
  parent: unknown,
43
48
  env: Record<string, string>
@@ -65,19 +70,17 @@ export interface ZeroCacheEmbedBrowserOptions {
65
70
 
66
71
  /** timeout in ms waiting for zero-cache ready (default: 30000) */
67
72
  readyTimeout?: number
68
- }
69
-
70
- export interface HttpRequest {
71
- method: string
72
- url: string
73
- headers?: Record<string, string>
74
- body?: string | null
75
- }
76
73
 
77
- export interface HttpResponse {
78
- status: number
79
- headers: Record<string, string>
80
- body: string
74
+ /**
75
+ * intercept browser-mode orez admin routes before they reach zero-cache.
76
+ *
77
+ * browser embeds do not run the node admin dashboard or log store. leaving
78
+ * `/__orez/*` to fall through to zero-cache makes disabled admin look like
79
+ * an app/zero route miss. keep the contract explicit by returning empty
80
+ * admin responses for the small read-only surface and 404 for the rest.
81
+ * default: true.
82
+ */
83
+ disableAdminApi?: boolean
81
84
  }
82
85
 
83
86
  /** WebSocket-like object — matches CF WebSocket, browser WebSocket, or MessagePort adapter */
@@ -116,6 +119,7 @@ export async function startZeroCacheEmbedBrowser(
116
119
  const appId = opts.appId || 'zero'
117
120
  const publications = opts.publications?.join(',') || `orez_${appId}_public`
118
121
  const readyTimeout = opts.readyTimeout ?? 30000
122
+ const disableAdminApi = opts.disableAdminApi ?? true
119
123
 
120
124
  // set up sqlite storage from sql.js or in-memory
121
125
  if (opts.sqlite) {
@@ -303,6 +307,11 @@ export async function startZeroCacheEmbedBrowser(
303
307
  },
304
308
 
305
309
  async handleHttp(request: HttpRequest): Promise<HttpResponse> {
310
+ if (disableAdminApi) {
311
+ const adminResponse = handleDisabledBrowserAdminRequest(request)
312
+ if (adminResponse) return adminResponse
313
+ }
314
+
306
315
  if (!isReady || !fastifyInstance?.inject) {
307
316
  return { status: 503, headers: {}, body: 'not ready' }
308
317
  }