@geekmidas/studio 0.1.0 → 0.4.0

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 (75) hide show
  1. package/dist/{DataBrowser-hGwiTffZ.d.cts → DataBrowser-B-jz8KBR.d.mts} +5 -2
  2. package/dist/DataBrowser-B-jz8KBR.d.mts.map +1 -0
  3. package/dist/{DataBrowser-SOcqmZb2.d.mts → DataBrowser-BTe9HWJy.d.cts} +5 -2
  4. package/dist/DataBrowser-BTe9HWJy.d.cts.map +1 -0
  5. package/dist/{DataBrowser-c-Gs6PZB.cjs → DataBrowser-D8c_pBf4.cjs} +4 -4
  6. package/dist/DataBrowser-D8c_pBf4.cjs.map +1 -0
  7. package/dist/{DataBrowser-DQ3-ZxdV.mjs → DataBrowser-kgcI9ApJ.mjs} +4 -4
  8. package/dist/DataBrowser-kgcI9ApJ.mjs.map +1 -0
  9. package/dist/Studio-CYzz3wD2.d.cts +152 -0
  10. package/dist/Studio-CYzz3wD2.d.cts.map +1 -0
  11. package/dist/Studio-D5yGscb8.d.mts +152 -0
  12. package/dist/Studio-D5yGscb8.d.mts.map +1 -0
  13. package/dist/data/index.cjs +1 -1
  14. package/dist/data/index.d.cts +1 -1
  15. package/dist/data/index.d.mts +1 -1
  16. package/dist/data/index.mjs +1 -1
  17. package/dist/index.cjs +33 -3
  18. package/dist/index.cjs.map +1 -1
  19. package/dist/index.d.cts +4 -131
  20. package/dist/index.d.mts +4 -131
  21. package/dist/index.mjs +33 -3
  22. package/dist/index.mjs.map +1 -1
  23. package/dist/server/hono.cjs +168 -21
  24. package/dist/server/hono.cjs.map +1 -1
  25. package/dist/server/hono.d.cts +13 -2
  26. package/dist/server/hono.d.cts.map +1 -0
  27. package/dist/server/hono.d.mts +13 -2
  28. package/dist/server/hono.d.mts.map +1 -0
  29. package/dist/server/hono.mjs +168 -21
  30. package/dist/server/hono.mjs.map +1 -1
  31. package/dist/types-BZv87Ikv.mjs.map +1 -1
  32. package/dist/types-CMttUZYk.cjs.map +1 -1
  33. package/package.json +14 -6
  34. package/src/Studio.ts +341 -292
  35. package/src/__tests__/Studio.spec.ts +447 -0
  36. package/src/data/DataBrowser.ts +147 -143
  37. package/src/data/__tests__/DataBrowser.integration.spec.ts +404 -404
  38. package/src/data/__tests__/filtering.integration.spec.ts +726 -726
  39. package/src/data/__tests__/introspection.integration.spec.ts +340 -340
  40. package/src/data/__tests__/pagination.spec.ts +123 -0
  41. package/src/data/filtering.ts +154 -154
  42. package/src/data/introspection.ts +141 -141
  43. package/src/data/pagination.ts +15 -15
  44. package/src/index.ts +22 -24
  45. package/src/server/__tests__/hono.integration.spec.ts +605 -347
  46. package/src/server/hono.ts +392 -190
  47. package/src/types.ts +138 -138
  48. package/src/ui-assets.ts +10 -13
  49. package/tsconfig.json +9 -0
  50. package/tsdown.config.ts +9 -9
  51. package/ui/package.json +28 -22
  52. package/ui/src/App.tsx +95 -235
  53. package/ui/src/api.ts +184 -42
  54. package/ui/src/components/FilterPanel.tsx +198 -198
  55. package/ui/src/components/NavRail.tsx +183 -0
  56. package/ui/src/components/RowDetail.tsx +106 -106
  57. package/ui/src/components/StudioHeader.tsx +109 -0
  58. package/ui/src/components/TableList.tsx +49 -49
  59. package/ui/src/components/TableView.tsx +530 -485
  60. package/ui/src/main.tsx +3 -3
  61. package/ui/src/pages/DashboardPage.tsx +500 -0
  62. package/ui/src/pages/DatabasePage.tsx +226 -0
  63. package/ui/src/pages/EndpointDetailsPage.tsx +288 -0
  64. package/ui/src/pages/ExceptionsPage.tsx +268 -0
  65. package/ui/src/pages/LogsPage.tsx +228 -0
  66. package/ui/src/pages/MonitoringPage.tsx +46 -0
  67. package/ui/src/pages/PerformancePage.tsx +307 -0
  68. package/ui/src/pages/RequestsPage.tsx +379 -0
  69. package/ui/src/providers/StudioProvider.tsx +194 -0
  70. package/ui/src/styles.css +53 -142
  71. package/ui/src/types.ts +154 -30
  72. package/ui/tsconfig.tsbuildinfo +1 -1
  73. package/ui/vite.config.ts +6 -6
  74. package/dist/DataBrowser-DQ3-ZxdV.mjs.map +0 -1
  75. package/dist/DataBrowser-c-Gs6PZB.cjs.map +0 -1
@@ -1,20 +1,33 @@
1
- import { Hono } from 'hono';
2
1
  import type { Context } from 'hono';
2
+ import { Hono } from 'hono';
3
3
  import type { DataBrowser } from '../data/DataBrowser';
4
+ import type { Studio } from '../Studio';
4
5
  import {
5
- Direction,
6
- type FilterCondition,
7
- FilterOperator,
8
- type SortConfig,
6
+ Direction,
7
+ type FilterCondition,
8
+ FilterOperator,
9
+ type SortConfig,
9
10
  } from '../types';
10
11
  import { getAsset, getIndexHtml } from '../ui-assets';
11
12
 
12
13
  /**
13
14
  * Interface for the Studio instance used by the Hono adapter.
14
- * Only requires the data browser - monitoring routes are separate.
15
15
  */
16
16
  export interface StudioLike {
17
- data: DataBrowser<unknown>;
17
+ data: DataBrowser<unknown>;
18
+ // Monitoring methods
19
+ getRequests: Studio<unknown>['getRequests'];
20
+ getRequest: Studio<unknown>['getRequest'];
21
+ getExceptions: Studio<unknown>['getExceptions'];
22
+ getException: Studio<unknown>['getException'];
23
+ getLogs: Studio<unknown>['getLogs'];
24
+ getStats: Studio<unknown>['getStats'];
25
+ // Metrics methods
26
+ getMetrics: Studio<unknown>['getMetrics'];
27
+ getEndpointMetrics: Studio<unknown>['getEndpointMetrics'];
28
+ getEndpointDetails: Studio<unknown>['getEndpointDetails'];
29
+ getStatusDistribution: Studio<unknown>['getStatusDistribution'];
30
+ resetMetrics: Studio<unknown>['resetMetrics'];
18
31
  }
19
32
 
20
33
  /**
@@ -23,42 +36,45 @@ export interface StudioLike {
23
36
  * Example: filter[name][eq]=John&filter[age][gt]=18
24
37
  */
25
38
  function parseFilters(c: Context): FilterCondition[] {
26
- const filters: FilterCondition[] = [];
27
- const url = new URL(c.req.url);
28
-
29
- url.searchParams.forEach((value, key) => {
30
- const match = key.match(/^filter\[(\w+)\]\[(\w+)\]$/);
31
- if (match) {
32
- const [, column, operator] = match;
33
- const op = operator as FilterOperator;
34
-
35
- // Validate operator
36
- if (!Object.values(FilterOperator).includes(op)) {
37
- return;
38
- }
39
-
40
- // Handle special cases
41
- if (op === FilterOperator.In || op === FilterOperator.Nin) {
42
- filters.push({ column, operator: op, value: value.split(',') });
43
- } else if (
44
- op === FilterOperator.IsNull ||
45
- op === FilterOperator.IsNotNull
46
- ) {
47
- filters.push({ column, operator: op });
48
- } else {
49
- // Try to parse as number or boolean
50
- let parsedValue: unknown = value;
51
- if (value === 'true') parsedValue = true;
52
- else if (value === 'false') parsedValue = false;
53
- else if (!isNaN(Number(value)) && value !== '')
54
- parsedValue = Number(value);
55
-
56
- filters.push({ column, operator: op, value: parsedValue });
57
- }
58
- }
59
- });
60
-
61
- return filters;
39
+ const filters: FilterCondition[] = [];
40
+ const url = new URL(c.req.url);
41
+
42
+ url.searchParams.forEach((value, key) => {
43
+ const match = key.match(/^filter\[(\w+)\]\[(\w+)\]$/);
44
+ if (match) {
45
+ const column = match[1];
46
+ const operator = match[2];
47
+ if (!column || !operator) return;
48
+
49
+ const op = operator as FilterOperator;
50
+
51
+ // Validate operator
52
+ if (!Object.values(FilterOperator).includes(op)) {
53
+ return;
54
+ }
55
+
56
+ // Handle special cases
57
+ if (op === FilterOperator.In || op === FilterOperator.Nin) {
58
+ filters.push({ column, operator: op, value: value.split(',') });
59
+ } else if (
60
+ op === FilterOperator.IsNull ||
61
+ op === FilterOperator.IsNotNull
62
+ ) {
63
+ filters.push({ column, operator: op });
64
+ } else {
65
+ // Try to parse as number or boolean
66
+ let parsedValue: unknown = value;
67
+ if (value === 'true') parsedValue = true;
68
+ else if (value === 'false') parsedValue = false;
69
+ else if (!Number.isNaN(Number(value)) && value !== '')
70
+ parsedValue = Number(value);
71
+
72
+ filters.push({ column, operator: op, value: parsedValue });
73
+ }
74
+ }
75
+ });
76
+
77
+ return filters;
62
78
  }
63
79
 
64
80
  /**
@@ -67,159 +83,345 @@ function parseFilters(c: Context): FilterCondition[] {
67
83
  * Example: sort=name:asc,created_at:desc
68
84
  */
69
85
  function parseSort(c: Context): SortConfig[] {
70
- const sortParam = c.req.query('sort');
71
- if (!sortParam) return [];
72
-
73
- return sortParam.split(',').map((part) => {
74
- const [column, dir] = part.split(':');
75
- return {
76
- column,
77
- direction: dir === 'desc' ? Direction.Desc : Direction.Asc,
78
- };
79
- });
86
+ const sortParam = c.req.query('sort');
87
+ if (!sortParam) return [];
88
+
89
+ return sortParam
90
+ .split(',')
91
+ .map((part) => {
92
+ const parts = part.split(':');
93
+ const column = parts[0];
94
+ const dir = parts[1];
95
+ if (!column) return null;
96
+ return {
97
+ column,
98
+ direction: dir === 'desc' ? Direction.Desc : Direction.Asc,
99
+ };
100
+ })
101
+ .filter((s): s is SortConfig => s !== null);
102
+ }
103
+
104
+ /**
105
+ * Parse query options for monitoring endpoints.
106
+ */
107
+ function parseQueryOptions(c: Context) {
108
+ const limit = parseInt(c.req.query('limit') || '50', 10);
109
+ const offset = parseInt(c.req.query('offset') || '0', 10);
110
+ const search = c.req.query('search');
111
+ const before = c.req.query('before');
112
+ const after = c.req.query('after');
113
+ const tags = c.req.query('tags')?.split(',').filter(Boolean);
114
+ const method = c.req.query('method');
115
+ const status = c.req.query('status');
116
+ const level = c.req.query('level') as
117
+ | 'debug'
118
+ | 'info'
119
+ | 'warn'
120
+ | 'error'
121
+ | undefined;
122
+
123
+ return {
124
+ limit: Math.min(limit, 100),
125
+ offset,
126
+ search,
127
+ before: before ? new Date(before) : undefined,
128
+ after: after ? new Date(after) : undefined,
129
+ tags,
130
+ method: method || undefined,
131
+ status: status || undefined,
132
+ level: level || undefined,
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Parse metrics query options from query parameters.
138
+ */
139
+ function parseMetricsQueryOptions(c: Context) {
140
+ const start = c.req.query('start');
141
+ const end = c.req.query('end');
142
+ const bucketSize = c.req.query('bucketSize');
143
+ const limit = c.req.query('limit');
144
+
145
+ return {
146
+ range:
147
+ start && end ? { start: new Date(start), end: new Date(end) } : undefined,
148
+ bucketSize: bucketSize ? parseInt(bucketSize, 10) : undefined,
149
+ limit: limit ? parseInt(limit, 10) : undefined,
150
+ };
80
151
  }
81
152
 
82
153
  /**
83
154
  * Create Hono app with Studio API routes and dashboard UI.
84
155
  */
85
156
  export function createStudioApp(studio: StudioLike): Hono {
86
- const app = new Hono();
87
-
88
- // ============================================
89
- // Schema API
90
- // ============================================
91
-
92
- /**
93
- * GET /api/schema
94
- * Get the complete database schema
95
- */
96
- app.get('/api/schema', async (c) => {
97
- const forceRefresh = c.req.query('refresh') === 'true';
98
- const schema = await studio.data.getSchema(forceRefresh);
99
- return c.json(schema);
100
- });
101
-
102
- /**
103
- * GET /api/tables
104
- * List all tables with basic info
105
- */
106
- app.get('/api/tables', async (c) => {
107
- const schema = await studio.data.getSchema();
108
- const tables = schema.tables.map((t) => ({
109
- name: t.name,
110
- schema: t.schema,
111
- columnCount: t.columns.length,
112
- primaryKey: t.primaryKey,
113
- estimatedRowCount: t.estimatedRowCount,
114
- }));
115
- return c.json({ tables });
116
- });
117
-
118
- /**
119
- * GET /api/tables/:name
120
- * Get detailed information about a specific table
121
- */
122
- app.get('/api/tables/:name', async (c) => {
123
- const tableName = c.req.param('name');
124
- const tableInfo = await studio.data.getTableInfo(tableName);
125
-
126
- if (!tableInfo) {
127
- return c.json({ error: `Table '${tableName}' not found` }, 404);
128
- }
129
-
130
- return c.json(tableInfo);
131
- });
132
-
133
- /**
134
- * GET /api/tables/:name/rows
135
- * Query table data with pagination, filtering, and sorting
136
- *
137
- * Query parameters:
138
- * - pageSize: number (default: 50, max: 100)
139
- * - cursor: string (pagination cursor)
140
- * - filter[column][operator]=value (e.g., filter[status][eq]=active)
141
- * - sort=column:direction (e.g., sort=created_at:desc)
142
- */
143
- app.get('/api/tables/:name/rows', async (c) => {
144
- const tableName = c.req.param('name');
145
- const pageSize = Math.min(
146
- parseInt(c.req.query('pageSize') || '50', 10),
147
- 100,
148
- );
149
- const cursor = c.req.query('cursor') || undefined;
150
- const filters = parseFilters(c);
151
- const sort = parseSort(c);
152
-
153
- try {
154
- const result = await studio.data.query({
155
- table: tableName,
156
- pageSize,
157
- cursor,
158
- filters: filters.length > 0 ? filters : undefined,
159
- sort: sort.length > 0 ? sort : undefined,
160
- });
161
-
162
- return c.json(result);
163
- } catch (error) {
164
- if (error instanceof Error && error.message.includes('not found')) {
165
- return c.json({ error: error.message }, 404);
166
- }
167
- throw error;
168
- }
169
- });
170
-
171
- // ============================================
172
- // Static Assets & Dashboard UI
173
- // ============================================
174
-
175
- // Static assets
176
- app.get('/assets/:filename', (c) => {
177
- const filename = c.req.param('filename');
178
- const assetPath = `assets/${filename}`;
179
- const asset = getAsset(assetPath);
180
- if (asset) {
181
- return c.body(asset.content, 200, {
182
- 'Content-Type': asset.contentType,
183
- 'Cache-Control': 'public, max-age=31536000, immutable',
184
- });
185
- }
186
- return c.notFound();
187
- });
188
-
189
- // Dashboard UI - serve React app
190
- app.get('/', (c) => {
191
- const html = getIndexHtml();
192
- if (!html) {
193
- return c.json({
194
- message: 'Studio API is running',
195
- note: 'UI not available. Run "pnpm build:ui" first.',
196
- endpoints: {
197
- schema: '/api/schema',
198
- tables: '/api/tables',
199
- tableInfo: '/api/tables/:name',
200
- tableRows: '/api/tables/:name/rows',
201
- },
202
- });
203
- }
204
- return c.html(html);
205
- });
206
-
207
- // SPA fallback - serve index.html for client-side routing
208
- app.get('/*', (c) => {
209
- // Skip API routes
210
- if (c.req.path.startsWith('/api/')) {
211
- return c.notFound();
212
- }
213
-
214
- const html = getIndexHtml();
215
- if (!html) {
216
- return c.notFound();
217
- }
218
- return c.html(html);
219
- });
220
-
221
- return app;
157
+ const app = new Hono();
158
+
159
+ // ============================================
160
+ // Database API
161
+ // ============================================
162
+
163
+ /**
164
+ * GET /api/schema
165
+ * Get the complete database schema
166
+ */
167
+ app.get('/api/schema', async (c) => {
168
+ const forceRefresh = c.req.query('refresh') === 'true';
169
+ const schema = await studio.data.getSchema(forceRefresh);
170
+ return c.json(schema);
171
+ });
172
+
173
+ /**
174
+ * GET /api/tables
175
+ * List all tables with basic info
176
+ */
177
+ app.get('/api/tables', async (c) => {
178
+ const schema = await studio.data.getSchema();
179
+ const tables = schema.tables.map((t) => ({
180
+ name: t.name,
181
+ schema: t.schema,
182
+ columnCount: t.columns.length,
183
+ primaryKey: t.primaryKey,
184
+ estimatedRowCount: t.estimatedRowCount,
185
+ }));
186
+ return c.json({ tables });
187
+ });
188
+
189
+ /**
190
+ * GET /api/tables/:name
191
+ * Get detailed information about a specific table
192
+ */
193
+ app.get('/api/tables/:name', async (c) => {
194
+ const tableName = c.req.param('name');
195
+ const tableInfo = await studio.data.getTableInfo(tableName);
196
+
197
+ if (!tableInfo) {
198
+ return c.json({ error: `Table '${tableName}' not found` }, 404);
199
+ }
200
+
201
+ return c.json(tableInfo);
202
+ });
203
+
204
+ /**
205
+ * GET /api/tables/:name/rows
206
+ * Query table data with pagination, filtering, and sorting
207
+ */
208
+ app.get('/api/tables/:name/rows', async (c) => {
209
+ const tableName = c.req.param('name');
210
+ const pageSize = Math.min(
211
+ parseInt(c.req.query('pageSize') || '50', 10),
212
+ 100,
213
+ );
214
+ const cursor = c.req.query('cursor') || undefined;
215
+ const filters = parseFilters(c);
216
+ const sort = parseSort(c);
217
+
218
+ try {
219
+ const result = await studio.data.query({
220
+ table: tableName,
221
+ pageSize,
222
+ cursor,
223
+ filters: filters.length > 0 ? filters : undefined,
224
+ sort: sort.length > 0 ? sort : undefined,
225
+ });
226
+
227
+ return c.json(result);
228
+ } catch (error) {
229
+ if (error instanceof Error && error.message.includes('not found')) {
230
+ return c.json({ error: error.message }, 404);
231
+ }
232
+ throw error;
233
+ }
234
+ });
235
+
236
+ // ============================================
237
+ // Monitoring API
238
+ // ============================================
239
+
240
+ /**
241
+ * GET /api/stats
242
+ * Get storage statistics
243
+ */
244
+ app.get('/api/stats', async (c) => {
245
+ const stats = await studio.getStats();
246
+ return c.json(stats);
247
+ });
248
+
249
+ /**
250
+ * GET /api/requests
251
+ * Get request entries
252
+ */
253
+ app.get('/api/requests', async (c) => {
254
+ const options = parseQueryOptions(c);
255
+ const requests = await studio.getRequests(options);
256
+ return c.json(requests);
257
+ });
258
+
259
+ /**
260
+ * GET /api/requests/:id
261
+ * Get a single request by ID
262
+ */
263
+ app.get('/api/requests/:id', async (c) => {
264
+ const request = await studio.getRequest(c.req.param('id'));
265
+ if (!request) {
266
+ return c.json({ error: 'Request not found' }, 404);
267
+ }
268
+ return c.json(request);
269
+ });
270
+
271
+ /**
272
+ * GET /api/exceptions
273
+ * Get exception entries
274
+ */
275
+ app.get('/api/exceptions', async (c) => {
276
+ const options = parseQueryOptions(c);
277
+ const exceptions = await studio.getExceptions(options);
278
+ return c.json(exceptions);
279
+ });
280
+
281
+ /**
282
+ * GET /api/exceptions/:id
283
+ * Get a single exception by ID
284
+ */
285
+ app.get('/api/exceptions/:id', async (c) => {
286
+ const exception = await studio.getException(c.req.param('id'));
287
+ if (!exception) {
288
+ return c.json({ error: 'Exception not found' }, 404);
289
+ }
290
+ return c.json(exception);
291
+ });
292
+
293
+ /**
294
+ * GET /api/logs
295
+ * Get log entries
296
+ */
297
+ app.get('/api/logs', async (c) => {
298
+ const options = parseQueryOptions(c);
299
+ const logs = await studio.getLogs(options);
300
+ return c.json(logs);
301
+ });
302
+
303
+ // ============================================
304
+ // Metrics API
305
+ // ============================================
306
+
307
+ /**
308
+ * GET /api/metrics
309
+ * Get aggregated request metrics
310
+ */
311
+ app.get('/api/metrics', (c) => {
312
+ const options = parseMetricsQueryOptions(c);
313
+ const metrics = studio.getMetrics(options);
314
+ return c.json(metrics);
315
+ });
316
+
317
+ /**
318
+ * GET /api/metrics/endpoints
319
+ * Get metrics grouped by endpoint
320
+ */
321
+ app.get('/api/metrics/endpoints', (c) => {
322
+ const options = parseMetricsQueryOptions(c);
323
+ const endpoints = studio.getEndpointMetrics(options);
324
+ return c.json(endpoints);
325
+ });
326
+
327
+ /**
328
+ * GET /api/metrics/endpoint
329
+ * Get detailed metrics for a specific endpoint
330
+ */
331
+ app.get('/api/metrics/endpoint', (c) => {
332
+ const method = c.req.query('method');
333
+ const path = c.req.query('path');
334
+
335
+ if (!method || !path) {
336
+ return c.json({ error: 'method and path are required' }, 400);
337
+ }
338
+
339
+ const options = parseMetricsQueryOptions(c);
340
+ const details = studio.getEndpointDetails(method, path, options);
341
+
342
+ if (!details) {
343
+ return c.json({ error: 'Endpoint not found' }, 404);
344
+ }
345
+
346
+ return c.json(details);
347
+ });
348
+
349
+ /**
350
+ * GET /api/metrics/status
351
+ * Get HTTP status code distribution
352
+ */
353
+ app.get('/api/metrics/status', (c) => {
354
+ const options = parseMetricsQueryOptions(c);
355
+ const distribution = studio.getStatusDistribution(options);
356
+ return c.json(distribution);
357
+ });
358
+
359
+ /**
360
+ * DELETE /api/metrics
361
+ * Reset all metrics
362
+ */
363
+ app.delete('/api/metrics', (c) => {
364
+ studio.resetMetrics();
365
+ return c.json({ success: true });
366
+ });
367
+
368
+ // ============================================
369
+ // Static Assets & Dashboard UI
370
+ // ============================================
371
+
372
+ // Static assets
373
+ app.get('/assets/:filename', (c) => {
374
+ const filename = c.req.param('filename');
375
+ const assetPath = `assets/${filename}`;
376
+ const asset = getAsset(assetPath);
377
+ if (asset) {
378
+ return c.body(asset.content, 200, {
379
+ 'Content-Type': asset.contentType,
380
+ 'Cache-Control': 'public, max-age=31536000, immutable',
381
+ });
382
+ }
383
+ return c.notFound();
384
+ });
385
+
386
+ // Dashboard UI - serve React app
387
+ app.get('/', (c) => {
388
+ const html = getIndexHtml();
389
+ if (!html) {
390
+ return c.json({
391
+ message: 'Studio API is running',
392
+ note: 'UI not available. Run "pnpm build:ui" first.',
393
+ endpoints: {
394
+ schema: '/api/schema',
395
+ tables: '/api/tables',
396
+ tableInfo: '/api/tables/:name',
397
+ tableRows: '/api/tables/:name/rows',
398
+ stats: '/api/stats',
399
+ requests: '/api/requests',
400
+ exceptions: '/api/exceptions',
401
+ logs: '/api/logs',
402
+ metrics: '/api/metrics',
403
+ },
404
+ });
405
+ }
406
+ return c.html(html);
407
+ });
408
+
409
+ // SPA fallback - serve index.html for client-side routing
410
+ app.get('/*', (c) => {
411
+ // Return 404 JSON for API routes
412
+ if (c.req.path.startsWith('/api/')) {
413
+ return c.json({ error: 'Not found' }, 404);
414
+ }
415
+
416
+ const html = getIndexHtml();
417
+ if (!html) {
418
+ return c.notFound();
419
+ }
420
+ return c.html(html);
421
+ });
422
+
423
+ return app;
222
424
  }
223
425
 
224
426
  // Re-export types
225
- export type { StudioLike, DataBrowser };
427
+ export type { DataBrowser };