@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,137 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { Controller, Get, Query } from '@carno.js/core';
|
|
3
|
+
import { Live } from '../src/decorators/Live';
|
|
4
|
+
import { ResourceRegistry } from '../src/resource/ResourceRegistry';
|
|
5
|
+
import { prefetchLive } from '../src/resource/prefetch';
|
|
6
|
+
import { hydrationKey, readHydrationPayload, toHydrateMap } from '../src/client/hydrate';
|
|
7
|
+
import { storeKey } from '../src/client/core';
|
|
8
|
+
import { fnv1a64 } from '../src/shared/hash';
|
|
9
|
+
import { canonical } from '../src/shared/canonical';
|
|
10
|
+
import { directResourceExecutor } from './resource-registry-helper';
|
|
11
|
+
|
|
12
|
+
@Controller('/cards')
|
|
13
|
+
class CardsController {
|
|
14
|
+
@Get('/')
|
|
15
|
+
@Live({ shared: 'public', key: 'id' })
|
|
16
|
+
list(@Query('done') done?: string) {
|
|
17
|
+
return done === 'true' ? [{ id: 2 }] : [{ id: 1 }, { id: 2 }];
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function registry(): ResourceRegistry {
|
|
22
|
+
const instance = new ResourceRegistry();
|
|
23
|
+
instance.register(CardsController, new CardsController(), directResourceExecutor);
|
|
24
|
+
return instance;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('prefetchLive', () => {
|
|
28
|
+
test('returns the data and the hash the subscription will compare against', async () => {
|
|
29
|
+
const payload = await prefetchLive(registry(), 'CardsController.list');
|
|
30
|
+
|
|
31
|
+
expect(payload.data).toEqual([{ id: 1 }, { id: 2 }]);
|
|
32
|
+
expect(payload.hash).toBe(fnv1a64(canonical([{ id: 1 }, { id: 2 }])));
|
|
33
|
+
expect(payload.resourceId).toBe('CardsController.list');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('passes the inputs to the handler', async () => {
|
|
37
|
+
const payload = await prefetchLive(registry(), 'CardsController.list', { query: { done: 'true' } });
|
|
38
|
+
|
|
39
|
+
expect(payload.data).toEqual([{ id: 2 }]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('normalises the inputs, so the key matches what the client will build', async () => {
|
|
43
|
+
const payload = await prefetchLive(registry(), 'CardsController.list');
|
|
44
|
+
|
|
45
|
+
expect(payload.inputs).toEqual({ params: {}, query: {}, body: undefined });
|
|
46
|
+
expect(hydrationKey(payload)).toBe(storeKey('CardsController.list', { params: {}, query: {} }));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('refuses a resource that is not registered, naming it', async () => {
|
|
50
|
+
await expect(prefetchLive(registry(), 'Nope.thing')).rejects.toThrow(/Nope\.thing/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('does not register an instance anywhere', async () => {
|
|
54
|
+
// The whole point: a rendered page that nobody subscribes to must not
|
|
55
|
+
// leave a live instance behind being recomputed forever.
|
|
56
|
+
const resources = registry();
|
|
57
|
+
await prefetchLive(resources, 'CardsController.list');
|
|
58
|
+
|
|
59
|
+
expect(resources.ids()).toEqual(['CardsController.list']);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('toHydrateMap', () => {
|
|
64
|
+
test('keys payloads exactly the way LiveClient looks them up', async () => {
|
|
65
|
+
const resources = registry();
|
|
66
|
+
const payloads = [
|
|
67
|
+
await prefetchLive(resources, 'CardsController.list'),
|
|
68
|
+
await prefetchLive(resources, 'CardsController.list', { query: { done: 'true' } })
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
const map = toHydrateMap(payloads);
|
|
72
|
+
|
|
73
|
+
expect(Object.keys(map).sort()).toEqual([
|
|
74
|
+
storeKey('CardsController.list', { params: {}, query: {} }),
|
|
75
|
+
storeKey('CardsController.list', { params: {}, query: { done: 'true' } })
|
|
76
|
+
].sort());
|
|
77
|
+
expect(map[storeKey('CardsController.list', { params: {}, query: {} })].data).toEqual([{ id: 1 }, { id: 2 }]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('skips structurally invalid islands and keeps later payloads', () => {
|
|
81
|
+
const valid = {
|
|
82
|
+
resourceId: 'CardsController.list',
|
|
83
|
+
inputs: { params: {}, query: {} },
|
|
84
|
+
data: [{ id: 1 }],
|
|
85
|
+
hash: 'h1'
|
|
86
|
+
};
|
|
87
|
+
const root = {
|
|
88
|
+
querySelectorAll: () => [
|
|
89
|
+
{ textContent: 'null' },
|
|
90
|
+
{ textContent: '{}' },
|
|
91
|
+
{ textContent: JSON.stringify({ ...valid, inputs: null }) },
|
|
92
|
+
{ textContent: JSON.stringify(valid) }
|
|
93
|
+
]
|
|
94
|
+
} as unknown as ParentNode;
|
|
95
|
+
|
|
96
|
+
expect(readHydrationPayload(root)).toEqual(toHydrateMap([valid]));
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('hydration end to end', () => {
|
|
101
|
+
test('a hydrated client subscribes with the hash and never shows pending', async () => {
|
|
102
|
+
const { LiveClient } = await import('../src/client/core');
|
|
103
|
+
const payload = await prefetchLive(registry(), 'CardsController.list');
|
|
104
|
+
|
|
105
|
+
const sent: string[] = [];
|
|
106
|
+
const socket = {
|
|
107
|
+
sent,
|
|
108
|
+
send(data: string) { sent.push(data); },
|
|
109
|
+
close() {},
|
|
110
|
+
onopen: null as (() => void) | null,
|
|
111
|
+
onmessage: null as ((event: { data: string }) => void) | null,
|
|
112
|
+
onclose: null as (() => void) | null,
|
|
113
|
+
onerror: null as ((error: unknown) => void) | null
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const client = new LiveClient({
|
|
117
|
+
url: 'ws://x/live',
|
|
118
|
+
hydrate: toHydrateMap([payload]),
|
|
119
|
+
socketFactory: () => socket as any
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const store = client.store('CardsController.list', { params: {}, query: {} });
|
|
123
|
+
|
|
124
|
+
expect(store.getSnapshot()).toEqual({
|
|
125
|
+
data: [{ id: 1 }, { id: 2 }],
|
|
126
|
+
pending: false,
|
|
127
|
+
error: null,
|
|
128
|
+
stale: false
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
store.subscribe(() => {});
|
|
132
|
+
socket.onopen?.();
|
|
133
|
+
|
|
134
|
+
const sub = JSON.parse(sent.find(raw => raw.includes('"t":"sub"'))!);
|
|
135
|
+
expect(sub.hash).toBe(payload.hash);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { createElement } from 'react';
|
|
3
|
+
import { renderToString } from 'react-dom/server';
|
|
4
|
+
import { LiveClient, type LiveSocket } from '../src/client/core';
|
|
5
|
+
import { LiveProvider, useLive } from '../src/client/react';
|
|
6
|
+
|
|
7
|
+
function silentSocket(): LiveSocket {
|
|
8
|
+
return {
|
|
9
|
+
send() {},
|
|
10
|
+
close() {},
|
|
11
|
+
onopen: null,
|
|
12
|
+
onmessage: null,
|
|
13
|
+
onclose: null,
|
|
14
|
+
onerror: null
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function clientWith(hydrate: Record<string, { data: unknown; hash: string }>): LiveClient {
|
|
19
|
+
return new LiveClient({ url: 'ws://test/live', hydrate, socketFactory: silentSocket });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('useLive', () => {
|
|
23
|
+
test('renders hydrated server state on the first pass, with no round trip', () => {
|
|
24
|
+
const client = clientWith({
|
|
25
|
+
'UsersController.list|{"body":null,"params":{},"query":{"status":"active"}}': {
|
|
26
|
+
data: [{ id: 1, name: 'Ada' }],
|
|
27
|
+
hash: 'h1'
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function UserList() {
|
|
32
|
+
const { data, stale } = useLive<{ id: number; name: string }[]>(
|
|
33
|
+
'UsersController.list',
|
|
34
|
+
{ query: { status: 'active' } }
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
return createElement(
|
|
38
|
+
'ul',
|
|
39
|
+
{ 'data-stale': String(stale) },
|
|
40
|
+
(data ?? []).map(user => createElement('li', { key: user.id }, user.name))
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const html = renderToString(
|
|
45
|
+
createElement(LiveProvider, { client }, createElement(UserList))
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
expect(html).toContain('Ada');
|
|
49
|
+
expect(html).toContain('data-stale="false"');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('renders the pending state when nothing was hydrated', () => {
|
|
53
|
+
const client = clientWith({});
|
|
54
|
+
|
|
55
|
+
function Pending() {
|
|
56
|
+
const { pending } = useLive('UsersController.list');
|
|
57
|
+
return createElement('span', null, pending ? 'loading' : 'ready');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const html = renderToString(createElement(LiveProvider, { client }, createElement(Pending)));
|
|
61
|
+
|
|
62
|
+
expect(html).toContain('loading');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('the same resource and inputs resolve to the same store instance', () => {
|
|
66
|
+
const client = clientWith({});
|
|
67
|
+
const a = client.store('r', { params: {}, query: { x: '1' } });
|
|
68
|
+
const b = client.store('r', { params: {}, query: { x: '1' } });
|
|
69
|
+
const c = client.store('r', { params: {}, query: { x: '2' } });
|
|
70
|
+
|
|
71
|
+
expect(a).toBe(b);
|
|
72
|
+
expect(a).not.toBe(c);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('fails loudly outside a provider', () => {
|
|
76
|
+
function Orphan() {
|
|
77
|
+
useLive('r');
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
expect(() => renderToString(createElement(Orphan))).toThrow(/LiveProvider/);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import './happydom';
|
|
2
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
3
|
+
import { act, cleanup, render } from '@testing-library/react';
|
|
4
|
+
import { createElement, useRef } from 'react';
|
|
5
|
+
import { LiveClient, type LiveSocket } from '../src/client/core';
|
|
6
|
+
import { LiveProvider, useLive } from '../src/client/react';
|
|
7
|
+
|
|
8
|
+
/** A socket the test drives by hand: nothing is sent, everything is injected. */
|
|
9
|
+
function fakeSocket(): LiveSocket & { sent: string[]; deliver: (message: unknown) => void } {
|
|
10
|
+
const socket = {
|
|
11
|
+
sent: [] as string[],
|
|
12
|
+
send(data: string) { socket.sent.push(data); },
|
|
13
|
+
close() {},
|
|
14
|
+
onopen: null as (() => void) | null,
|
|
15
|
+
onmessage: null as ((event: { data: string }) => void) | null,
|
|
16
|
+
onclose: null as (() => void) | null,
|
|
17
|
+
onerror: null as ((error: unknown) => void) | null,
|
|
18
|
+
deliver(message: unknown) { socket.onmessage?.({ data: JSON.stringify(message) }); }
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
return socket;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
afterEach(cleanup);
|
|
25
|
+
|
|
26
|
+
describe('useLive re-rendering', () => {
|
|
27
|
+
test('a snapshot after mount re-renders the component with the new data', async () => {
|
|
28
|
+
const socket = fakeSocket();
|
|
29
|
+
const client = new LiveClient({ url: 'ws://x/live', socketFactory: () => socket });
|
|
30
|
+
|
|
31
|
+
function Cards() {
|
|
32
|
+
const { data, pending } = useLive<{ id: number }[]>('CardsController.list');
|
|
33
|
+
return createElement('div', { 'data-testid': 'out' }, pending ? 'pending' : String(data?.length ?? 0));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const screen = render(createElement(LiveProvider, { client }, createElement(Cards)));
|
|
37
|
+
|
|
38
|
+
expect(screen.getByTestId('out').textContent).toBe('pending');
|
|
39
|
+
|
|
40
|
+
await act(async () => {
|
|
41
|
+
socket.onopen?.();
|
|
42
|
+
socket.deliver({ t: 'snapshot', sid: 's0', rev: 1, hash: 'h1', data: [{ id: 1 }, { id: 2 }] });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
expect(screen.getByTestId('out').textContent).toBe('2');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('a message that establishes nothing new does not re-render', async () => {
|
|
49
|
+
const socket = fakeSocket();
|
|
50
|
+
const client = new LiveClient({ url: 'ws://x/live', socketFactory: () => socket });
|
|
51
|
+
let renders = 0;
|
|
52
|
+
|
|
53
|
+
function Cards() {
|
|
54
|
+
renders += 1;
|
|
55
|
+
const { data } = useLive<{ id: number }[]>('CardsController.list');
|
|
56
|
+
return createElement('div', { 'data-testid': 'out' }, String(data?.length ?? 0));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
render(createElement(LiveProvider, { client }, createElement(Cards)));
|
|
60
|
+
|
|
61
|
+
await act(async () => {
|
|
62
|
+
socket.onopen?.();
|
|
63
|
+
socket.deliver({ t: 'snapshot', sid: 's0', rev: 1, hash: 'h1', data: [{ id: 1 }] });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const afterSnapshot = renders;
|
|
67
|
+
|
|
68
|
+
// Same data, same flags. The store has to keep the identical state
|
|
69
|
+
// object here, or useSyncExternalStore re-renders forever.
|
|
70
|
+
await act(async () => {
|
|
71
|
+
socket.deliver({ t: 'current', sid: 's0', rev: 1, hash: 'h1' });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(renders).toBe(afterSnapshot);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('the data object stays referentially identical across a no-op message', async () => {
|
|
78
|
+
const socket = fakeSocket();
|
|
79
|
+
const client = new LiveClient({ url: 'ws://x/live', socketFactory: () => socket });
|
|
80
|
+
const seen: unknown[] = [];
|
|
81
|
+
|
|
82
|
+
function Cards() {
|
|
83
|
+
const { data } = useLive<{ id: number }[]>('CardsController.list');
|
|
84
|
+
const previous = useRef<unknown>(null);
|
|
85
|
+
|
|
86
|
+
if (data !== previous.current) {
|
|
87
|
+
previous.current = data;
|
|
88
|
+
seen.push(data);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return createElement('div', null, String(data?.length ?? 0));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
render(createElement(LiveProvider, { client }, createElement(Cards)));
|
|
95
|
+
|
|
96
|
+
await act(async () => {
|
|
97
|
+
socket.onopen?.();
|
|
98
|
+
socket.deliver({ t: 'snapshot', sid: 's0', rev: 1, hash: 'h1', data: [{ id: 1 }] });
|
|
99
|
+
socket.deliver({ t: 'current', sid: 's0', rev: 1, hash: 'h1' });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// undefined at mount, then the array. A third entry would mean the
|
|
103
|
+
// store handed out a new object for content that did not change.
|
|
104
|
+
expect(seen.length).toBe(2);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('unmounting releases the subscription after the grace period', async () => {
|
|
108
|
+
const socket = fakeSocket();
|
|
109
|
+
const client = new LiveClient({
|
|
110
|
+
url: 'ws://x/live',
|
|
111
|
+
socketFactory: () => socket,
|
|
112
|
+
unsubGraceMs: 1
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
function Cards() {
|
|
116
|
+
useLive('CardsController.list');
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const screen = render(createElement(LiveProvider, { client }, createElement(Cards)));
|
|
121
|
+
|
|
122
|
+
await act(async () => { socket.onopen?.(); });
|
|
123
|
+
|
|
124
|
+
expect(socket.sent.some(raw => raw.includes('"t":"sub"'))).toBe(true);
|
|
125
|
+
|
|
126
|
+
screen.unmount();
|
|
127
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
128
|
+
|
|
129
|
+
expect(socket.sent.some(raw => raw.includes('"t":"unsub"'))).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ParamMetadata } from '@carno.js/core';
|
|
2
|
+
import type { LiveResourceExecutor } from '../src/resource/types';
|
|
3
|
+
|
|
4
|
+
/** Test-only direct executor for ResourceRegistry unit tests. */
|
|
5
|
+
export const directResourceExecutor: LiveResourceExecutor = async (
|
|
6
|
+
instance,
|
|
7
|
+
resource,
|
|
8
|
+
inputs
|
|
9
|
+
) => instance[resource.handlerName](...buildArgs(resource.params, inputs));
|
|
10
|
+
|
|
11
|
+
function buildArgs(params: ParamMetadata[], inputs: {
|
|
12
|
+
params: Record<string, string>;
|
|
13
|
+
query: Record<string, string | string[]>;
|
|
14
|
+
body?: unknown;
|
|
15
|
+
}): unknown[] {
|
|
16
|
+
if (params.length === 0) {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const args = new Array<unknown>(Math.max(...params.map(param => param.index)) + 1).fill(undefined);
|
|
21
|
+
|
|
22
|
+
for (const param of params) {
|
|
23
|
+
if (param.type === 'param') {
|
|
24
|
+
args[param.index] = param.key ? inputs.params[param.key] : inputs.params;
|
|
25
|
+
} else if (param.type === 'query') {
|
|
26
|
+
args[param.index] = param.key ? inputs.query[param.key] : inputs.query;
|
|
27
|
+
} else if (param.type === 'body') {
|
|
28
|
+
args[param.index] = param.key
|
|
29
|
+
? (inputs.body as Record<string, unknown> | undefined)?.[param.key]
|
|
30
|
+
: inputs.body;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return args;
|
|
35
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { Controller, Ctx, Delete, Get, Param, Query, Req } from '@carno.js/core';
|
|
3
|
+
import { Live } from '../src/decorators/Live';
|
|
4
|
+
import { dependencyContext } from '../src/resource/dependency-context';
|
|
5
|
+
import { LiveValidationError, ResourceRegistry } from '../src/resource/ResourceRegistry';
|
|
6
|
+
import { directResourceExecutor } from './resource-registry-helper';
|
|
7
|
+
|
|
8
|
+
@Controller('/users')
|
|
9
|
+
class UsersController {
|
|
10
|
+
@Get('/')
|
|
11
|
+
@Live({ key: 'id' })
|
|
12
|
+
list(@Query('status') status: string) {
|
|
13
|
+
dependencyContext.current()?.add({ key: 'orm:users', columns: ['status'] });
|
|
14
|
+
return [{ id: 1, status }];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@Get('/:id')
|
|
18
|
+
@Live()
|
|
19
|
+
get(@Param('id') id: string) {
|
|
20
|
+
dependencyContext.current()?.add({ key: `orm:users#${id}`, columns: null });
|
|
21
|
+
return { id };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@Controller('/bad')
|
|
26
|
+
class WritingController {
|
|
27
|
+
@Delete('/:id')
|
|
28
|
+
@Live()
|
|
29
|
+
remove(@Param('id') id: string) {
|
|
30
|
+
return { id };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@Controller('/bad2')
|
|
35
|
+
class RequestController {
|
|
36
|
+
@Get('/')
|
|
37
|
+
@Live()
|
|
38
|
+
read(@Req() req: unknown, @Ctx() ctx: unknown) {
|
|
39
|
+
return { req, ctx };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('ResourceRegistry.register', () => {
|
|
44
|
+
test('registers every @Live handler under controller.handler', () => {
|
|
45
|
+
const registry = new ResourceRegistry();
|
|
46
|
+
registry.register(UsersController, new UsersController(), directResourceExecutor);
|
|
47
|
+
|
|
48
|
+
expect(registry.ids().sort()).toEqual(['UsersController.get', 'UsersController.list']);
|
|
49
|
+
expect(registry.get('UsersController.list')?.meta.key).toBe('id');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('refuses @Live on a verb that is neither GET nor POST', () => {
|
|
53
|
+
const registry = new ResourceRegistry();
|
|
54
|
+
|
|
55
|
+
expect(() => registry.register(WritingController, new WritingController(), directResourceExecutor))
|
|
56
|
+
.toThrow(LiveValidationError);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('refuses request-bound parameters that break recomputability', () => {
|
|
60
|
+
const registry = new ResourceRegistry();
|
|
61
|
+
|
|
62
|
+
expect(() => registry.register(RequestController, new RequestController(), directResourceExecutor))
|
|
63
|
+
.toThrow(/@Req\(\)/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('refuses two resources with the same id', () => {
|
|
67
|
+
const registry = new ResourceRegistry();
|
|
68
|
+
registry.register(UsersController, new UsersController(), directResourceExecutor);
|
|
69
|
+
|
|
70
|
+
expect(() => registry.register(UsersController, new UsersController(), directResourceExecutor))
|
|
71
|
+
.toThrow(/already registered/);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('ResourceRegistry.compute', () => {
|
|
76
|
+
test('resolves query and param arguments from inputs', async () => {
|
|
77
|
+
const registry = new ResourceRegistry();
|
|
78
|
+
registry.register(UsersController, new UsersController(), directResourceExecutor);
|
|
79
|
+
|
|
80
|
+
const list = await registry.compute(registry.get('UsersController.list')!, {
|
|
81
|
+
params: {},
|
|
82
|
+
query: { status: 'active' }
|
|
83
|
+
});
|
|
84
|
+
expect(list.data).toEqual([{ id: 1, status: 'active' }]);
|
|
85
|
+
|
|
86
|
+
const one = await registry.compute(registry.get('UsersController.get')!, {
|
|
87
|
+
params: { id: '42' },
|
|
88
|
+
query: {}
|
|
89
|
+
});
|
|
90
|
+
expect(one.data).toEqual({ id: '42' });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('collects the dependencies registered during the compute', async () => {
|
|
94
|
+
const registry = new ResourceRegistry();
|
|
95
|
+
registry.register(UsersController, new UsersController(), directResourceExecutor);
|
|
96
|
+
|
|
97
|
+
const result = await registry.compute(registry.get('UsersController.get')!, {
|
|
98
|
+
params: { id: '42' },
|
|
99
|
+
query: {}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(result.deps).toEqual([{ key: 'orm:users#42', columns: null }]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('seeds the collector with the declared dependsOn keys', async () => {
|
|
106
|
+
@Controller('/reports')
|
|
107
|
+
class ReportsController {
|
|
108
|
+
@Get('/')
|
|
109
|
+
@Live({ dependsOn: ['app:report:current'] })
|
|
110
|
+
current() {
|
|
111
|
+
return { ok: true };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const registry = new ResourceRegistry();
|
|
116
|
+
registry.register(ReportsController, new ReportsController(), directResourceExecutor);
|
|
117
|
+
|
|
118
|
+
const result = await registry.compute(registry.get('ReportsController.current')!, {
|
|
119
|
+
params: {},
|
|
120
|
+
query: {}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(result.deps).toEqual([{ key: 'app:report:current', columns: null }]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('the collector is inactive outside a compute', () => {
|
|
127
|
+
expect(dependencyContext.isActive()).toBe(false);
|
|
128
|
+
expect(dependencyContext.current()).toBeUndefined();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('concurrent computes do not mix dependencies', async () => {
|
|
132
|
+
const registry = new ResourceRegistry();
|
|
133
|
+
registry.register(UsersController, new UsersController(), directResourceExecutor);
|
|
134
|
+
const resource = registry.get('UsersController.get')!;
|
|
135
|
+
|
|
136
|
+
const [a, b] = await Promise.all([
|
|
137
|
+
registry.compute(resource, { params: { id: '1' }, query: {} }),
|
|
138
|
+
registry.compute(resource, { params: { id: '2' }, query: {} })
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
expect(a.deps).toEqual([{ key: 'orm:users#1', columns: null }]);
|
|
142
|
+
expect(b.deps).toEqual([{ key: 'orm:users#2', columns: null }]);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import {
|
|
4
|
+
Body,
|
|
5
|
+
Controller,
|
|
6
|
+
Get,
|
|
7
|
+
Post,
|
|
8
|
+
Schema,
|
|
9
|
+
Use,
|
|
10
|
+
type MiddlewareHandler
|
|
11
|
+
} from '@carno.js/core';
|
|
12
|
+
import { createTestHarness } from '@carno.js/core';
|
|
13
|
+
import { closeLiveRuntime, Live, LivePlugin, LiveService } from '../src';
|
|
14
|
+
|
|
15
|
+
let controllerCalls = 0;
|
|
16
|
+
const pipelineSteps: string[] = [];
|
|
17
|
+
|
|
18
|
+
const allowHeader: MiddlewareHandler = ctx => {
|
|
19
|
+
pipelineSteps.push('global');
|
|
20
|
+
|
|
21
|
+
if (ctx.headers.get('x-allow') !== 'yes') {
|
|
22
|
+
return new Response('forbidden', { status: 403 });
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function nativeFetch(input: string, init?: RequestInit): Promise<Response> {
|
|
27
|
+
const fetchImpl = (globalThis as { __carnoNativeFetch?: typeof fetch }).__carnoNativeFetch ?? fetch;
|
|
28
|
+
return fetchImpl(input, init);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@Controller('/guarded')
|
|
32
|
+
@Use(ctx => {
|
|
33
|
+
pipelineSteps.push('controller');
|
|
34
|
+
ctx.locals.controller = true;
|
|
35
|
+
})
|
|
36
|
+
class GuardedController {
|
|
37
|
+
@Get('/')
|
|
38
|
+
@Use(ctx => {
|
|
39
|
+
pipelineSteps.push('route');
|
|
40
|
+
ctx.locals.route = true;
|
|
41
|
+
})
|
|
42
|
+
@Live({ shared: 'public' })
|
|
43
|
+
read() {
|
|
44
|
+
pipelineSteps.push('handler');
|
|
45
|
+
controllerCalls += 1;
|
|
46
|
+
return { ok: true };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@Schema(z.object({ name: z.string().min(2) }))
|
|
51
|
+
class GreetingDto {
|
|
52
|
+
name!: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@Controller('/greetings')
|
|
56
|
+
class GreetingController {
|
|
57
|
+
@Post('/search')
|
|
58
|
+
@Live({ shared: 'public' })
|
|
59
|
+
search(@Body() dto: GreetingDto) {
|
|
60
|
+
controllerCalls += 1;
|
|
61
|
+
return { greeting: `Hello, ${dto.name}` };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
afterEach(async () => {
|
|
66
|
+
controllerCalls = 0;
|
|
67
|
+
pipelineSteps.length = 0;
|
|
68
|
+
await closeLiveRuntime();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('Live route pipeline', () => {
|
|
72
|
+
test('prefetch executes middleware and fails closed without route credentials', async () => {
|
|
73
|
+
const harness = await createTestHarness({
|
|
74
|
+
plugins: [LivePlugin.create({
|
|
75
|
+
controllers: [GuardedController],
|
|
76
|
+
config: { coalesceMs: 1 }
|
|
77
|
+
})],
|
|
78
|
+
config: { globalMiddlewares: [allowHeader] },
|
|
79
|
+
listen: true
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const http = await nativeFetch(`http://127.0.0.1:${harness.port}/guarded`, {
|
|
84
|
+
headers: { 'x-allow': 'no' }
|
|
85
|
+
});
|
|
86
|
+
expect(http.status).toBe(403);
|
|
87
|
+
|
|
88
|
+
const live = harness.resolve(LiveService);
|
|
89
|
+
await expect(live.prefetch('GuardedController.read')).rejects.toThrow(/403|forbidden/i);
|
|
90
|
+
expect(controllerCalls).toBe(0);
|
|
91
|
+
|
|
92
|
+
pipelineSteps.length = 0;
|
|
93
|
+
const payload = await live.prefetch(
|
|
94
|
+
'GuardedController.read',
|
|
95
|
+
{},
|
|
96
|
+
{ headers: { 'x-allow': 'yes' } }
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
expect(payload.data).toEqual({ ok: true });
|
|
100
|
+
expect(controllerCalls).toBe(1);
|
|
101
|
+
expect(pipelineSteps).toEqual(['global', 'controller', 'route', 'handler']);
|
|
102
|
+
} finally {
|
|
103
|
+
await harness.close();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('prefetch applies the same DTO validation as the POST route', async () => {
|
|
108
|
+
const harness = await createTestHarness({
|
|
109
|
+
plugins: [LivePlugin.create({
|
|
110
|
+
controllers: [GreetingController],
|
|
111
|
+
config: { coalesceMs: 1 }
|
|
112
|
+
})],
|
|
113
|
+
config: { globalMiddlewares: [allowHeader] },
|
|
114
|
+
listen: true
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const http = await nativeFetch(`http://127.0.0.1:${harness.port}/greetings/search`, {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
headers: {
|
|
121
|
+
'Content-Type': 'application/json',
|
|
122
|
+
'x-allow': 'yes'
|
|
123
|
+
},
|
|
124
|
+
body: JSON.stringify({ name: 'A' })
|
|
125
|
+
});
|
|
126
|
+
expect(http.status).toBe(400);
|
|
127
|
+
|
|
128
|
+
const live = harness.resolve(LiveService);
|
|
129
|
+
await expect(live.prefetch(
|
|
130
|
+
'GreetingController.search',
|
|
131
|
+
{ body: { name: 'A' } },
|
|
132
|
+
{ headers: { 'x-allow': 'yes' } }
|
|
133
|
+
)).rejects.toThrow(/Validation failed/);
|
|
134
|
+
expect(controllerCalls).toBe(0);
|
|
135
|
+
} finally {
|
|
136
|
+
await harness.close();
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
});
|