@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,178 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import { CONTROLLER_META, PARAMS_META, ROUTES_META, type ParamMetadata } from '@carno.js/core';
|
|
3
|
+
import type { Dependency } from '../graph/types';
|
|
4
|
+
import { LIVE_META, type LiveMeta } from '../metadata';
|
|
5
|
+
import { dependencyContext } from './dependency-context';
|
|
6
|
+
import type {
|
|
7
|
+
LiveExecutionContext,
|
|
8
|
+
LiveInputs,
|
|
9
|
+
LiveResource,
|
|
10
|
+
LiveResourceExecutor
|
|
11
|
+
} from './types';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Verbs that may carry @Live. The real criterion is idempotence, not the verb:
|
|
15
|
+
* subscribing means re-running the handler whenever the data changes, and
|
|
16
|
+
* re-running a write duplicates the side effect. GET and POST are the two the
|
|
17
|
+
* web uses for reading; a PUT that only reads is an abuse of the protocol and
|
|
18
|
+
* is not worth the API surface.
|
|
19
|
+
*/
|
|
20
|
+
const ALLOWED_METHODS = new Set(['get', 'post']);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parameters that would make the handler depend on a caller request rather
|
|
24
|
+
* than its declared, replayable inputs. Middleware still receives a synthetic
|
|
25
|
+
* request during every compute and may use it as a guard.
|
|
26
|
+
*/
|
|
27
|
+
const FORBIDDEN_PARAMS: Record<string, string> = {
|
|
28
|
+
req: '@Req()',
|
|
29
|
+
ctx: '@Ctx()',
|
|
30
|
+
header: '@Header()',
|
|
31
|
+
locals: '@Locals()'
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export class LiveValidationError extends Error {
|
|
35
|
+
constructor(message: string) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = 'LiveValidationError';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface RouteInfoLike {
|
|
42
|
+
method: string;
|
|
43
|
+
path: string;
|
|
44
|
+
handlerName: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class ResourceRegistry {
|
|
48
|
+
private readonly resources = new Map<string, LiveResource>();
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Scan a controller for @Live handlers and validate them.
|
|
52
|
+
*
|
|
53
|
+
* Validation runs at registration, which is bootstrap time: the core
|
|
54
|
+
* compiles everything at startup, so a misdeclared resource fails the boot
|
|
55
|
+
* instead of failing the first subscription in production.
|
|
56
|
+
*/
|
|
57
|
+
register(
|
|
58
|
+
ControllerClass: new (...args: any[]) => any,
|
|
59
|
+
instance: any,
|
|
60
|
+
executor: LiveResourceExecutor
|
|
61
|
+
): void {
|
|
62
|
+
const routes: RouteInfoLike[] = Reflect.getMetadata(ROUTES_META, ControllerClass) || [];
|
|
63
|
+
const controllerMeta: { path?: string } = Reflect.getMetadata(CONTROLLER_META, ControllerClass) || {};
|
|
64
|
+
const prefix = controllerMeta.path ?? '';
|
|
65
|
+
|
|
66
|
+
for (const route of routes) {
|
|
67
|
+
const meta: LiveMeta | undefined = Reflect.getMetadata(
|
|
68
|
+
LIVE_META,
|
|
69
|
+
ControllerClass,
|
|
70
|
+
route.handlerName
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
if (!meta) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const id = `${ControllerClass.name}.${route.handlerName}`;
|
|
78
|
+
const where = `${ControllerClass.name}.${route.handlerName}()`;
|
|
79
|
+
|
|
80
|
+
if (!ALLOWED_METHODS.has(route.method)) {
|
|
81
|
+
throw new LiveValidationError(
|
|
82
|
+
`${where} is decorated with @Live() on @${route.method.toUpperCase()}(). ` +
|
|
83
|
+
`Subscribing means re-running the handler whenever the data changes, so it has ` +
|
|
84
|
+
`to be idempotent. Only @Get() and @Post() may be live.`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const params: ParamMetadata[] =
|
|
89
|
+
Reflect.getMetadata(PARAMS_META, ControllerClass, route.handlerName) || [];
|
|
90
|
+
|
|
91
|
+
for (const type of ['req', 'ctx', 'header', 'locals']) {
|
|
92
|
+
const param = params.find(candidate => candidate.type === type);
|
|
93
|
+
const forbidden = param ? FORBIDDEN_PARAMS[param.type] : undefined;
|
|
94
|
+
|
|
95
|
+
if (forbidden) {
|
|
96
|
+
throw new LiveValidationError(
|
|
97
|
+
`${where} uses ${forbidden}, which is not a replayable live input. ` +
|
|
98
|
+
`A live resource handler must be a pure function of its declared inputs.`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (route.method === 'get' && params.some(param => param.type === 'body')) {
|
|
105
|
+
throw new LiveValidationError(
|
|
106
|
+
`${where} uses @Body() on @Get(). A GET subscription carries no body; ` +
|
|
107
|
+
`declare the route as @Post() or read the value from @Query().`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (meta.key !== undefined && (typeof meta.key !== 'string' || meta.key === '')) {
|
|
112
|
+
throw new LiveValidationError(`${where} declares an empty @Live({ key }).`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (this.resources.has(id)) {
|
|
116
|
+
throw new LiveValidationError(`Live resource "${id}" is already registered.`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let resource: LiveResource;
|
|
120
|
+
|
|
121
|
+
resource = {
|
|
122
|
+
id,
|
|
123
|
+
controllerClass: ControllerClass,
|
|
124
|
+
controllerName: ControllerClass.name,
|
|
125
|
+
handlerName: route.handlerName,
|
|
126
|
+
meta,
|
|
127
|
+
params,
|
|
128
|
+
invoke: (inputs: LiveInputs, context: LiveExecutionContext = {}) =>
|
|
129
|
+
executor(instance, resource, inputs, context),
|
|
130
|
+
httpPath: joinRoutePath(prefix, route.path),
|
|
131
|
+
httpMethod: route.method.toUpperCase()
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
this.resources.set(id, resource);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
get(id: string): LiveResource | undefined {
|
|
139
|
+
return this.resources.get(id);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
ids(): string[] {
|
|
143
|
+
return [...this.resources.keys()];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Every live route, as the HTTP layer addresses it. */
|
|
147
|
+
livePaths(): { method: string; path: string; resourceId: string }[] {
|
|
148
|
+
return [...this.resources.values()].map(resource => ({
|
|
149
|
+
method: resource.httpMethod,
|
|
150
|
+
path: resource.httpPath,
|
|
151
|
+
resourceId: resource.id
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Run the handler and report what it read. */
|
|
156
|
+
async compute(
|
|
157
|
+
resource: LiveResource,
|
|
158
|
+
inputs: LiveInputs,
|
|
159
|
+
context: LiveExecutionContext = {}
|
|
160
|
+
): Promise<{ data: unknown; deps: Dependency[] }> {
|
|
161
|
+
const { result, deps } = await dependencyContext.run(collector => {
|
|
162
|
+
for (const key of resource.meta.dependsOn) {
|
|
163
|
+
collector.add({ key, columns: null });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return resource.invoke(inputs, context);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
return { data: result, deps };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Same join the core router does: collapse the slashes, keep the root. */
|
|
174
|
+
export function joinRoutePath(prefix: string, path: string): string {
|
|
175
|
+
const joined = `${prefix}${path}`.replace(/\/{2,}/g, '/');
|
|
176
|
+
|
|
177
|
+
return joined.length > 1 ? joined.replace(/\/$/, '') : (joined || '/');
|
|
178
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'async_hooks';
|
|
2
|
+
import type { Dependency } from '../graph/types';
|
|
3
|
+
|
|
4
|
+
export class DependencyCollector {
|
|
5
|
+
private readonly deps: Dependency[] = [];
|
|
6
|
+
|
|
7
|
+
add(dep: Dependency): void {
|
|
8
|
+
this.deps.push(dep);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
addAll(deps: Dependency[]): void {
|
|
12
|
+
for (const dep of deps) {
|
|
13
|
+
this.deps.push(dep);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
drain(): Dependency[] {
|
|
18
|
+
return this.deps.slice();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Collects the reads performed during one resource compute.
|
|
24
|
+
*
|
|
25
|
+
* Same AsyncLocalStorage shape as identityMapContext, tenantContext and
|
|
26
|
+
* transactionContext in @carno.js/orm — concurrent computes each get their own
|
|
27
|
+
* collector without threading a parameter through user code.
|
|
28
|
+
*/
|
|
29
|
+
class DependencyContext {
|
|
30
|
+
private readonly storage = new AsyncLocalStorage<DependencyCollector>();
|
|
31
|
+
|
|
32
|
+
async run<T>(fn: (collector: DependencyCollector) => Promise<T> | T): Promise<{ result: T; deps: Dependency[] }> {
|
|
33
|
+
const collector = new DependencyCollector();
|
|
34
|
+
const result = await this.storage.run(collector, async () => fn(collector));
|
|
35
|
+
|
|
36
|
+
return { result, deps: collector.drain() };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
current(): DependencyCollector | undefined {
|
|
40
|
+
return this.storage.getStore();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
isActive(): boolean {
|
|
44
|
+
return this.storage.getStore() !== undefined;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const dependencyContext = new DependencyContext();
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { canonical } from '../shared/canonical';
|
|
2
|
+
import { fnv1a64 } from '../shared/hash';
|
|
3
|
+
import type { LiveShared } from '../metadata';
|
|
4
|
+
import type { LiveInputs, LiveScope } from '../shared/inputs';
|
|
5
|
+
|
|
6
|
+
export class MissingScopeError extends Error {
|
|
7
|
+
constructor(public readonly dimension: 'tenant' | 'principal') {
|
|
8
|
+
super(
|
|
9
|
+
`Live resource requires a ${dimension} in scope but none was resolved. ` +
|
|
10
|
+
'Register a LiveScopeResolver, or declare the resource as @Live({ shared: \'public\' }).'
|
|
11
|
+
);
|
|
12
|
+
this.name = 'MissingScopeError';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class InputTooLargeError extends Error {
|
|
17
|
+
constructor(public readonly size: number, public readonly limit: number) {
|
|
18
|
+
super(`Live subscription inputs are ${size} bytes, over the ${limit} byte limit.`);
|
|
19
|
+
this.name = 'InputTooLargeError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The scope half of the instance identity, embedded literally and encoded. */
|
|
24
|
+
export function scopeKeyOf(shared: LiveShared, scope: LiveScope): string {
|
|
25
|
+
if (shared === 'public') {
|
|
26
|
+
return 'pub';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (shared === 'tenant') {
|
|
30
|
+
if (scope.tenant === undefined || scope.tenant === null || scope.tenant === '') {
|
|
31
|
+
throw new MissingScopeError('tenant');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return `t:${encodeURIComponent(String(scope.tenant))}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (scope.principal === undefined || scope.principal === null || scope.principal === '') {
|
|
38
|
+
throw new MissingScopeError('principal');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return `p:${encodeURIComponent(String(scope.principal))}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Canonical form of inputs, guarded by the size ceiling. */
|
|
45
|
+
export function canonicalInputs(inputs: LiveInputs, maxInputBytes: number): string {
|
|
46
|
+
const encoded = canonical({
|
|
47
|
+
params: inputs.params ?? {},
|
|
48
|
+
query: inputs.query ?? {},
|
|
49
|
+
// `canonical` renders undefined and null identically, so a GET (no
|
|
50
|
+
// body) and a POST with an empty body land on the same string.
|
|
51
|
+
body: inputs.body ?? null
|
|
52
|
+
});
|
|
53
|
+
const size = Buffer.byteLength(encoded, 'utf8');
|
|
54
|
+
|
|
55
|
+
if (size > maxInputBytes) {
|
|
56
|
+
throw new InputTooLargeError(size, maxInputBytes);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return encoded;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Same resource, scope and inputs means one compute, one diff and N sends. */
|
|
63
|
+
export function instanceIdOf(resourceId: string, scopeKey: string, canonicalInputsValue: string): string {
|
|
64
|
+
return `${resourceId}|${scopeKey}|${fnv1a64(canonicalInputsValue)}`;
|
|
65
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { canonical } from '../shared/canonical';
|
|
2
|
+
import { normalizeLiveInputs } from '../shared/descriptor';
|
|
3
|
+
import { fnv1a64 } from '../shared/hash';
|
|
4
|
+
import type { LiveExecutionContext, LiveInputs } from './types';
|
|
5
|
+
import type { ResourceRegistry } from './ResourceRegistry';
|
|
6
|
+
|
|
7
|
+
/** What a server-rendered page hands the client so the first paint is full. */
|
|
8
|
+
export interface LivePayload {
|
|
9
|
+
resourceId: string;
|
|
10
|
+
inputs: LiveInputs;
|
|
11
|
+
data: unknown;
|
|
12
|
+
hash: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Compute a live resource once, for the first paint.
|
|
17
|
+
*
|
|
18
|
+
* Deliberately not a subscription: nothing is registered in the dependency
|
|
19
|
+
* graph and no instance is created. Every rendered page would otherwise leave
|
|
20
|
+
* behind an instance being recomputed forever, including the ones nobody ever
|
|
21
|
+
* subscribes to -- the worst possible cost for the most common case. The
|
|
22
|
+
* instance is born when a client subscribes, and the hash returned here is
|
|
23
|
+
* what makes that subscription carry no data.
|
|
24
|
+
*/
|
|
25
|
+
export async function prefetchLive(
|
|
26
|
+
resources: ResourceRegistry,
|
|
27
|
+
resourceId: string,
|
|
28
|
+
inputs: Partial<LiveInputs> = {},
|
|
29
|
+
context: LiveExecutionContext = {}
|
|
30
|
+
): Promise<LivePayload> {
|
|
31
|
+
const resource = resources.get(resourceId);
|
|
32
|
+
|
|
33
|
+
if (!resource) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`[carno:live] cannot prefetch "${resourceId}": no live resource by that name. ` +
|
|
36
|
+
`Is its controller listed in LivePlugin.create({ controllers })?`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const normalized = normalizeLiveInputs(inputs);
|
|
41
|
+
const { data } = await resources.compute(resource, normalized, context);
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
resourceId,
|
|
45
|
+
inputs: normalized,
|
|
46
|
+
data,
|
|
47
|
+
hash: fnv1a64(canonical(data))
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HttpException,
|
|
3
|
+
ValidationException,
|
|
4
|
+
type Carno
|
|
5
|
+
} from '@carno.js/core';
|
|
6
|
+
import type {
|
|
7
|
+
LiveExecutionContext,
|
|
8
|
+
LiveInputs,
|
|
9
|
+
LiveResource,
|
|
10
|
+
LiveResourceExecutor
|
|
11
|
+
} from './types';
|
|
12
|
+
|
|
13
|
+
export class LiveRouteExecutionError extends Error {
|
|
14
|
+
constructor(
|
|
15
|
+
public readonly statusCode: number,
|
|
16
|
+
message: string
|
|
17
|
+
) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = 'LiveRouteExecutionError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isLiveAuthorizationFailure(error: unknown): boolean {
|
|
24
|
+
return error instanceof LiveRouteExecutionError &&
|
|
25
|
+
(error.statusCode === 401 || error.statusCode === 403);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Adapt the core's compiled HTTP route to a Live resource compute. The
|
|
30
|
+
* adapter is created once at startup; only the synthetic request and response
|
|
31
|
+
* decoding happen per compute.
|
|
32
|
+
*/
|
|
33
|
+
export function createLiveRouteExecutor(carno: Carno): LiveResourceExecutor {
|
|
34
|
+
return async (
|
|
35
|
+
_controllerInstance: any,
|
|
36
|
+
resource: LiveResource,
|
|
37
|
+
inputs: LiveInputs,
|
|
38
|
+
context: LiveExecutionContext
|
|
39
|
+
): Promise<unknown> => {
|
|
40
|
+
const request = createRequest(resource, inputs, context);
|
|
41
|
+
let response: Response;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
response = await carno.executeCompiledRoute(
|
|
45
|
+
resource.controllerClass,
|
|
46
|
+
resource.handlerName,
|
|
47
|
+
request,
|
|
48
|
+
inputs.params
|
|
49
|
+
);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (error instanceof HttpException || error instanceof ValidationException) {
|
|
52
|
+
response = error.toResponse();
|
|
53
|
+
} else {
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (response.status < 200 || response.status >= 300) {
|
|
59
|
+
const detail = await response.text().catch(() => '');
|
|
60
|
+
throw new LiveRouteExecutionError(
|
|
61
|
+
response.status,
|
|
62
|
+
detail || `Live route returned HTTP ${response.status}.`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return decodeResponse(response);
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function createRequest(
|
|
71
|
+
resource: LiveResource,
|
|
72
|
+
inputs: LiveInputs,
|
|
73
|
+
context: LiveExecutionContext
|
|
74
|
+
): Request {
|
|
75
|
+
const path = interpolatePath(resource.httpPath, inputs.params, resource.id);
|
|
76
|
+
const url = new URL(`http://carno.live${path}`);
|
|
77
|
+
|
|
78
|
+
for (const [key, value] of Object.entries(inputs.query)) {
|
|
79
|
+
for (const item of Array.isArray(value) ? value : [value]) {
|
|
80
|
+
url.searchParams.append(key, item);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const headers = new Headers(context.scope?.headers);
|
|
85
|
+
|
|
86
|
+
if (context.headers) {
|
|
87
|
+
new Headers(context.headers).forEach((value, key) => headers.set(key, value));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const hasBody = resource.httpMethod === 'POST' && inputs.body !== undefined;
|
|
91
|
+
|
|
92
|
+
if (hasBody && !headers.has('content-type')) {
|
|
93
|
+
headers.set('content-type', 'application/json');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return new Request(url, {
|
|
97
|
+
method: resource.httpMethod,
|
|
98
|
+
headers,
|
|
99
|
+
body: hasBody ? JSON.stringify(inputs.body) : undefined
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function interpolatePath(
|
|
104
|
+
path: string,
|
|
105
|
+
params: Record<string, string>,
|
|
106
|
+
resourceId: string
|
|
107
|
+
): string {
|
|
108
|
+
return path.replace(/:([A-Za-z0-9_]+)/g, (_segment, key: string) => {
|
|
109
|
+
const value = params[key];
|
|
110
|
+
|
|
111
|
+
if (value === undefined) {
|
|
112
|
+
throw new LiveRouteExecutionError(
|
|
113
|
+
400,
|
|
114
|
+
`Live resource "${resourceId}" is missing route parameter "${key}".`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return encodeURIComponent(value);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function decodeResponse(response: Response): Promise<unknown> {
|
|
123
|
+
if (response.status === 204) {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
|
|
128
|
+
|
|
129
|
+
if (contentType.includes('json')) {
|
|
130
|
+
return response.json();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return response.text();
|
|
134
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ParamMetadata } from '@carno.js/core';
|
|
2
|
+
import type { LiveMeta } from '../metadata';
|
|
3
|
+
|
|
4
|
+
export type { LiveInputs, LiveScope } from '../shared/inputs';
|
|
5
|
+
import type { LiveInputs, LiveScope } from '../shared/inputs';
|
|
6
|
+
|
|
7
|
+
export interface LiveExecutionContext {
|
|
8
|
+
/** Scope resolved for a subscription, including optional middleware headers. */
|
|
9
|
+
scope?: LiveScope;
|
|
10
|
+
/** Explicit headers, useful when prefetching from an authenticated SSR request. */
|
|
11
|
+
headers?: HeadersInit;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface LiveResourceExecutor {
|
|
15
|
+
(
|
|
16
|
+
controllerInstance: any,
|
|
17
|
+
resource: LiveResource,
|
|
18
|
+
inputs: LiveInputs,
|
|
19
|
+
context: LiveExecutionContext
|
|
20
|
+
): Promise<unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface LiveResource {
|
|
24
|
+
/** `${controllerName}.${handlerName}` */
|
|
25
|
+
id: string;
|
|
26
|
+
controllerClass: new (...args: any[]) => any;
|
|
27
|
+
controllerName: string;
|
|
28
|
+
handlerName: string;
|
|
29
|
+
meta: LiveMeta;
|
|
30
|
+
params: ParamMetadata[];
|
|
31
|
+
invoke(inputs: LiveInputs, context?: LiveExecutionContext): Promise<unknown>;
|
|
32
|
+
/** Full HTTP path, controller prefix included. Used by the ETag layer. */
|
|
33
|
+
httpPath: string;
|
|
34
|
+
httpMethod: string;
|
|
35
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { LiveEngine } from './LiveEngine';
|
|
2
|
+
import type { SocketTransport } from './transport/SocketTransport';
|
|
3
|
+
import type { LiveScopeResolver } from './transport/scope-resolver';
|
|
4
|
+
import type { LiveScope } from './shared/inputs';
|
|
5
|
+
import type { ResourceRegistry } from './resource/ResourceRegistry';
|
|
6
|
+
|
|
7
|
+
export interface LiveRuntime {
|
|
8
|
+
engine: LiveEngine;
|
|
9
|
+
transport: SocketTransport;
|
|
10
|
+
resolver: LiveScopeResolver;
|
|
11
|
+
scopes: Map<string, LiveScope>;
|
|
12
|
+
/** Connections that have completed their single allowed hello. */
|
|
13
|
+
handshakes: Set<string>;
|
|
14
|
+
/** Needed by prefetch(), which computes without subscribing. */
|
|
15
|
+
resources: ResourceRegistry;
|
|
16
|
+
/**
|
|
17
|
+
* Everything the plugin opened and nothing else knows about: the dedicated
|
|
18
|
+
* LISTEN connections of the Postgres emitter and of the distributed bus,
|
|
19
|
+
* plus the engine's timers.
|
|
20
|
+
*/
|
|
21
|
+
dispose?: (() => Promise<void> | void)[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let current: LiveRuntime | null = null;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Process-wide holder, same shape as `Orm.getInstance()`.
|
|
28
|
+
*
|
|
29
|
+
* The gateway is instantiated by the core container, which has no factory
|
|
30
|
+
* providers, so constructor-injecting a hand-built engine would need a
|
|
31
|
+
* registration dance that exists only to satisfy the container.
|
|
32
|
+
*/
|
|
33
|
+
export function setLiveRuntime(runtime: LiveRuntime): void {
|
|
34
|
+
current = runtime;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getLiveRuntime(): LiveRuntime {
|
|
38
|
+
if (!current) {
|
|
39
|
+
throw new Error('[carno:live] LivePlugin.create() has not run yet.');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return current;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Close whatever the plugin opened, then forget the runtime.
|
|
47
|
+
*
|
|
48
|
+
* Dropping the reference alone is not enough: a LISTEN connection is a socket
|
|
49
|
+
* held open by nothing the container can see, so a process that builds and
|
|
50
|
+
* tears down several Carno instances — a test suite, most obviously — runs the
|
|
51
|
+
* database out of client slots.
|
|
52
|
+
*/
|
|
53
|
+
export async function closeLiveRuntime(): Promise<void> {
|
|
54
|
+
const runtime = current;
|
|
55
|
+
current = null;
|
|
56
|
+
|
|
57
|
+
for (const close of runtime?.dispose ?? []) {
|
|
58
|
+
try {
|
|
59
|
+
await close();
|
|
60
|
+
} catch (error) {
|
|
61
|
+
console.error('[carno:live] failed to close a live connection', error);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Synchronous form. Prefer `closeLiveRuntime()` when you can await. */
|
|
67
|
+
export function resetLiveRuntime(): void {
|
|
68
|
+
void closeLiveRuntime();
|
|
69
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic JSON canonicalization, shared verbatim by client and server.
|
|
3
|
+
*
|
|
4
|
+
* Both sides MUST produce byte-identical output for the same logical value:
|
|
5
|
+
* the instance id and the content hash are derived from it, so a divergence
|
|
6
|
+
* silently breaks subscription dedupe and the hydration handshake instead of
|
|
7
|
+
* failing loudly.
|
|
8
|
+
*/
|
|
9
|
+
export class NonSerializableInputError extends Error {
|
|
10
|
+
constructor(
|
|
11
|
+
public readonly path: string,
|
|
12
|
+
public readonly received: string
|
|
13
|
+
) {
|
|
14
|
+
super(`Live input at "${path}" is not serializable (received ${received}).`);
|
|
15
|
+
this.name = 'NonSerializableInputError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function canonical(value: unknown, path: string = '$'): string {
|
|
20
|
+
if (value === null || value === undefined) {
|
|
21
|
+
return 'null';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
switch (typeof value) {
|
|
25
|
+
case 'boolean':
|
|
26
|
+
return value ? 'true' : 'false';
|
|
27
|
+
case 'number':
|
|
28
|
+
if (!Number.isFinite(value)) {
|
|
29
|
+
throw new NonSerializableInputError(path, String(value));
|
|
30
|
+
}
|
|
31
|
+
// -0 and 0 are the same input as far as a query is concerned.
|
|
32
|
+
return Object.is(value, -0) ? '0' : String(value);
|
|
33
|
+
case 'string':
|
|
34
|
+
return JSON.stringify(value);
|
|
35
|
+
case 'bigint':
|
|
36
|
+
case 'function':
|
|
37
|
+
case 'symbol':
|
|
38
|
+
throw new NonSerializableInputError(path, typeof value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (Array.isArray(value)) {
|
|
42
|
+
const items = value.map((item, index) => canonical(item, `${path}[${index}]`));
|
|
43
|
+
return `[${items.join(',')}]`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const proto = Object.getPrototypeOf(value);
|
|
47
|
+
if (proto !== Object.prototype && proto !== null) {
|
|
48
|
+
// Date, Map, Set, class instances: no agreed wire form, so refuse
|
|
49
|
+
// rather than guess one the client would canonicalize differently.
|
|
50
|
+
const name = (value as object).constructor?.name ?? 'object';
|
|
51
|
+
throw new NonSerializableInputError(path, name);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
55
|
+
.filter(([, item]) => item !== undefined)
|
|
56
|
+
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
|
|
57
|
+
|
|
58
|
+
const body = entries
|
|
59
|
+
.map(([key, item]) => `${JSON.stringify(key)}:${canonical(item, `${path}.${key}`)}`)
|
|
60
|
+
.join(',');
|
|
61
|
+
|
|
62
|
+
return `{${body}}`;
|
|
63
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { LiveInputs } from './inputs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Structural twin of the descriptor @carno.js/client emits.
|
|
5
|
+
*
|
|
6
|
+
* The generated file imports nothing, on purpose, so the two definitions meet
|
|
7
|
+
* through TypeScript's structural typing. Keep the field names identical.
|
|
8
|
+
*/
|
|
9
|
+
export interface LiveDescriptor<R = unknown> {
|
|
10
|
+
readonly method: string;
|
|
11
|
+
readonly path: string;
|
|
12
|
+
readonly resourceId?: string;
|
|
13
|
+
readonly live?: { readonly shared: 'private' | 'tenant' | 'public'; readonly key?: string };
|
|
14
|
+
readonly __route?: R;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** What the route answers, as the client sees it. */
|
|
18
|
+
export type LiveDataOf<R> = R extends { response: infer S } ? Exclude<S, undefined | void> : unknown;
|
|
19
|
+
|
|
20
|
+
/** What the route takes, as a subscription sends it. */
|
|
21
|
+
export type LiveInputsOf<R> =
|
|
22
|
+
(R extends { params: infer P } ? { params: P } : { params?: Record<string, string> })
|
|
23
|
+
& (R extends { query: infer Q } ? { query: Q } : { query?: Record<string, string | string[]> })
|
|
24
|
+
& (R extends { body: infer B } ? { body: B } : { body?: undefined });
|
|
25
|
+
|
|
26
|
+
export function resourceIdOf(descriptor: LiveDescriptor<any>): string {
|
|
27
|
+
if (!descriptor.resourceId || !descriptor.live) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`${descriptor.method.toUpperCase()} ${descriptor.path} is not a live resource. ` +
|
|
30
|
+
`Add @Live() to the handler and re-run the client codegen.`
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return descriptor.resourceId;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Fill the three input slots, whichever of them the caller bothered with. */
|
|
38
|
+
export function normalizeLiveInputs(inputs: Partial<LiveInputs> = {}): LiveInputs {
|
|
39
|
+
return {
|
|
40
|
+
params: inputs.params ?? {},
|
|
41
|
+
query: inputs.query ?? {},
|
|
42
|
+
body: inputs.body
|
|
43
|
+
};
|
|
44
|
+
}
|