@edge-base/server 0.1.5 → 0.2.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.
- package/admin-build/_app/immutable/chunks/{7FYfV8UU.js → 2nyN5wuZ.js} +1 -1
- package/admin-build/_app/immutable/chunks/{BnlxEqP4.js → B-WlnirM.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DLc6L4xD.js → B14gOIqE.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DLTo2HQr.js → BKLsgaNT.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CWHVkYdi.js → BSfSfeDG.js} +1 -1
- package/admin-build/_app/immutable/chunks/{COYZ6F_d.js → CN6aakgF.js} +1 -1
- package/admin-build/_app/immutable/chunks/CfPHB4r5.js +1 -0
- package/admin-build/_app/immutable/chunks/{gE9fQ_Ff.js → CkdaVlhQ.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CKIubXVC.js → D43CH5ty.js} +1 -1
- package/admin-build/_app/immutable/chunks/{BPXFNSAT.js → D8Nrx_IG.js} +3 -3
- package/admin-build/_app/immutable/chunks/{DLRcaFHo.js → DP9kmlCd.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CKFIU4S8.js → DgxOZ3uv.js} +1 -1
- package/admin-build/_app/immutable/chunks/{Bsp3uE8m.js → DpuSetmN.js} +1 -1
- package/admin-build/_app/immutable/chunks/{Jxx0jGlP.js → cqSkc6KP.js} +1 -1
- package/admin-build/_app/immutable/chunks/{Cn5ZQY9O.js → mD4EETH_.js} +1 -1
- package/admin-build/_app/immutable/chunks/uboHVq-x.js +1 -0
- package/admin-build/_app/immutable/entry/{app.C4kLStKR.js → app.Dc071f6C.js} +2 -2
- package/admin-build/_app/immutable/entry/start.Bhlxoqtt.js +1 -0
- package/admin-build/_app/immutable/nodes/{0.BywaJfpH.js → 0.CCfcYVV2.js} +1 -1
- package/admin-build/_app/immutable/nodes/{1.s1kW8gyv.js → 1.rMaczUKT.js} +1 -1
- package/admin-build/_app/immutable/nodes/{10.Cr9ml-GD.js → 10.DIOlO4hv.js} +1 -1
- package/admin-build/_app/immutable/nodes/{11.DXTOyMEn.js → 11.WxD9E0Eq.js} +1 -1
- package/admin-build/_app/immutable/nodes/{12.DzhitECA.js → 12.CNcefK3l.js} +1 -1
- package/admin-build/_app/immutable/nodes/{13.U_1VNA5x.js → 13.aAWsqDdR.js} +1 -1
- package/admin-build/_app/immutable/nodes/{14.x0SzmV0P.js → 14.C9hdr3EN.js} +1 -1
- package/admin-build/_app/immutable/nodes/{15.BJI1Z1hk.js → 15.43r5uVx5.js} +1 -1
- package/admin-build/_app/immutable/nodes/{16.CUvRFqLW.js → 16.D519948J.js} +1 -1
- package/admin-build/_app/immutable/nodes/{17.sDFD-Vbm.js → 17.ks4I4yoH.js} +1 -1
- package/admin-build/_app/immutable/nodes/{18.DdoTnAXc.js → 18.ZuNm22dY.js} +1 -1
- package/admin-build/_app/immutable/nodes/{19.CUvRFqLW.js → 19.D519948J.js} +1 -1
- package/admin-build/_app/immutable/nodes/{20.fxudyPKp.js → 20.C9ASlwCn.js} +1 -1
- package/admin-build/_app/immutable/nodes/21.BhSD2EfX.js +1 -0
- package/admin-build/_app/immutable/nodes/{22.CY6ICoyn.js → 22.6k8cg0Pr.js} +1 -1
- package/admin-build/_app/immutable/nodes/{23.DmEaHsZw.js → 23.B9hcFTU-.js} +1 -1
- package/admin-build/_app/immutable/nodes/{24.Cqerdln2.js → 24.OsQM9QtS.js} +1 -1
- package/admin-build/_app/immutable/nodes/25.ClwkdaPp.js +2 -0
- package/admin-build/_app/immutable/nodes/{26.CEzfjTSO.js → 26._-65WG0q.js} +1 -1
- package/admin-build/_app/immutable/nodes/{27.DAeKhnG9.js → 27.J1QASB3b.js} +1 -1
- package/admin-build/_app/immutable/nodes/{28.D_lJZpNR.js → 28.BKP1tVcZ.js} +1 -1
- package/admin-build/_app/immutable/nodes/{29.Dx7iQCpT.js → 29.mqIe62On.js} +1 -1
- package/admin-build/_app/immutable/nodes/{3.CKC_yDnF.js → 3.WkDZWDQC.js} +1 -1
- package/admin-build/_app/immutable/nodes/{30.DcO-bjQZ.js → 30.BRk-4B3j.js} +1 -1
- package/admin-build/_app/immutable/nodes/{31.DesWAzIF.js → 31.BBqGNVXN.js} +1 -1
- package/admin-build/_app/immutable/nodes/{4.B2rm4_4a.js → 4.Bi91lv2V.js} +1 -1
- package/admin-build/_app/immutable/nodes/{5.4Os1aYeW.js → 5.BumjsbNK.js} +1 -1
- package/admin-build/_app/immutable/nodes/{6.BzR--5Dj.js → 6.CMTP_7xN.js} +1 -1
- package/admin-build/_app/immutable/nodes/{7.QDC-47H1.js → 7.4T4wo7Kg.js} +1 -1
- package/admin-build/_app/immutable/nodes/{8.DDPWEc96.js → 8.MUZQPNsN.js} +1 -1
- package/admin-build/_app/immutable/nodes/{9.fyfVIJPa.js → 9.3SV00WXe.js} +1 -1
- package/admin-build/_app/version.json +1 -1
- package/admin-build/index.html +7 -7
- package/openapi.json +6710 -5866
- package/package.json +2 -2
- package/src/__tests__/functions-route.test.ts +153 -0
- package/src/__tests__/internal-request.test.ts +5 -5
- package/src/__tests__/meta-export-coverage.test.ts +3 -2
- package/src/__tests__/meta-route-registration.test.ts +3 -2
- package/src/__tests__/openapi-coverage.test.ts +5 -1
- package/src/__tests__/rate-limit.test.ts +0 -1
- package/src/__tests__/room-handler-context.test.ts +31 -0
- package/src/__tests__/room-runtime-routing.test.ts +48 -0
- package/src/__tests__/runtime-surface-accounting.test.ts +5 -4
- package/src/__tests__/smoke-skip-report.test.ts +3 -2
- package/src/durable-objects/database-do.ts +6 -1
- package/src/durable-objects/database-live-do.ts +22 -10
- package/src/durable-objects/rooms-do.ts +202 -0
- package/src/lib/internal-request.ts +5 -8
- package/src/lib/openapi.ts +1 -0
- package/src/routes/admin.ts +28 -1
- package/src/routes/auth.ts +67 -33
- package/src/routes/room.ts +42 -0
- package/src/types.ts +6 -0
- package/admin-build/_app/immutable/chunks/DKjA1S1a.js +0 -1
- package/admin-build/_app/immutable/chunks/fBE8lw-R.js +0 -1
- package/admin-build/_app/immutable/entry/start.CAAH6ztW.js +0 -1
- package/admin-build/_app/immutable/nodes/21.N2QN0lbw.js +0 -1
- package/admin-build/_app/immutable/nodes/25.x3hL7Y-O.js +0 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edge-base/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "EdgeBase runtime assets consumed by the EdgeBase CLI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"jose": "^6.0.0",
|
|
35
35
|
"pg": "^8.16.3",
|
|
36
36
|
"zod": "^4.3.6",
|
|
37
|
-
"@edge-base/shared": "0.
|
|
37
|
+
"@edge-base/shared": "0.2.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@cloudflare/vitest-pool-workers": "^0.8.71",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import type { FunctionDefinition } from '@edge-base/shared';
|
|
2
3
|
import { OpenAPIHono } from '../lib/hono.js';
|
|
3
4
|
import { setConfig } from '../lib/do-router.js';
|
|
4
5
|
import {
|
|
@@ -73,6 +74,29 @@ function createExecutionContext(): ExecutionContext {
|
|
|
73
74
|
} as unknown as ExecutionContext;
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
function httpFunction(
|
|
78
|
+
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
|
|
79
|
+
handler: (ctx: Record<string, any>) => Promise<unknown> | unknown,
|
|
80
|
+
path?: string,
|
|
81
|
+
): FunctionDefinition {
|
|
82
|
+
return {
|
|
83
|
+
trigger: {
|
|
84
|
+
type: 'http' as const,
|
|
85
|
+
method,
|
|
86
|
+
...(path ? { path } : {}),
|
|
87
|
+
},
|
|
88
|
+
handler: async (ctx: unknown) => handler(ctx as Record<string, any>),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function invokeFunction(path: string, method = 'GET') {
|
|
93
|
+
return createApp().fetch(
|
|
94
|
+
new Request(`http://localhost/api/functions/${path}`, { method }),
|
|
95
|
+
createEnv(),
|
|
96
|
+
createExecutionContext(),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
76
100
|
afterEach(() => {
|
|
77
101
|
clearFunctionRegistry();
|
|
78
102
|
clearMiddlewareRegistry();
|
|
@@ -137,3 +161,132 @@ describe('functionsRoute FunctionError compatibility', () => {
|
|
|
137
161
|
});
|
|
138
162
|
});
|
|
139
163
|
});
|
|
164
|
+
|
|
165
|
+
describe('functionsRoute HTTP contracts', () => {
|
|
166
|
+
it('serializes plain objects as JSON responses', async () => {
|
|
167
|
+
registerFunction('reports/summary', httpFunction('GET', async () => ({
|
|
168
|
+
ok: true,
|
|
169
|
+
total: 3,
|
|
170
|
+
})));
|
|
171
|
+
rebuildCompiledRoutes();
|
|
172
|
+
|
|
173
|
+
const response = await invokeFunction('reports/summary');
|
|
174
|
+
|
|
175
|
+
expect(response.status).toBe(200);
|
|
176
|
+
expect(response.headers.get('content-type')).toContain('application/json');
|
|
177
|
+
await expect(response.json()).resolves.toEqual({ ok: true, total: 3 });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('returns text/plain when handler returns a string', async () => {
|
|
181
|
+
registerFunction('health', httpFunction('GET', async () => 'healthy'));
|
|
182
|
+
rebuildCompiledRoutes();
|
|
183
|
+
|
|
184
|
+
const response = await invokeFunction('health');
|
|
185
|
+
|
|
186
|
+
expect(response.status).toBe(200);
|
|
187
|
+
expect(response.headers.get('content-type')).toContain('text/plain');
|
|
188
|
+
await expect(response.text()).resolves.toBe('healthy');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('returns 204 when handler returns null', async () => {
|
|
192
|
+
registerFunction('empty', httpFunction('POST', async () => null));
|
|
193
|
+
rebuildCompiledRoutes();
|
|
194
|
+
|
|
195
|
+
const response = await invokeFunction('empty', 'POST');
|
|
196
|
+
|
|
197
|
+
expect(response.status).toBe(204);
|
|
198
|
+
await expect(response.text()).resolves.toBe('');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('passes through native Response objects untouched', async () => {
|
|
202
|
+
registerFunction('created', httpFunction('POST', async () => (
|
|
203
|
+
new Response('created-body', {
|
|
204
|
+
status: 201,
|
|
205
|
+
headers: { 'content-type': 'text/plain', 'x-fn': 'direct-response' },
|
|
206
|
+
})
|
|
207
|
+
)));
|
|
208
|
+
rebuildCompiledRoutes();
|
|
209
|
+
|
|
210
|
+
const response = await invokeFunction('created', 'POST');
|
|
211
|
+
|
|
212
|
+
expect(response.status).toBe(201);
|
|
213
|
+
expect(response.headers.get('x-fn')).toBe('direct-response');
|
|
214
|
+
await expect(response.text()).resolves.toBe('created-body');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('executes directory middleware before the handler', async () => {
|
|
218
|
+
const executionOrder: string[] = [];
|
|
219
|
+
registerMiddleware('secure', async () => {
|
|
220
|
+
executionOrder.push('middleware');
|
|
221
|
+
});
|
|
222
|
+
registerFunction('secure/audit', httpFunction('GET', async () => {
|
|
223
|
+
executionOrder.push('handler');
|
|
224
|
+
return { executionOrder };
|
|
225
|
+
}));
|
|
226
|
+
rebuildCompiledRoutes();
|
|
227
|
+
|
|
228
|
+
const response = await invokeFunction('secure/audit');
|
|
229
|
+
|
|
230
|
+
expect(response.status).toBe(200);
|
|
231
|
+
await expect(response.json()).resolves.toEqual({
|
|
232
|
+
executionOrder: ['middleware', 'handler'],
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('supports custom trigger.path params and preserves query strings', async () => {
|
|
237
|
+
registerFunction(
|
|
238
|
+
'shortlink/resolve',
|
|
239
|
+
httpFunction(
|
|
240
|
+
'GET',
|
|
241
|
+
async (ctx) => {
|
|
242
|
+
const requestUrl = new URL(ctx.request.url);
|
|
243
|
+
return {
|
|
244
|
+
code: ctx.params.code,
|
|
245
|
+
target: requestUrl.searchParams.get('target'),
|
|
246
|
+
};
|
|
247
|
+
},
|
|
248
|
+
'/s/:code',
|
|
249
|
+
),
|
|
250
|
+
);
|
|
251
|
+
rebuildCompiledRoutes();
|
|
252
|
+
|
|
253
|
+
const response = await createApp().fetch(
|
|
254
|
+
new Request('http://localhost/api/functions/s/abc123?target=docs', { method: 'GET' }),
|
|
255
|
+
createEnv(),
|
|
256
|
+
createExecutionContext(),
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
expect(response.status).toBe(200);
|
|
260
|
+
await expect(response.json()).resolves.toEqual({
|
|
261
|
+
code: 'abc123',
|
|
262
|
+
target: 'docs',
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('supports catch-all params at execution time', async () => {
|
|
267
|
+
registerFunction('docs/[...slug]', httpFunction('GET', async (ctx) => ({
|
|
268
|
+
slug: ctx.params.slug,
|
|
269
|
+
})));
|
|
270
|
+
rebuildCompiledRoutes();
|
|
271
|
+
|
|
272
|
+
const response = await invokeFunction('docs/guides/getting-started/install');
|
|
273
|
+
|
|
274
|
+
expect(response.status).toBe(200);
|
|
275
|
+
await expect(response.json()).resolves.toEqual({
|
|
276
|
+
slug: 'guides/getting-started/install',
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('returns 405 with structured JSON when method does not match', async () => {
|
|
281
|
+
registerFunction('users', httpFunction('GET', async () => ({ ok: true })));
|
|
282
|
+
rebuildCompiledRoutes();
|
|
283
|
+
|
|
284
|
+
const response = await invokeFunction('users', 'POST');
|
|
285
|
+
|
|
286
|
+
expect(response.status).toBe(405);
|
|
287
|
+
await expect(response.json()).resolves.toEqual({
|
|
288
|
+
code: 405,
|
|
289
|
+
message: "Method POST not allowed for 'users'.",
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
});
|
|
@@ -21,16 +21,16 @@ function makeContext(
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
describe('internal request helpers', () => {
|
|
24
|
-
it('isTrustedInternalRequestUrl
|
|
25
|
-
expect(isTrustedInternalRequestUrl('http://internal/api/db/shared')).toBe(
|
|
26
|
-
expect(isTrustedInternalRequestUrl('http://do/internal/functions/reindex')).toBe(
|
|
24
|
+
it('isTrustedInternalRequestUrl never trusts hostnames', () => {
|
|
25
|
+
expect(isTrustedInternalRequestUrl('http://internal/api/db/shared')).toBe(false);
|
|
26
|
+
expect(isTrustedInternalRequestUrl('http://do/internal/functions/reindex')).toBe(false);
|
|
27
27
|
expect(isTrustedInternalRequestUrl('http://localhost:8787/api/db/shared')).toBe(false);
|
|
28
28
|
expect(isTrustedInternalRequestUrl('not-a-url')).toBe(false);
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
-
it('isTrustedInternalContext accepts explicit internal flags
|
|
31
|
+
it('isTrustedInternalContext only accepts explicit internal flags', () => {
|
|
32
32
|
expect(isTrustedInternalContext(makeContext('http://localhost/api/db/shared', true))).toBe(true);
|
|
33
|
-
expect(isTrustedInternalContext(makeContext('http://do/api/db/shared'))).toBe(
|
|
33
|
+
expect(isTrustedInternalContext(makeContext('http://do/api/db/shared'))).toBe(false);
|
|
34
34
|
expect(isTrustedInternalContext(makeContext('http://example.com/api/db/shared'))).toBe(false);
|
|
35
35
|
});
|
|
36
36
|
|
|
@@ -9,10 +9,11 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { readFileSync, readdirSync } from 'fs';
|
|
11
11
|
import { resolve } from 'path';
|
|
12
|
+
import { fileURLToPath } from 'url';
|
|
12
13
|
import { describe, it, expect } from 'vitest';
|
|
13
14
|
|
|
14
|
-
const LIB_DIR = resolve(new URL('../lib', import.meta.url)
|
|
15
|
-
const TEST_DIR = resolve(new URL('.', import.meta.url)
|
|
15
|
+
const LIB_DIR = resolve(fileURLToPath(new URL('../lib', import.meta.url)));
|
|
16
|
+
const TEST_DIR = resolve(fileURLToPath(new URL('.', import.meta.url)));
|
|
16
17
|
|
|
17
18
|
// ─── Lib files that do NOT yet have dedicated tests ─────────────────────────
|
|
18
19
|
// Remove entries as tests are added — each removal is a net win.
|
|
@@ -6,14 +6,15 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { readFileSync, readdirSync } from 'fs';
|
|
8
8
|
import { resolve } from 'path';
|
|
9
|
+
import { fileURLToPath } from 'url';
|
|
9
10
|
import { describe, it, expect } from 'vitest';
|
|
10
11
|
|
|
11
12
|
describe('index.ts route registration completeness', () => {
|
|
12
13
|
const source = readFileSync(
|
|
13
|
-
new URL('../index.ts', import.meta.url),
|
|
14
|
+
fileURLToPath(new URL('../index.ts', import.meta.url)),
|
|
14
15
|
'utf-8',
|
|
15
16
|
);
|
|
16
|
-
const routesDir = resolve(new URL('../routes', import.meta.url)
|
|
17
|
+
const routesDir = resolve(fileURLToPath(new URL('../routes', import.meta.url)));
|
|
17
18
|
const EXPECTED_ROUTES = readdirSync(routesDir)
|
|
18
19
|
.filter((fileName) => fileName.endsWith('.ts'))
|
|
19
20
|
.sort()
|
|
@@ -12,10 +12,11 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { readFileSync, readdirSync } from 'fs';
|
|
14
14
|
import { resolve } from 'path';
|
|
15
|
+
import { fileURLToPath } from 'url';
|
|
15
16
|
import { describe, it, expect } from 'vitest';
|
|
16
17
|
import { normalizeOpenApiDocument, type OpenApiSpec } from '../lib/openapi.js';
|
|
17
18
|
|
|
18
|
-
const ROUTES_DIR = resolve(new URL('../routes', import.meta.url)
|
|
19
|
+
const ROUTES_DIR = resolve(fileURLToPath(new URL('../routes', import.meta.url)));
|
|
19
20
|
|
|
20
21
|
// ─── Intentionally non-OpenAPI route registrations ───────────────────────────
|
|
21
22
|
// Format: "filename.ts:<line-content-substring>"
|
|
@@ -118,6 +119,7 @@ describe('OpenAPI route coverage', () => {
|
|
|
118
119
|
'/admin/api/setup': { get: {} },
|
|
119
120
|
'/admin/api/data/users': { get: {} },
|
|
120
121
|
'/api/room/media/realtime/session': { post: {} },
|
|
122
|
+
'/api/room/media/cloudflare_realtimekit/session': { post: {} },
|
|
121
123
|
},
|
|
122
124
|
};
|
|
123
125
|
|
|
@@ -134,6 +136,8 @@ describe('OpenAPI route coverage', () => {
|
|
|
134
136
|
.toEqual([{ userBearerAuth: [] }]);
|
|
135
137
|
expect((normalized.paths?.['/api/room/media/realtime/session'] as Record<string, { security?: unknown }>).post.security)
|
|
136
138
|
.toEqual([{ userBearerAuth: [] }]);
|
|
139
|
+
expect((normalized.paths?.['/api/room/media/cloudflare_realtimekit/session'] as Record<string, { security?: unknown }>).post.security)
|
|
140
|
+
.toEqual([{ userBearerAuth: [] }]);
|
|
137
141
|
expect((normalized.paths?.['/api/sql'] as Record<string, { security?: unknown }>).post.security)
|
|
138
142
|
.toEqual([{ serviceKeyAuth: [] }]);
|
|
139
143
|
expect((normalized.paths?.['/admin/api/setup'] as Record<string, { security?: unknown }>).get.security)
|
|
@@ -127,4 +127,35 @@ describe('RoomsDO handler context', () => {
|
|
|
127
127
|
}),
|
|
128
128
|
);
|
|
129
129
|
});
|
|
130
|
+
|
|
131
|
+
it('returns 409 when creating a Cloudflare RealtimeKit session while media is already published', async () => {
|
|
132
|
+
const { RoomsDO } = await import('../durable-objects/rooms-do.js');
|
|
133
|
+
|
|
134
|
+
const room: any = Object.create(RoomsDO.prototype);
|
|
135
|
+
room.readJsonBody = vi.fn().mockResolvedValue({ connectionId: 'conn-1' });
|
|
136
|
+
room.authenticateRealtimeRequest = vi.fn().mockResolvedValue({
|
|
137
|
+
memberId: 'member-1',
|
|
138
|
+
connectionId: 'conn-1',
|
|
139
|
+
meta: {
|
|
140
|
+
authenticated: true,
|
|
141
|
+
connectionId: 'conn-1',
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
room.hasPublishedTracks = vi.fn().mockReturnValue(true);
|
|
145
|
+
|
|
146
|
+
const response = await room.handleCloudflareRealtimeKitSessionCreate(
|
|
147
|
+
new Request('http://do/media/cloudflare_realtimekit/session?room=game::room-1', {
|
|
148
|
+
method: 'POST',
|
|
149
|
+
body: JSON.stringify({ connectionId: 'conn-1' }),
|
|
150
|
+
headers: { 'Content-Type': 'application/json' },
|
|
151
|
+
}),
|
|
152
|
+
new URL('http://do/media/cloudflare_realtimekit/session?room=game::room-1'),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
expect(response.status).toBe(409);
|
|
156
|
+
await expect(response.json()).resolves.toEqual({
|
|
157
|
+
code: 409,
|
|
158
|
+
message: 'Unpublish existing room media before creating a new Cloudflare RealtimeKit session',
|
|
159
|
+
});
|
|
160
|
+
});
|
|
130
161
|
});
|
|
@@ -219,4 +219,52 @@ describe('room route runtime routing', () => {
|
|
|
219
219
|
expect(doFetch).toHaveBeenCalledTimes(1);
|
|
220
220
|
expect(new URL((doFetch.mock.calls[0][0] as Request).url).searchParams.get('room')).toBe('game::room-1');
|
|
221
221
|
});
|
|
222
|
+
|
|
223
|
+
it('routes room cloudflare realtimekit session requests to the rooms runtime', async () => {
|
|
224
|
+
setConfig(defineConfig({
|
|
225
|
+
rooms: {
|
|
226
|
+
game: {
|
|
227
|
+
runtime: {
|
|
228
|
+
target: 'rooms',
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
}));
|
|
233
|
+
|
|
234
|
+
const doFetch = vi.fn(async (request: Request) => new Response(JSON.stringify({
|
|
235
|
+
runtime: 'rooms',
|
|
236
|
+
path: new URL(request.url).pathname,
|
|
237
|
+
auth: request.headers.get('Authorization'),
|
|
238
|
+
body: await request.clone().json(),
|
|
239
|
+
}), {
|
|
240
|
+
headers: { 'Content-Type': 'application/json' },
|
|
241
|
+
status: 201,
|
|
242
|
+
}));
|
|
243
|
+
|
|
244
|
+
const env = createRoomRuntimeEnv();
|
|
245
|
+
env.ROOMS = {
|
|
246
|
+
idFromName: (name: string) => name as unknown as DurableObjectId,
|
|
247
|
+
get: () => ({ fetch: doFetch }),
|
|
248
|
+
} as unknown as DurableObjectNamespace;
|
|
249
|
+
|
|
250
|
+
const app = createAuthedRoomApp();
|
|
251
|
+
const response = await app.request('/api/room/media/cloudflare_realtimekit/session?namespace=game&id=room-1', {
|
|
252
|
+
method: 'POST',
|
|
253
|
+
headers: {
|
|
254
|
+
Authorization: 'Bearer room-token',
|
|
255
|
+
'Content-Type': 'application/json',
|
|
256
|
+
},
|
|
257
|
+
body: JSON.stringify({ connectionId: 'conn-1' }),
|
|
258
|
+
}, env);
|
|
259
|
+
|
|
260
|
+
expect(response.status).toBe(201);
|
|
261
|
+
await expect(response.json()).resolves.toMatchObject({
|
|
262
|
+
runtime: 'rooms',
|
|
263
|
+
path: '/media/cloudflare_realtimekit/session',
|
|
264
|
+
auth: 'Bearer room-token',
|
|
265
|
+
body: { connectionId: 'conn-1' },
|
|
266
|
+
});
|
|
267
|
+
expect(doFetch).toHaveBeenCalledTimes(1);
|
|
268
|
+
expect(new URL((doFetch.mock.calls[0][0] as Request).url).searchParams.get('room')).toBe('game::room-1');
|
|
269
|
+
});
|
|
222
270
|
});
|
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
* indirectly covered.
|
|
8
8
|
*/
|
|
9
9
|
import { readFileSync, readdirSync } from 'fs';
|
|
10
|
-
import { resolve } from 'path';
|
|
10
|
+
import { basename, relative, resolve } from 'path';
|
|
11
|
+
import { fileURLToPath } from 'url';
|
|
11
12
|
import { describe, expect, it } from 'vitest';
|
|
12
13
|
|
|
13
|
-
const SRC_ROOT = resolve(new URL('..', import.meta.url)
|
|
14
|
+
const SRC_ROOT = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
14
15
|
const PACKAGE_ROOT = resolve(SRC_ROOT, '..');
|
|
15
16
|
const RUNTIME_DIRS = [
|
|
16
17
|
resolve(SRC_ROOT, 'routes'),
|
|
@@ -21,7 +22,7 @@ const TEST_DIRS = [
|
|
|
21
22
|
resolve(SRC_ROOT, '__tests__'),
|
|
22
23
|
resolve(PACKAGE_ROOT, 'test/integration'),
|
|
23
24
|
];
|
|
24
|
-
const THIS_TEST_PATH = resolve(
|
|
25
|
+
const THIS_TEST_PATH = resolve(fileURLToPath(import.meta.url));
|
|
25
26
|
|
|
26
27
|
// Files below are exercised indirectly, but the test sources do not mention the
|
|
27
28
|
// exact filename/stem yet. Tracking them here keeps the gap reviewable.
|
|
@@ -60,7 +61,7 @@ function collectFiles(dir: string, predicate: (path: string) => boolean): string
|
|
|
60
61
|
function toRuntimeKey(absPath: string): string {
|
|
61
62
|
for (const dir of RUNTIME_DIRS) {
|
|
62
63
|
if (absPath.startsWith(dir)) {
|
|
63
|
-
return
|
|
64
|
+
return `${basename(dir)}/${relative(dir, absPath).replace(/\\/g, '/')}`;
|
|
64
65
|
}
|
|
65
66
|
}
|
|
66
67
|
throw new Error(`Unexpected runtime path: ${absPath}`);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from 'fs';
|
|
2
2
|
import { resolve } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
3
4
|
import { describe, expect, it } from 'vitest';
|
|
4
5
|
|
|
5
6
|
interface SmokeSkipEntry {
|
|
@@ -18,7 +19,7 @@ interface SmokeSkipReport {
|
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
const REPORT_PATH = resolve(
|
|
21
|
-
new URL('../../test/integration/generated/smoke-skip-report.json', import.meta.url)
|
|
22
|
+
fileURLToPath(new URL('../../test/integration/generated/smoke-skip-report.json', import.meta.url)),
|
|
22
23
|
);
|
|
23
24
|
|
|
24
25
|
function readReport(): SmokeSkipReport {
|
|
@@ -37,7 +38,7 @@ describe('smoke skip report', () => {
|
|
|
37
38
|
{
|
|
38
39
|
"skippedRouteCount": 0,
|
|
39
40
|
"summaryByReason": {},
|
|
40
|
-
"totalRoutes":
|
|
41
|
+
"totalRoutes": 197,
|
|
41
42
|
}
|
|
42
43
|
`);
|
|
43
44
|
});
|
|
@@ -48,7 +48,7 @@ import {
|
|
|
48
48
|
type FilterTuple,
|
|
49
49
|
} from '../lib/query-engine.js';
|
|
50
50
|
import { summarizeValidationErrors, validateInsert, validateUpdate } from '../lib/validation.js';
|
|
51
|
-
import { hookRejectedError, validationError, notFoundError } from '../lib/errors.js';
|
|
51
|
+
import { hookRejectedError, validationError, notFoundError, normalizeDatabaseError } from '../lib/errors.js';
|
|
52
52
|
import {
|
|
53
53
|
executeDbTriggers,
|
|
54
54
|
getRegisteredFunctions,
|
|
@@ -1936,6 +1936,11 @@ export class DatabaseDO extends DurableObject<DOEnv> {
|
|
|
1936
1936
|
const e = err as { code: number; message: string; data?: unknown };
|
|
1937
1937
|
return c.json({ code: e.code, message: e.message, data: e.data }, e.code as 200);
|
|
1938
1938
|
}
|
|
1939
|
+
// Normalize well-known database errors (e.g. UNIQUE constraint violations → 409)
|
|
1940
|
+
const normalizedDbError = normalizeDatabaseError(err);
|
|
1941
|
+
if (normalizedDbError) {
|
|
1942
|
+
return c.json(normalizedDbError.toJSON(), normalizedDbError.code as 400);
|
|
1943
|
+
}
|
|
1939
1944
|
console.error('DatabaseDO Error:', err);
|
|
1940
1945
|
return c.json({ code: 500, message: 'Internal server error.' }, 500);
|
|
1941
1946
|
});
|
|
@@ -573,11 +573,13 @@ export class DatabaseLiveDO extends DurableObject<DOEnv> {
|
|
|
573
573
|
if (!canRead) continue;
|
|
574
574
|
|
|
575
575
|
let shouldSend = true;
|
|
576
|
-
if (
|
|
576
|
+
if (meta.channelFilters.size > 0 || meta.channelOrFilters.size > 0) {
|
|
577
577
|
const filters = meta.channelFilters.get(batch.channel) || [];
|
|
578
578
|
const orFilters = meta.channelOrFilters.get(batch.channel) || [];
|
|
579
579
|
if (filters.length > 0 || orFilters.length > 0) {
|
|
580
|
-
shouldSend =
|
|
580
|
+
shouldSend = change.data
|
|
581
|
+
? evaluateDatabaseLiveFilters(change.data as Record<string, unknown>, filters, orFilters)
|
|
582
|
+
: true; // DELETE events (null data) pass filters
|
|
581
583
|
}
|
|
582
584
|
}
|
|
583
585
|
|
|
@@ -608,11 +610,13 @@ export class DatabaseLiveDO extends DurableObject<DOEnv> {
|
|
|
608
610
|
if (!canRead) continue;
|
|
609
611
|
|
|
610
612
|
let shouldSend = true;
|
|
611
|
-
if (
|
|
613
|
+
if (meta.channelFilters.size > 0 || meta.channelOrFilters.size > 0) {
|
|
612
614
|
const filters = meta.channelFilters.get(batch.channel) || [];
|
|
613
615
|
const orFilters = meta.channelOrFilters.get(batch.channel) || [];
|
|
614
616
|
if (filters.length > 0 || orFilters.length > 0) {
|
|
615
|
-
shouldSend =
|
|
617
|
+
shouldSend = change.data
|
|
618
|
+
? evaluateDatabaseLiveFilters(change.data as Record<string, unknown>, filters, orFilters)
|
|
619
|
+
: true; // DELETE events (null data) pass filters
|
|
616
620
|
}
|
|
617
621
|
}
|
|
618
622
|
|
|
@@ -727,11 +731,13 @@ export class DatabaseLiveDO extends DurableObject<DOEnv> {
|
|
|
727
731
|
if (!canRead) continue;
|
|
728
732
|
|
|
729
733
|
let shouldSend = true;
|
|
730
|
-
if (
|
|
734
|
+
if ((meta.channelFilters.size > 0 || meta.channelOrFilters.size > 0) && msgChannel) {
|
|
731
735
|
const filters = meta.channelFilters.get(msgChannel) || [];
|
|
732
736
|
const orFilters = meta.channelOrFilters.get(msgChannel) || [];
|
|
733
737
|
if (filters.length > 0 || orFilters.length > 0) {
|
|
734
|
-
shouldSend =
|
|
738
|
+
shouldSend = eventData
|
|
739
|
+
? evaluateDatabaseLiveFilters(eventData, filters, orFilters)
|
|
740
|
+
: true; // DELETE events (null data) pass filters
|
|
735
741
|
}
|
|
736
742
|
}
|
|
737
743
|
|
|
@@ -820,11 +826,14 @@ export class DatabaseLiveDO extends DurableObject<DOEnv> {
|
|
|
820
826
|
const result = await Promise.race([
|
|
821
827
|
Promise.resolve(rule(authCtx as Record<string, unknown> | null, row)),
|
|
822
828
|
new Promise<never>((_, reject) =>
|
|
823
|
-
setTimeout(() => reject(new Error('Database live row access timeout')),
|
|
829
|
+
setTimeout(() => reject(new Error('Database live row access timeout')), 500),
|
|
824
830
|
),
|
|
825
831
|
]);
|
|
826
832
|
return Boolean(result);
|
|
827
|
-
} catch {
|
|
833
|
+
} catch (e) {
|
|
834
|
+
if (e instanceof Error && e.message.includes('timeout')) {
|
|
835
|
+
console.warn('DatabaseLive: row access rule timed out after 500ms');
|
|
836
|
+
}
|
|
828
837
|
return false;
|
|
829
838
|
}
|
|
830
839
|
}
|
|
@@ -848,11 +857,14 @@ export class DatabaseLiveDO extends DurableObject<DOEnv> {
|
|
|
848
857
|
const result = await Promise.race([
|
|
849
858
|
Promise.resolve(tableRules(authCtx as Record<string, unknown> | null, {})),
|
|
850
859
|
new Promise<never>((_, reject) =>
|
|
851
|
-
setTimeout(() => reject(new Error('Database live channel access timeout')),
|
|
860
|
+
setTimeout(() => reject(new Error('Database live channel access timeout')), 500),
|
|
852
861
|
),
|
|
853
862
|
]);
|
|
854
863
|
return Boolean(result);
|
|
855
|
-
} catch {
|
|
864
|
+
} catch (e) {
|
|
865
|
+
if (e instanceof Error && e.message.includes('timeout')) {
|
|
866
|
+
console.warn('DatabaseLive: channel access rule timed out after 500ms');
|
|
867
|
+
}
|
|
856
868
|
return false;
|
|
857
869
|
}
|
|
858
870
|
}
|