@carno.js/live 1.8.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/LICENSE +21 -0
- package/dist/LiveEngine.d.ts +89 -0
- package/dist/LiveEngine.js +520 -0
- package/dist/LivePlugin.d.ts +55 -0
- package/dist/LivePlugin.js +165 -0
- package/dist/LiveService.d.ts +32 -0
- package/dist/LiveService.js +51 -0
- package/dist/auth/authorizer.d.ts +33 -0
- package/dist/auth/authorizer.js +32 -0
- package/dist/bus/InProcessBus.d.ts +8 -0
- package/dist/bus/InProcessBus.js +29 -0
- package/dist/bus/InvalidationBus.d.ts +15 -0
- package/dist/bus/InvalidationBus.js +2 -0
- package/dist/bus/PgNotifyBus.d.ts +55 -0
- package/dist/bus/PgNotifyBus.js +138 -0
- package/dist/client/angular.d.ts +19 -0
- package/dist/client/angular.js +71 -0
- package/dist/client/core.d.ts +111 -0
- package/dist/client/core.js +337 -0
- package/dist/client/hydrate.d.ts +19 -0
- package/dist/client/hydrate.js +65 -0
- package/dist/client/optimistic.d.ts +17 -0
- package/dist/client/optimistic.js +2 -0
- package/dist/client/react.d.ts +22 -0
- package/dist/client/react.js +69 -0
- package/dist/client/transport.d.ts +133 -0
- package/dist/client/transport.js +431 -0
- package/dist/client/vanilla.d.ts +54 -0
- package/dist/client/vanilla.js +111 -0
- package/dist/client/vue.d.ts +12 -0
- package/dist/client/vue.js +35 -0
- package/dist/config.d.ts +32 -0
- package/dist/config.js +21 -0
- package/dist/decorators/Live.d.ts +4 -0
- package/dist/decorators/Live.js +17 -0
- package/dist/emitters/AppEmitter.d.ts +20 -0
- package/dist/emitters/AppEmitter.js +59 -0
- package/dist/emitters/pg-listener.d.ts +50 -0
- package/dist/emitters/pg-listener.js +120 -0
- package/dist/emitters/pg-notify-emitter.d.ts +59 -0
- package/dist/emitters/pg-notify-emitter.js +105 -0
- package/dist/emitters/pg-trigger-sql.d.ts +27 -0
- package/dist/emitters/pg-trigger-sql.js +107 -0
- package/dist/emitters/statement-keys.d.ts +16 -0
- package/dist/emitters/statement-keys.js +182 -0
- package/dist/graph/DependencyGraph.d.ts +27 -0
- package/dist/graph/DependencyGraph.js +118 -0
- package/dist/graph/SubscriptionRegistry.d.ts +22 -0
- package/dist/graph/SubscriptionRegistry.js +92 -0
- package/dist/graph/dep-key.d.ts +14 -0
- package/dist/graph/dep-key.js +37 -0
- package/dist/graph/types.d.ts +13 -0
- package/dist/graph/types.js +2 -0
- package/dist/http/etag.d.ts +41 -0
- package/dist/http/etag.js +109 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.js +111 -0
- package/dist/metadata.d.ts +16 -0
- package/dist/metadata.js +4 -0
- package/dist/observability.d.ts +25 -0
- package/dist/observability.js +44 -0
- package/dist/patch/PatchEngine.d.ts +20 -0
- package/dist/patch/PatchEngine.js +180 -0
- package/dist/patch/types.d.ts +33 -0
- package/dist/patch/types.js +2 -0
- package/dist/resource/ResourceRegistry.d.ts +32 -0
- package/dist/resource/ResourceRegistry.js +126 -0
- package/dist/resource/dependency-context.d.ts +25 -0
- package/dist/resource/dependency-context.js +45 -0
- package/dist/resource/instance-id.d.ts +17 -0
- package/dist/resource/instance-id.js +61 -0
- package/dist/resource/prefetch.d.ts +20 -0
- package/dist/resource/prefetch.js +31 -0
- package/dist/resource/route-executor.d.ts +13 -0
- package/dist/resource/route-executor.js +86 -0
- package/dist/resource/types.d.ts +26 -0
- package/dist/resource/types.js +2 -0
- package/dist/runtime.d.ts +41 -0
- package/dist/runtime.js +47 -0
- package/dist/shared/canonical.d.ts +14 -0
- package/dist/shared/canonical.js +60 -0
- package/dist/shared/descriptor.d.ts +44 -0
- package/dist/shared/descriptor.js +19 -0
- package/dist/shared/hash.d.ts +12 -0
- package/dist/shared/hash.js +29 -0
- package/dist/shared/inputs.d.ts +19 -0
- package/dist/shared/inputs.js +2 -0
- package/dist/shared/protocol.d.ts +83 -0
- package/dist/shared/protocol.js +9 -0
- package/dist/transport/FanTransport.d.ts +18 -0
- package/dist/transport/FanTransport.js +29 -0
- package/dist/transport/LiveGateway.d.ts +16 -0
- package/dist/transport/LiveGateway.js +126 -0
- package/dist/transport/SocketTransport.d.ts +18 -0
- package/dist/transport/SocketTransport.js +40 -0
- package/dist/transport/SseTransport.d.ts +30 -0
- package/dist/transport/SseTransport.js +92 -0
- package/dist/transport/scope-resolver.d.ts +20 -0
- package/dist/transport/scope-resolver.js +17 -0
- package/dist/transport/sse-routes.d.ts +24 -0
- package/dist/transport/sse-routes.js +78 -0
- package/package.json +92 -0
- package/src/LiveEngine.ts +730 -0
- package/src/LivePlugin.ts +253 -0
- package/src/LiveService.ts +47 -0
- package/src/auth/authorizer.ts +56 -0
- package/src/bus/InProcessBus.ts +28 -0
- package/src/bus/InvalidationBus.ts +17 -0
- package/src/bus/PgNotifyBus.ts +188 -0
- package/src/client/angular.ts +115 -0
- package/src/client/core.ts +492 -0
- package/src/client/hydrate.ts +80 -0
- package/src/client/optimistic.ts +19 -0
- package/src/client/react.ts +113 -0
- package/src/client/transport.ts +568 -0
- package/src/client/vanilla.ts +165 -0
- package/src/client/vue.ts +67 -0
- package/src/config.ts +49 -0
- package/src/decorators/Live.ts +16 -0
- package/src/emitters/AppEmitter.ts +69 -0
- package/src/emitters/pg-listener.ts +171 -0
- package/src/emitters/pg-notify-emitter.ts +143 -0
- package/src/emitters/pg-trigger-sql.ts +111 -0
- package/src/emitters/statement-keys.ts +229 -0
- package/src/graph/DependencyGraph.ts +147 -0
- package/src/graph/SubscriptionRegistry.ts +109 -0
- package/src/graph/dep-key.ts +41 -0
- package/src/graph/types.ts +15 -0
- package/src/http/etag.ts +144 -0
- package/src/index.ts +81 -0
- package/src/metadata.ts +21 -0
- package/src/observability.ts +54 -0
- package/src/patch/PatchEngine.ts +235 -0
- package/src/patch/types.ts +39 -0
- package/src/resource/ResourceRegistry.ts +178 -0
- package/src/resource/dependency-context.ts +48 -0
- package/src/resource/instance-id.ts +65 -0
- package/src/resource/prefetch.ts +49 -0
- package/src/resource/route-executor.ts +134 -0
- package/src/resource/types.ts +35 -0
- package/src/runtime.ts +69 -0
- package/src/shared/canonical.ts +63 -0
- package/src/shared/descriptor.ts +44 -0
- package/src/shared/hash.ts +31 -0
- package/src/shared/inputs.ts +20 -0
- package/src/shared/protocol.ts +96 -0
- package/src/transport/FanTransport.ts +34 -0
- package/src/transport/LiveGateway.ts +124 -0
- package/src/transport/SocketTransport.ts +43 -0
- package/src/transport/SseTransport.ts +112 -0
- package/src/transport/scope-resolver.ts +25 -0
- package/src/transport/sse-routes.ts +94 -0
- package/test/acceptance-fase-2.test.ts +248 -0
- package/test/acceptance-fase-3.test.ts +216 -0
- package/test/acceptance.test.ts +306 -0
- package/test/angular-adapter.test.ts +151 -0
- package/test/app-emitter.test.ts +112 -0
- package/test/authorization.test.ts +175 -0
- package/test/client-core.test.ts +287 -0
- package/test/client-transport.test.ts +154 -0
- package/test/dependency-graph.test.ts +83 -0
- package/test/etag.test.ts +193 -0
- package/test/fan-transport.test.ts +62 -0
- package/test/happydom.ts +24 -0
- package/test/inputs-body.test.ts +81 -0
- package/test/instance-id.test.ts +104 -0
- package/test/live-engine.test.ts +593 -0
- package/test/live-post.test.ts +81 -0
- package/test/metrics.test.ts +131 -0
- package/test/optimistic.test.ts +166 -0
- package/test/orm-integration.test.ts +75 -0
- package/test/patch-engine.test.ts +121 -0
- package/test/pg-bus.test.ts +162 -0
- package/test/pg-listen-probe.test.ts +56 -0
- package/test/pg-listener.test.ts +134 -0
- package/test/pg-notify-emitter.test.ts +113 -0
- package/test/pg-notify-integration.test.ts +88 -0
- package/test/polling-auth.test.ts +169 -0
- package/test/prefetch.test.ts +137 -0
- package/test/react-adapter.test.tsx +83 -0
- package/test/react-rerender.test.tsx +131 -0
- package/test/resource-registry-helper.ts +35 -0
- package/test/resource-registry.test.ts +144 -0
- package/test/route-pipeline.test.ts +139 -0
- package/test/shared.test.ts +53 -0
- package/test/sse-routes.test.ts +204 -0
- package/test/sse-transport.test.ts +91 -0
- package/test/statement-keys.test.ts +126 -0
- package/test/subscription-registry.test.ts +61 -0
- package/test/transport-ladder.test.ts +293 -0
- package/test/transport.test.ts +274 -0
- package/test/types/optimistic-types.ts +34 -0
- package/test/use-live-action.test.ts +127 -0
- package/test/use-live.test.ts +109 -0
- package/test/vanilla-adapter.test.ts +200 -0
- package/test/vue-adapter.test.ts +122 -0
- package/tsconfig.json +15 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/tsconfig.types.json +10 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { DependencyGraph } from '../src/graph/DependencyGraph';
|
|
3
|
+
|
|
4
|
+
describe('DependencyGraph', () => {
|
|
5
|
+
test('resolves an instance that depends on the exact key', () => {
|
|
6
|
+
const graph = new DependencyGraph();
|
|
7
|
+
graph.setDependencies('i1', [{ key: 'orm:users#42', columns: null }]);
|
|
8
|
+
|
|
9
|
+
expect(graph.resolve({ key: 'orm:users#42', columns: null })).toEqual(['i1']);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('a row write wakes the table subscriber through the ancestor', () => {
|
|
13
|
+
const graph = new DependencyGraph();
|
|
14
|
+
graph.setDependencies('list', [{ key: 'orm:users', columns: null }]);
|
|
15
|
+
graph.setDependencies('detail', [{ key: 'orm:users#42', columns: null }]);
|
|
16
|
+
|
|
17
|
+
expect(graph.resolve({ key: 'orm:users#42', columns: null }).sort()).toEqual(['detail', 'list']);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('a table write wakes row subscribers because it may touch any row', () => {
|
|
21
|
+
const graph = new DependencyGraph();
|
|
22
|
+
graph.setDependencies('detail', [{ key: 'orm:users#42', columns: null }]);
|
|
23
|
+
|
|
24
|
+
expect(graph.resolve({ key: 'orm:users', columns: null })).toEqual(['detail']);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('does not wake a row subscriber for a different row', () => {
|
|
28
|
+
const graph = new DependencyGraph();
|
|
29
|
+
graph.setDependencies('detail', [{ key: 'orm:users#42', columns: null }]);
|
|
30
|
+
|
|
31
|
+
expect(graph.resolve({ key: 'orm:users#7', columns: null })).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('skips an instance whose read columns do not intersect the write', () => {
|
|
35
|
+
const graph = new DependencyGraph();
|
|
36
|
+
graph.setDependencies('i1', [{ key: 'orm:users', columns: ['id', 'name'] }]);
|
|
37
|
+
|
|
38
|
+
expect(graph.resolve({ key: 'orm:users', columns: ['last_seen_at'] })).toEqual([]);
|
|
39
|
+
expect(graph.resolve({ key: 'orm:users', columns: ['name'] })).toEqual(['i1']);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('a wildcard on either side always intersects', () => {
|
|
43
|
+
const graph = new DependencyGraph();
|
|
44
|
+
graph.setDependencies('narrow', [{ key: 'orm:users', columns: ['id'] }]);
|
|
45
|
+
graph.setDependencies('wide', [{ key: 'orm:users', columns: null }]);
|
|
46
|
+
|
|
47
|
+
expect(graph.resolve({ key: 'orm:users', columns: null }).sort()).toEqual(['narrow', 'wide']);
|
|
48
|
+
expect(graph.resolve({ key: 'orm:users', columns: ['zzz'] })).toEqual(['wide']);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('returns each instance once even when several keys match', () => {
|
|
52
|
+
const graph = new DependencyGraph();
|
|
53
|
+
graph.setDependencies('i1', [
|
|
54
|
+
{ key: 'orm:users', columns: null },
|
|
55
|
+
{ key: 'orm:users#42', columns: null }
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
expect(graph.resolve({ key: 'orm:users#42', columns: null })).toEqual(['i1']);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('replacing dependencies drops the previous ones', () => {
|
|
62
|
+
const graph = new DependencyGraph();
|
|
63
|
+
graph.setDependencies('i1', [{ key: 'orm:users', columns: null }]);
|
|
64
|
+
graph.setDependencies('i1', [{ key: 'orm:orders', columns: null }]);
|
|
65
|
+
|
|
66
|
+
expect(graph.resolve({ key: 'orm:users', columns: null })).toEqual([]);
|
|
67
|
+
expect(graph.resolve({ key: 'orm:orders', columns: null })).toEqual(['i1']);
|
|
68
|
+
expect(graph.keyCount()).toBe(1);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('remove clears every key the instance held', () => {
|
|
72
|
+
const graph = new DependencyGraph();
|
|
73
|
+
graph.setDependencies('i1', [
|
|
74
|
+
{ key: 'orm:users', columns: null },
|
|
75
|
+
{ key: 'orm:orders', columns: null }
|
|
76
|
+
]);
|
|
77
|
+
graph.remove('i1');
|
|
78
|
+
|
|
79
|
+
expect(graph.keyCount()).toBe(0);
|
|
80
|
+
expect(graph.instanceCount()).toBe(0);
|
|
81
|
+
expect(graph.resolve({ key: 'orm:users', columns: null })).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { Context, Controller, Get, Post } from '@carno.js/core';
|
|
3
|
+
import { Live } from '../src/decorators/Live';
|
|
4
|
+
import { LiveETagMiddleware, pathMatcher } from '../src/http/etag';
|
|
5
|
+
import { ResourceRegistry } from '../src/resource/ResourceRegistry';
|
|
6
|
+
import { directResourceExecutor } from './resource-registry-helper';
|
|
7
|
+
|
|
8
|
+
const LIVE_PATHS = [
|
|
9
|
+
{ method: 'GET', path: '/cards' },
|
|
10
|
+
{ method: 'GET', path: '/cards/:id' },
|
|
11
|
+
{ method: 'POST', path: '/cards/search' }
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
function contextFor(url: string, headers: Record<string, string> = {}, method = 'GET'): Context {
|
|
15
|
+
return new Context(new Request(url, { method, headers }));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function jsonResponse(body: unknown): Response {
|
|
19
|
+
return new Response(JSON.stringify(body), {
|
|
20
|
+
status: 200,
|
|
21
|
+
headers: { 'Content-Type': 'application/json' }
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('pathMatcher', () => {
|
|
26
|
+
test('a :param matches exactly one segment', () => {
|
|
27
|
+
const matcher = pathMatcher('/cards/:id');
|
|
28
|
+
|
|
29
|
+
expect(matcher.test('/cards/42')).toBe(true);
|
|
30
|
+
expect(matcher.test('/cards')).toBe(false);
|
|
31
|
+
expect(matcher.test('/cards/42/comments')).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('a literal path matches only itself', () => {
|
|
35
|
+
const matcher = pathMatcher('/cards');
|
|
36
|
+
|
|
37
|
+
expect(matcher.test('/cards')).toBe(true);
|
|
38
|
+
expect(matcher.test('/cardsx')).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('LiveETagMiddleware', () => {
|
|
43
|
+
test('adds an ETag to a live GET', async () => {
|
|
44
|
+
const middleware = new LiveETagMiddleware(LIVE_PATHS);
|
|
45
|
+
const response = await middleware.handle(contextFor('http://x/cards'), async () =>
|
|
46
|
+
jsonResponse([{ id: 1 }])
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
expect((response as Response).headers.get('ETag')).toMatch(/^"[0-9a-f]+"$/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('answers 304 with no body when If-None-Match matches', async () => {
|
|
53
|
+
const middleware = new LiveETagMiddleware(LIVE_PATHS);
|
|
54
|
+
const first = await middleware.handle(contextFor('http://x/cards'), async () =>
|
|
55
|
+
jsonResponse([{ id: 1 }])
|
|
56
|
+
) as Response;
|
|
57
|
+
const tag = first.headers.get('ETag')!;
|
|
58
|
+
|
|
59
|
+
const second = await middleware.handle(
|
|
60
|
+
contextFor('http://x/cards', { 'If-None-Match': tag }),
|
|
61
|
+
async () => jsonResponse([{ id: 1 }])
|
|
62
|
+
) as Response;
|
|
63
|
+
|
|
64
|
+
expect(second.status).toBe(304);
|
|
65
|
+
expect(await second.text()).toBe('');
|
|
66
|
+
expect(second.headers.get('ETag')).toBe(tag);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('answers 200 when the content changed', async () => {
|
|
70
|
+
const middleware = new LiveETagMiddleware(LIVE_PATHS);
|
|
71
|
+
const first = await middleware.handle(contextFor('http://x/cards'), async () =>
|
|
72
|
+
jsonResponse([{ id: 1 }])
|
|
73
|
+
) as Response;
|
|
74
|
+
|
|
75
|
+
const second = await middleware.handle(
|
|
76
|
+
contextFor('http://x/cards', { 'If-None-Match': first.headers.get('ETag')! }),
|
|
77
|
+
async () => jsonResponse([{ id: 1 }, { id: 2 }])
|
|
78
|
+
) as Response;
|
|
79
|
+
|
|
80
|
+
expect(second.status).toBe(200);
|
|
81
|
+
expect(await second.json()).toEqual([{ id: 1 }, { id: 2 }]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('the hash is of the canonical content, not of the byte order', async () => {
|
|
85
|
+
const middleware = new LiveETagMiddleware(LIVE_PATHS);
|
|
86
|
+
const ordered = await middleware.handle(contextFor('http://x/cards'), async () =>
|
|
87
|
+
jsonResponse({ a: 1, b: 2 })
|
|
88
|
+
) as Response;
|
|
89
|
+
|
|
90
|
+
const reordered = await middleware.handle(
|
|
91
|
+
contextFor('http://x/cards', { 'If-None-Match': ordered.headers.get('ETag')! }),
|
|
92
|
+
async () => jsonResponse({ b: 2, a: 1 })
|
|
93
|
+
) as Response;
|
|
94
|
+
|
|
95
|
+
// Same content, different key order. A byte hash would send it again.
|
|
96
|
+
expect(reordered.status).toBe(304);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('a path that is not live is left alone', async () => {
|
|
100
|
+
const middleware = new LiveETagMiddleware(LIVE_PATHS);
|
|
101
|
+
const response = await middleware.handle(contextFor('http://x/health'), async () =>
|
|
102
|
+
jsonResponse({ ok: true })
|
|
103
|
+
) as Response;
|
|
104
|
+
|
|
105
|
+
expect(response.headers.get('ETag')).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('a live POST is left alone', async () => {
|
|
109
|
+
const middleware = new LiveETagMiddleware(LIVE_PATHS);
|
|
110
|
+
const response = await middleware.handle(
|
|
111
|
+
contextFor('http://x/cards/search', {}, 'POST'),
|
|
112
|
+
async () => jsonResponse([])
|
|
113
|
+
) as Response;
|
|
114
|
+
|
|
115
|
+
// POST is not cacheable, and conditional polling on it means nothing.
|
|
116
|
+
expect(response.headers.get('ETag')).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('rejects a polling request without its resource id', async () => {
|
|
120
|
+
let downstream = false;
|
|
121
|
+
const middleware = new LiveETagMiddleware([
|
|
122
|
+
{ method: 'GET', path: '/cards', resourceId: 'CardsController.list' }
|
|
123
|
+
]);
|
|
124
|
+
middleware.setPollingGuard(() => ({ principal: 'ada' }));
|
|
125
|
+
|
|
126
|
+
const response = await middleware.handle(
|
|
127
|
+
contextFor('http://x/cards', { 'X-Carno-Live-Poll': '1' }),
|
|
128
|
+
async () => {
|
|
129
|
+
downstream = true;
|
|
130
|
+
return jsonResponse([]);
|
|
131
|
+
}
|
|
132
|
+
) as Response;
|
|
133
|
+
|
|
134
|
+
expect(response.status).toBe(403);
|
|
135
|
+
expect(downstream).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('a non-200 is left alone', async () => {
|
|
139
|
+
const middleware = new LiveETagMiddleware(LIVE_PATHS);
|
|
140
|
+
const response = await middleware.handle(contextFor('http://x/cards'), async () =>
|
|
141
|
+
new Response('nope', { status: 500 })
|
|
142
|
+
) as Response;
|
|
143
|
+
|
|
144
|
+
expect(response.headers.get('ETag')).toBeNull();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('a non-JSON body is left alone', async () => {
|
|
148
|
+
const middleware = new LiveETagMiddleware(LIVE_PATHS);
|
|
149
|
+
const response = await middleware.handle(contextFor('http://x/cards'), async () =>
|
|
150
|
+
new Response('<html></html>', { status: 200, headers: { 'Content-Type': 'text/html' } })
|
|
151
|
+
) as Response;
|
|
152
|
+
|
|
153
|
+
expect(response.headers.get('ETag')).toBeNull();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('the downstream response is still readable by the caller', async () => {
|
|
157
|
+
const middleware = new LiveETagMiddleware(LIVE_PATHS);
|
|
158
|
+
const response = await middleware.handle(contextFor('http://x/cards'), async () =>
|
|
159
|
+
jsonResponse([{ id: 7 }])
|
|
160
|
+
) as Response;
|
|
161
|
+
|
|
162
|
+
// Hashing consumed the body once; the caller has to get a fresh one.
|
|
163
|
+
expect(await response.json()).toEqual([{ id: 7 }]);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('ResourceRegistry.livePaths', () => {
|
|
168
|
+
test('joins the controller prefix with the handler path', () => {
|
|
169
|
+
@Controller('/cards')
|
|
170
|
+
class CardsController {
|
|
171
|
+
@Get('/')
|
|
172
|
+
@Live({ shared: 'public' })
|
|
173
|
+
list() { return []; }
|
|
174
|
+
|
|
175
|
+
@Get('/:id')
|
|
176
|
+
@Live({ shared: 'public' })
|
|
177
|
+
one() { return {}; }
|
|
178
|
+
|
|
179
|
+
@Post('/search')
|
|
180
|
+
@Live({ shared: 'public' })
|
|
181
|
+
search() { return []; }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const registry = new ResourceRegistry();
|
|
185
|
+
registry.register(CardsController, new CardsController(), directResourceExecutor);
|
|
186
|
+
|
|
187
|
+
expect(registry.livePaths().sort((a, b) => a.path.localeCompare(b.path))).toEqual([
|
|
188
|
+
{ method: 'GET', path: '/cards', resourceId: 'CardsController.list' },
|
|
189
|
+
{ method: 'GET', path: '/cards/:id', resourceId: 'CardsController.one' },
|
|
190
|
+
{ method: 'POST', path: '/cards/search', resourceId: 'CardsController.search' }
|
|
191
|
+
]);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { FanTransport, type OwnedTransport } from '../src/transport/FanTransport';
|
|
3
|
+
import type { ServerMessage } from '../src/shared/protocol';
|
|
4
|
+
|
|
5
|
+
function child(prefix: string): OwnedTransport & { sent: { id: string; message: ServerMessage }[] } {
|
|
6
|
+
const sent: { id: string; message: ServerMessage }[] = [];
|
|
7
|
+
|
|
8
|
+
return {
|
|
9
|
+
sent,
|
|
10
|
+
owns: (connectionId: string) => connectionId.startsWith(prefix),
|
|
11
|
+
send(connectionId: string, message: ServerMessage) {
|
|
12
|
+
sent.push({ id: connectionId, message });
|
|
13
|
+
return 1;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const HELLO: ServerMessage = { t: 'current', sid: 's1', rev: 1, hash: 'h' };
|
|
19
|
+
|
|
20
|
+
describe('FanTransport', () => {
|
|
21
|
+
test('delivers to the child that owns the connection', () => {
|
|
22
|
+
const sockets = child('ws:');
|
|
23
|
+
const streams = child('sse:');
|
|
24
|
+
const fan = new FanTransport();
|
|
25
|
+
fan.add(sockets);
|
|
26
|
+
fan.add(streams);
|
|
27
|
+
|
|
28
|
+
fan.send('sse:abc', HELLO);
|
|
29
|
+
|
|
30
|
+
expect(streams.sent.length).toBe(1);
|
|
31
|
+
expect(sockets.sent.length).toBe(0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('an unowned connection is a dropped send, not a throw', () => {
|
|
35
|
+
const fan = new FanTransport();
|
|
36
|
+
fan.add(child('ws:'));
|
|
37
|
+
|
|
38
|
+
// The engine treats <= 0 as back-pressure or drop, and cleans up on
|
|
39
|
+
// its own schedule. Throwing here would take a whole fan-out down.
|
|
40
|
+
expect(fan.send('sse:gone', HELLO)).toBe(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('the first owner wins, so a stale child cannot shadow a live one', () => {
|
|
44
|
+
const first = child('c');
|
|
45
|
+
const second = child('c');
|
|
46
|
+
const fan = new FanTransport();
|
|
47
|
+
fan.add(first);
|
|
48
|
+
fan.add(second);
|
|
49
|
+
|
|
50
|
+
fan.send('c1', HELLO);
|
|
51
|
+
|
|
52
|
+
expect(first.sent.length).toBe(1);
|
|
53
|
+
expect(second.sent.length).toBe(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('reports back-pressure from the child verbatim', () => {
|
|
57
|
+
const fan = new FanTransport();
|
|
58
|
+
fan.add({ owns: () => true, send: () => -1 });
|
|
59
|
+
|
|
60
|
+
expect(fan.send('anything', HELLO)).toBe(-1);
|
|
61
|
+
});
|
|
62
|
+
});
|
package/test/happydom.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { GlobalRegistrator } from '@happy-dom/global-registrator';
|
|
2
|
+
import { afterAll } from 'bun:test';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Native fetch, captured before happy-dom replaces it.
|
|
6
|
+
*
|
|
7
|
+
* HTTP tests that talk to a real Bun.serve (SSE streams, CORS-free POSTs)
|
|
8
|
+
* must use this: happy-dom's fetch enforces same-origin and buffers the body.
|
|
9
|
+
*/
|
|
10
|
+
(globalThis as { __carnoNativeFetch?: typeof fetch }).__carnoNativeFetch = globalThis.fetch;
|
|
11
|
+
(globalThis as { __carnoNativeAbortController?: typeof AbortController }).__carnoNativeAbortController =
|
|
12
|
+
globalThis.AbortController;
|
|
13
|
+
|
|
14
|
+
if (!(globalThis as { document?: unknown }).document) {
|
|
15
|
+
GlobalRegistrator.register();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Do not leak happy-dom's Response/Request constructors into later tests
|
|
19
|
+
// that start a real Bun.serve instance.
|
|
20
|
+
afterAll(() => {
|
|
21
|
+
if ((globalThis as { document?: unknown }).document) {
|
|
22
|
+
GlobalRegistrator.unregister();
|
|
23
|
+
}
|
|
24
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { DEFAULT_LIVE_CONFIG } from '../src/config';
|
|
3
|
+
import { canonicalInputs, instanceIdOf } from '../src/resource/instance-id';
|
|
4
|
+
import { LiveClient, storeKey, type LiveSocket } from '../src/client/core';
|
|
5
|
+
|
|
6
|
+
const LIMIT = DEFAULT_LIVE_CONFIG.maxInputBytes;
|
|
7
|
+
|
|
8
|
+
describe('body as part of the instance identity', () => {
|
|
9
|
+
test('the same body written in a different key order is the same input', () => {
|
|
10
|
+
const first = canonicalInputs(
|
|
11
|
+
{ params: {}, query: {}, body: { status: 'active', page: 2 } },
|
|
12
|
+
LIMIT
|
|
13
|
+
);
|
|
14
|
+
const second = canonicalInputs(
|
|
15
|
+
{ params: {}, query: {}, body: { page: 2, status: 'active' } },
|
|
16
|
+
LIMIT
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
expect(first).toBe(second);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('a different body is a different instance', () => {
|
|
23
|
+
const active = instanceIdOf(
|
|
24
|
+
'ReportsController.run',
|
|
25
|
+
'pub',
|
|
26
|
+
canonicalInputs({ params: {}, query: {}, body: { status: 'active' } }, LIMIT)
|
|
27
|
+
);
|
|
28
|
+
const archived = instanceIdOf(
|
|
29
|
+
'ReportsController.run',
|
|
30
|
+
'pub',
|
|
31
|
+
canonicalInputs({ params: {}, query: {}, body: { status: 'archived' } }, LIMIT)
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
expect(active).not.toBe(archived);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('an absent body and an empty body are the same instance', () => {
|
|
38
|
+
const absent = canonicalInputs({ params: {}, query: {} }, LIMIT);
|
|
39
|
+
const explicitNull = canonicalInputs({ params: {}, query: {}, body: null }, LIMIT);
|
|
40
|
+
|
|
41
|
+
expect(absent).toBe(explicitNull);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('an oversized body is refused before it becomes an instance', () => {
|
|
45
|
+
expect(() =>
|
|
46
|
+
canonicalInputs({ params: {}, query: {}, body: { blob: 'x'.repeat(LIMIT) } }, LIMIT)
|
|
47
|
+
).toThrow(/over the 8192 byte limit/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('a body that cannot be canonicalized is refused', () => {
|
|
51
|
+
expect(() =>
|
|
52
|
+
canonicalInputs({ params: {}, query: {}, body: { when: new Date() } }, LIMIT)
|
|
53
|
+
).toThrow(/not serializable/);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('client store identity', () => {
|
|
58
|
+
class FakeSocket implements LiveSocket {
|
|
59
|
+
onopen: (() => void) | null = null;
|
|
60
|
+
onmessage: ((event: { data: string }) => void) | null = null;
|
|
61
|
+
onclose: (() => void) | null = null;
|
|
62
|
+
onerror: ((error: unknown) => void) | null = null;
|
|
63
|
+
send(): void {}
|
|
64
|
+
close(): void {}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
test('storeKey separates two bodies', () => {
|
|
68
|
+
const one = storeKey('R.run', { params: {}, query: {}, body: { page: 1 } });
|
|
69
|
+
const two = storeKey('R.run', { params: {}, query: {}, body: { page: 2 } });
|
|
70
|
+
|
|
71
|
+
expect(one).not.toBe(two);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('the same body reuses the same store', () => {
|
|
75
|
+
const client = new LiveClient({ url: 'ws://test/live', socketFactory: () => new FakeSocket() });
|
|
76
|
+
const first = client.store('R.run', { params: {}, query: {}, body: { page: 1 } });
|
|
77
|
+
const second = client.store('R.run', { params: {}, query: {}, body: { page: 1 } });
|
|
78
|
+
|
|
79
|
+
expect(first).toBe(second);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { Controller, Get } from '@carno.js/core';
|
|
3
|
+
import { Live } from '../src/decorators/Live';
|
|
4
|
+
import { LIVE_META, type LiveMeta } from '../src/metadata';
|
|
5
|
+
import {
|
|
6
|
+
canonicalInputs,
|
|
7
|
+
InputTooLargeError,
|
|
8
|
+
instanceIdOf,
|
|
9
|
+
MissingScopeError,
|
|
10
|
+
scopeKeyOf
|
|
11
|
+
} from '../src/resource/instance-id';
|
|
12
|
+
|
|
13
|
+
@Controller('/users')
|
|
14
|
+
class UsersController {
|
|
15
|
+
@Get('/')
|
|
16
|
+
@Live({ key: 'id' })
|
|
17
|
+
list() {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@Get('/:id')
|
|
22
|
+
@Live({ shared: 'tenant' })
|
|
23
|
+
get() {
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@Get('/stats')
|
|
28
|
+
plain() {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function metaOf(handler: string): LiveMeta | undefined {
|
|
34
|
+
return Reflect.getMetadata(LIVE_META, UsersController, handler);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('@Live', () => {
|
|
38
|
+
test('defaults to private scope and records the handler name', () => {
|
|
39
|
+
expect(metaOf('list')).toEqual({ key: 'id', shared: 'private', dependsOn: [], handlerName: 'list' });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('carries an explicit shared scope', () => {
|
|
43
|
+
expect(metaOf('get')?.shared).toBe('tenant');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('leaves undecorated handlers alone', () => {
|
|
47
|
+
expect(metaOf('plain')).toBeUndefined();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('scopeKeyOf', () => {
|
|
52
|
+
test('public collapses to a single shared bucket', () => {
|
|
53
|
+
expect(scopeKeyOf('public', {})).toBe('pub');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('tenant and principal are embedded literally, never hashed', () => {
|
|
57
|
+
expect(scopeKeyOf('tenant', { tenant: 'acme' })).toBe('t:acme');
|
|
58
|
+
expect(scopeKeyOf('private', { principal: 42 })).toBe('p:42');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('encodes separators so two tenants cannot forge each other keys', () => {
|
|
62
|
+
expect(scopeKeyOf('tenant', { tenant: 'a|b' })).toBe('t:a%7Cb');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('refuses a scope it cannot resolve rather than falling back', () => {
|
|
66
|
+
expect(() => scopeKeyOf('tenant', {})).toThrow(MissingScopeError);
|
|
67
|
+
expect(() => scopeKeyOf('private', {})).toThrow(MissingScopeError);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('instanceIdOf', () => {
|
|
72
|
+
const inputs = { params: {}, query: { status: 'active' } };
|
|
73
|
+
|
|
74
|
+
test('is stable for the same resource, scope and inputs', () => {
|
|
75
|
+
const a = instanceIdOf('UsersController.list', 'pub', canonicalInputs(inputs, 8192));
|
|
76
|
+
const b = instanceIdOf('UsersController.list', 'pub', canonicalInputs({ query: { status: 'active' }, params: {} }, 8192));
|
|
77
|
+
|
|
78
|
+
expect(a).toBe(b);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('two tenants never share an instance', () => {
|
|
82
|
+
const canonical = canonicalInputs(inputs, 8192);
|
|
83
|
+
const a = instanceIdOf('UsersController.list', scopeKeyOf('tenant', { tenant: 'acme' }), canonical);
|
|
84
|
+
const b = instanceIdOf('UsersController.list', scopeKeyOf('tenant', { tenant: 'globex' }), canonical);
|
|
85
|
+
|
|
86
|
+
expect(a).not.toBe(b);
|
|
87
|
+
expect(a.startsWith('UsersController.list|t:acme|')).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('different inputs produce different instances', () => {
|
|
91
|
+
const a = instanceIdOf('r', 'pub', canonicalInputs({ params: {}, query: { s: 'a' } }, 8192));
|
|
92
|
+
const b = instanceIdOf('r', 'pub', canonicalInputs({ params: {}, query: { s: 'b' } }, 8192));
|
|
93
|
+
|
|
94
|
+
expect(a).not.toBe(b);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('canonicalInputs', () => {
|
|
99
|
+
test('rejects inputs above the ceiling', () => {
|
|
100
|
+
const inputs = { params: {}, query: { q: 'x'.repeat(9000) } };
|
|
101
|
+
|
|
102
|
+
expect(() => canonicalInputs(inputs, 8192)).toThrow(InputTooLargeError);
|
|
103
|
+
});
|
|
104
|
+
});
|