@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,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,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;
|
package/dist/runtime.js
ADDED
|
@@ -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
|
+
}
|