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