lopata 0.1.3 → 0.1.4
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/package.json +1 -1
- package/src/cli/dev.ts +1 -1
- package/src/dashboard-serve.ts +90 -0
- package/src/error-page-render.ts +272 -0
- package/src/generation.ts +1 -1
- package/src/vite-plugin/dev-server-plugin.ts +2 -2
package/package.json
CHANGED
package/src/cli/dev.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { QueuePullConsumer } from '../bindings/queue'
|
|
|
8
8
|
import type { AckRequest, PullRequest } from '../bindings/queue'
|
|
9
9
|
import { CFWebSocket } from '../bindings/websocket-pair'
|
|
10
10
|
import { autoLoadConfig, loadConfig } from '../config'
|
|
11
|
-
import { handleDashboardRequest } from '../dashboard
|
|
11
|
+
import { handleDashboardRequest } from '../dashboard-serve'
|
|
12
12
|
import { getDatabase } from '../db'
|
|
13
13
|
import { FileWatcher } from '../file-watcher'
|
|
14
14
|
import { GenerationManager } from '../generation-manager'
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
let dashboardAssets: Map<string, { content: Uint8Array; contentType: string }> | null = null
|
|
5
|
+
let dashboardHtmlContent: string | null = null
|
|
6
|
+
|
|
7
|
+
const distDir = join(import.meta.dir, '../dist/dashboard')
|
|
8
|
+
|
|
9
|
+
if (existsSync(join(distDir, 'index.html'))) {
|
|
10
|
+
// Production: load pre-built assets from dist/
|
|
11
|
+
dashboardHtmlContent = await Bun.file(join(distDir, 'index.html')).text()
|
|
12
|
+
dashboardAssets = new Map()
|
|
13
|
+
for (const entry of readdirSync(distDir)) {
|
|
14
|
+
if (entry === 'index.html') continue
|
|
15
|
+
const content = new Uint8Array(await Bun.file(join(distDir, entry)).arrayBuffer())
|
|
16
|
+
const contentType = entry.endsWith('.css')
|
|
17
|
+
? 'text/css'
|
|
18
|
+
: entry.endsWith('.js')
|
|
19
|
+
? 'application/javascript'
|
|
20
|
+
: 'application/octet-stream'
|
|
21
|
+
dashboardAssets.set(entry, { content, contentType })
|
|
22
|
+
}
|
|
23
|
+
} else {
|
|
24
|
+
// Dev: build on-the-fly (requires source files + bun-plugin-tailwind)
|
|
25
|
+
const tailwindPlugin = (await import('bun-plugin-tailwind')).default
|
|
26
|
+
const htmlEntry = join(import.meta.dir, 'dashboard/index.html')
|
|
27
|
+
|
|
28
|
+
const result = await Bun.build({
|
|
29
|
+
entrypoints: [htmlEntry],
|
|
30
|
+
plugins: [tailwindPlugin],
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
if (!result.success) {
|
|
34
|
+
console.error('[lopata] Dashboard build failed:', result.logs)
|
|
35
|
+
throw new Error('Dashboard build failed')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const assets = new Map<string, { content: Uint8Array; contentType: string }>()
|
|
39
|
+
let html = ''
|
|
40
|
+
|
|
41
|
+
for (const output of result.outputs) {
|
|
42
|
+
const name = output.path.split('/').pop()!
|
|
43
|
+
const content = new Uint8Array(await output.arrayBuffer())
|
|
44
|
+
|
|
45
|
+
if (output.kind === 'entry-point' && name.endsWith('.html')) {
|
|
46
|
+
html = new TextDecoder().decode(content)
|
|
47
|
+
} else {
|
|
48
|
+
const contentType = name.endsWith('.css')
|
|
49
|
+
? 'text/css'
|
|
50
|
+
: name.endsWith('.js')
|
|
51
|
+
? 'application/javascript'
|
|
52
|
+
: 'application/octet-stream'
|
|
53
|
+
assets.set(name, { content, contentType })
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const name of assets.keys()) {
|
|
58
|
+
html = html.replaceAll(`./${name}`, `/__dashboard/assets/${name}`)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
dashboardHtmlContent = html
|
|
62
|
+
dashboardAssets = assets
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function handleDashboardRequest(request: Request): Response {
|
|
66
|
+
const url = new URL(request.url)
|
|
67
|
+
|
|
68
|
+
// Serve dashboard HTML
|
|
69
|
+
if (url.pathname === '/__dashboard') {
|
|
70
|
+
return new Response(dashboardHtmlContent, {
|
|
71
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Serve dashboard assets (JS, CSS)
|
|
76
|
+
const assetMatch = url.pathname.match(/^\/__dashboard\/assets\/(.+)$/)
|
|
77
|
+
if (assetMatch && dashboardAssets) {
|
|
78
|
+
const asset = dashboardAssets.get(assetMatch[1]!)
|
|
79
|
+
if (asset) {
|
|
80
|
+
return new Response(asset.content as unknown as BodyInit, {
|
|
81
|
+
headers: {
|
|
82
|
+
'Content-Type': asset.contentType,
|
|
83
|
+
'Cache-Control': 'public, max-age=31536000, immutable',
|
|
84
|
+
},
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return new Response('Not found', { status: 404 })
|
|
90
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import type { WranglerConfig } from './config'
|
|
4
|
+
import { getActiveContext } from './tracing/context'
|
|
5
|
+
import { enrichFrameWithSourceAsync, parseStackFrames, type StackFrame } from './tracing/frames'
|
|
6
|
+
import { getTraceStore } from './tracing/store'
|
|
7
|
+
|
|
8
|
+
interface ErrorPageData {
|
|
9
|
+
error: {
|
|
10
|
+
name: string
|
|
11
|
+
message: string
|
|
12
|
+
stack: string
|
|
13
|
+
frames: StackFrame[]
|
|
14
|
+
}
|
|
15
|
+
request: {
|
|
16
|
+
method: string
|
|
17
|
+
url: string
|
|
18
|
+
headers: Record<string, string>
|
|
19
|
+
}
|
|
20
|
+
env: Record<string, string>
|
|
21
|
+
bindings: { name: string; type: string }[]
|
|
22
|
+
runtime: {
|
|
23
|
+
bunVersion: string
|
|
24
|
+
platform: string
|
|
25
|
+
arch: string
|
|
26
|
+
workerName?: string
|
|
27
|
+
configName?: string
|
|
28
|
+
}
|
|
29
|
+
trace?: {
|
|
30
|
+
traceId: string
|
|
31
|
+
spanId: string | null
|
|
32
|
+
spans: Array<{
|
|
33
|
+
spanId: string
|
|
34
|
+
traceId: string
|
|
35
|
+
parentSpanId: string | null
|
|
36
|
+
name: string
|
|
37
|
+
status: string
|
|
38
|
+
startTime: number
|
|
39
|
+
endTime: number | null
|
|
40
|
+
durationMs: number | null
|
|
41
|
+
}>
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── Pre-built error page HTML ────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
let errorPageHtml: string | null = null
|
|
48
|
+
|
|
49
|
+
const distFile = join(import.meta.dir, '../dist/error-page.html')
|
|
50
|
+
|
|
51
|
+
if (existsSync(distFile)) {
|
|
52
|
+
// Production: load pre-built self-contained HTML
|
|
53
|
+
errorPageHtml = await Bun.file(distFile).text()
|
|
54
|
+
} else {
|
|
55
|
+
// Dev: build on-the-fly (requires source files + bun-plugin-tailwind)
|
|
56
|
+
const tailwindPlugin = (await import('bun-plugin-tailwind')).default
|
|
57
|
+
const htmlEntry = join(import.meta.dir, 'error-page/index.html')
|
|
58
|
+
|
|
59
|
+
const result = await Bun.build({
|
|
60
|
+
entrypoints: [htmlEntry],
|
|
61
|
+
plugins: [tailwindPlugin],
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
if (!result.success) {
|
|
65
|
+
console.error('[lopata] Error page build failed:', result.logs)
|
|
66
|
+
throw new Error('Error page build failed')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const assets = new Map<string, { content: Uint8Array; contentType: string }>()
|
|
70
|
+
let html = ''
|
|
71
|
+
|
|
72
|
+
for (const output of result.outputs) {
|
|
73
|
+
const name = output.path.split('/').pop()!
|
|
74
|
+
const content = new Uint8Array(await output.arrayBuffer())
|
|
75
|
+
|
|
76
|
+
if (output.kind === 'entry-point' && name.endsWith('.html')) {
|
|
77
|
+
html = new TextDecoder().decode(content)
|
|
78
|
+
} else {
|
|
79
|
+
const contentType = name.endsWith('.css')
|
|
80
|
+
? 'text/css'
|
|
81
|
+
: name.endsWith('.js')
|
|
82
|
+
? 'application/javascript'
|
|
83
|
+
: 'application/octet-stream'
|
|
84
|
+
assets.set(name, { content, contentType })
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Inline assets directly into the HTML to make it self-contained
|
|
89
|
+
for (const [name, asset] of assets) {
|
|
90
|
+
const assetText = new TextDecoder().decode(asset.content)
|
|
91
|
+
if (name.endsWith('.css')) {
|
|
92
|
+
html = html.replace(
|
|
93
|
+
new RegExp(`<link[^>]*href="\\./${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"[^>]*/?>`),
|
|
94
|
+
`<style>${assetText}</style>`,
|
|
95
|
+
)
|
|
96
|
+
} else if (name.endsWith('.js')) {
|
|
97
|
+
html = html.replace(
|
|
98
|
+
new RegExp(`<script[^>]*src="\\./${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"[^>]*>[^<]*</script>`),
|
|
99
|
+
`<script type="module">${assetText}</script>`,
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
errorPageHtml = html
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── Env masking ──────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
const SENSITIVE_KEYS = /SECRET|KEY|TOKEN|PASSWORD|API|PRIVATE/i
|
|
110
|
+
|
|
111
|
+
function maskValue(value: string): string {
|
|
112
|
+
const show = 3
|
|
113
|
+
if (value.length <= show * 2 + 3) return '***'
|
|
114
|
+
return value.slice(0, show) + '***' + value.slice(-show)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function maskEnv(env: Record<string, unknown>): Record<string, string> {
|
|
118
|
+
const masked: Record<string, string> = {}
|
|
119
|
+
for (const [key, value] of Object.entries(env)) {
|
|
120
|
+
if (typeof value === 'object') continue // skip bindings
|
|
121
|
+
const strVal = String(value)
|
|
122
|
+
masked[key] = SENSITIVE_KEYS.test(key) ? maskValue(strVal) : strVal
|
|
123
|
+
}
|
|
124
|
+
return masked
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ─── Binding extraction ──────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
function extractBindings(config: WranglerConfig): { name: string; type: string }[] {
|
|
130
|
+
const bindings: { name: string; type: string }[] = []
|
|
131
|
+
for (const kv of config.kv_namespaces ?? []) bindings.push({ name: kv.binding, type: 'KV' })
|
|
132
|
+
for (const r2 of config.r2_buckets ?? []) bindings.push({ name: r2.binding, type: 'R2' })
|
|
133
|
+
for (const d of config.durable_objects?.bindings ?? []) bindings.push({ name: d.name, type: 'Durable Object' })
|
|
134
|
+
for (const wf of config.workflows ?? []) bindings.push({ name: wf.binding, type: 'Workflow' })
|
|
135
|
+
for (const d1 of config.d1_databases ?? []) bindings.push({ name: d1.binding, type: 'D1' })
|
|
136
|
+
for (const p of config.queues?.producers ?? []) bindings.push({ name: p.binding, type: 'Queue' })
|
|
137
|
+
for (const svc of config.services ?? []) bindings.push({ name: svc.binding, type: 'Service' })
|
|
138
|
+
if (config.images) bindings.push({ name: config.images.binding, type: 'Images' })
|
|
139
|
+
if (config.assets?.binding) bindings.push({ name: config.assets.binding, type: 'Assets' })
|
|
140
|
+
return bindings
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ─── Public API ──────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
export async function renderErrorPage(
|
|
146
|
+
error: unknown,
|
|
147
|
+
request: Request,
|
|
148
|
+
env: Record<string, unknown>,
|
|
149
|
+
config: WranglerConfig,
|
|
150
|
+
workerName?: string,
|
|
151
|
+
): Promise<Response> {
|
|
152
|
+
if (!errorPageHtml) {
|
|
153
|
+
return new Response('Internal Server Error', { status: 500 })
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const err = error instanceof Error ? error : new Error(String(error))
|
|
157
|
+
const frames = parseStackFrames(err.stack ?? '')
|
|
158
|
+
// Drop native/node internal frames — they have no readable source and waste enrichment slots
|
|
159
|
+
.filter(f => !f.file.startsWith('native:') && !f.file.startsWith('node:'))
|
|
160
|
+
|
|
161
|
+
// Enrich frames with source code (limit to 20 for performance)
|
|
162
|
+
const framesToEnrich = frames.slice(0, 20)
|
|
163
|
+
await Promise.all(framesToEnrich.map(enrichFrameWithSourceAsync))
|
|
164
|
+
|
|
165
|
+
// Strip cwd prefix from paths for display
|
|
166
|
+
const cwdPrefix = process.cwd() + '/'
|
|
167
|
+
const displayFrames = framesToEnrich.filter(f => f.source).map(f => ({
|
|
168
|
+
...f,
|
|
169
|
+
file: f.file.startsWith(cwdPrefix) ? f.file.slice(cwdPrefix.length) : f.file,
|
|
170
|
+
}))
|
|
171
|
+
|
|
172
|
+
const headers: Record<string, string> = {}
|
|
173
|
+
request.headers.forEach((value, key) => {
|
|
174
|
+
headers[key] = value
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
const data: ErrorPageData = {
|
|
178
|
+
error: {
|
|
179
|
+
name: err.name,
|
|
180
|
+
message: err.message,
|
|
181
|
+
stack: err.stack ?? String(error),
|
|
182
|
+
frames: displayFrames,
|
|
183
|
+
},
|
|
184
|
+
request: {
|
|
185
|
+
method: request.method,
|
|
186
|
+
url: request.url,
|
|
187
|
+
headers,
|
|
188
|
+
},
|
|
189
|
+
env: maskEnv(env),
|
|
190
|
+
bindings: extractBindings(config),
|
|
191
|
+
runtime: {
|
|
192
|
+
bunVersion: Bun.version,
|
|
193
|
+
platform: process.platform,
|
|
194
|
+
arch: process.arch,
|
|
195
|
+
workerName,
|
|
196
|
+
configName: config.name,
|
|
197
|
+
},
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Attach trace data if available
|
|
201
|
+
try {
|
|
202
|
+
const ctx = getActiveContext()
|
|
203
|
+
if (ctx?.traceId) {
|
|
204
|
+
const traceDetail = getTraceStore().getTrace(ctx.traceId)
|
|
205
|
+
if (traceDetail.spans.length > 0) {
|
|
206
|
+
data.trace = {
|
|
207
|
+
traceId: ctx.traceId,
|
|
208
|
+
spanId: ctx.spanId ?? null,
|
|
209
|
+
spans: traceDetail.spans.map(s => ({
|
|
210
|
+
spanId: s.spanId,
|
|
211
|
+
traceId: s.traceId,
|
|
212
|
+
parentSpanId: s.parentSpanId,
|
|
213
|
+
name: s.name,
|
|
214
|
+
status: s.status,
|
|
215
|
+
startTime: s.startTime,
|
|
216
|
+
endTime: s.endTime,
|
|
217
|
+
durationMs: s.durationMs,
|
|
218
|
+
})),
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
} catch {
|
|
223
|
+
// Don't break error page if trace fetch fails
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Persist error to tracing store
|
|
227
|
+
try {
|
|
228
|
+
const ctx = getActiveContext()
|
|
229
|
+
getTraceStore().insertError({
|
|
230
|
+
id: crypto.randomUUID(),
|
|
231
|
+
timestamp: Date.now(),
|
|
232
|
+
errorName: data.error.name,
|
|
233
|
+
errorMessage: data.error.message,
|
|
234
|
+
requestMethod: data.request.method,
|
|
235
|
+
requestUrl: data.request.url,
|
|
236
|
+
workerName: data.runtime.workerName ?? null,
|
|
237
|
+
traceId: ctx?.traceId ?? null,
|
|
238
|
+
spanId: ctx?.spanId ?? null,
|
|
239
|
+
source: 'fetch',
|
|
240
|
+
data: JSON.stringify(data),
|
|
241
|
+
})
|
|
242
|
+
} catch {
|
|
243
|
+
// Don't let persistence failure break the error response
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const wantsHtml = (request.headers.get('Accept') ?? '').includes('text/html')
|
|
247
|
+
|
|
248
|
+
if (wantsHtml && errorPageHtml) {
|
|
249
|
+
const script = `<script>window.__LOPATA_ERROR__ = ${JSON.stringify(data).replace(/</g, '\\u003c')};</script>`
|
|
250
|
+
const html = errorPageHtml.replace('</head>', `${script}\n</head>`)
|
|
251
|
+
|
|
252
|
+
return new Response(html, {
|
|
253
|
+
status: 500,
|
|
254
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Text-only error response for non-HTML clients (curl, fetch, APIs, etc.)
|
|
259
|
+
let text = `${data.error.name}: ${data.error.message}\n`
|
|
260
|
+
if (displayFrames.length > 0) {
|
|
261
|
+
text += '\nStack:\n'
|
|
262
|
+
for (const f of displayFrames) {
|
|
263
|
+
text += ` at ${f.function} (${f.file}:${f.line}:${f.column})\n`
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
text += `\n${data.request.method} ${data.request.url}\n`
|
|
267
|
+
|
|
268
|
+
return new Response(text, {
|
|
269
|
+
status: 500,
|
|
270
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
271
|
+
})
|
|
272
|
+
}
|
package/src/generation.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { CFWebSocket } from './bindings/websocket-pair'
|
|
|
7
7
|
import type { SqliteWorkflowBinding } from './bindings/workflow'
|
|
8
8
|
import type { WranglerConfig } from './config'
|
|
9
9
|
import { getDatabase } from './db'
|
|
10
|
-
import { renderErrorPage } from './error-page
|
|
10
|
+
import { renderErrorPage } from './error-page-render'
|
|
11
11
|
import { ExecutionContext } from './execution-context'
|
|
12
12
|
import { getActiveContext } from './tracing/context'
|
|
13
13
|
import { persistError, setSpanAttribute, startSpan } from './tracing/span'
|
|
@@ -66,8 +66,8 @@ export function devServerPlugin(options: DevServerPluginOptions): Plugin {
|
|
|
66
66
|
const ecMod = await import('../execution-context.ts')
|
|
67
67
|
const spanMod = await import('../tracing/span.ts')
|
|
68
68
|
const ctxMod = await import('../tracing/context.ts')
|
|
69
|
-
const errorPageMod = await import('../error-page
|
|
70
|
-
const dashboardMod = await import('../dashboard
|
|
69
|
+
const errorPageMod = await import('../error-page-render.ts')
|
|
70
|
+
const dashboardMod = await import('../dashboard-serve.ts')
|
|
71
71
|
const apiMod = await import('../api/index.ts')
|
|
72
72
|
const traceMod = await import('../tracing/store.ts')
|
|
73
73
|
|