@peers-app/peers-sdk 0.19.13 → 0.20.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 (59) hide show
  1. package/dist/contracts/__tests__/builder.test.js +24 -0
  2. package/dist/contracts/__tests__/contract-events.test.d.ts +1 -0
  3. package/dist/contracts/__tests__/contract-events.test.js +199 -0
  4. package/dist/contracts/__tests__/contract-observables.test.d.ts +1 -0
  5. package/dist/contracts/__tests__/contract-observables.test.js +290 -0
  6. package/dist/contracts/__tests__/contract-provider-router.test.d.ts +1 -0
  7. package/dist/contracts/__tests__/contract-provider-router.test.js +193 -0
  8. package/dist/contracts/__tests__/contract-proxy.test.d.ts +1 -0
  9. package/dist/contracts/__tests__/contract-proxy.test.js +424 -0
  10. package/dist/contracts/__tests__/contract-resolvers.test.d.ts +1 -0
  11. package/dist/contracts/__tests__/contract-resolvers.test.js +148 -0
  12. package/dist/contracts/__tests__/contract-subscription-lifecycle.test.d.ts +1 -0
  13. package/dist/contracts/__tests__/contract-subscription-lifecycle.test.js +353 -0
  14. package/dist/contracts/__tests__/contract-table-events.test.d.ts +1 -0
  15. package/dist/contracts/__tests__/contract-table-events.test.js +209 -0
  16. package/dist/contracts/__tests__/contract-transport.test.d.ts +1 -0
  17. package/dist/contracts/__tests__/contract-transport.test.js +163 -0
  18. package/dist/contracts/__tests__/persistent-registry.test.d.ts +1 -0
  19. package/dist/contracts/__tests__/persistent-registry.test.js +109 -0
  20. package/dist/contracts/__tests__/playground.test.d.ts +1 -0
  21. package/dist/contracts/__tests__/playground.test.js +213 -0
  22. package/dist/contracts/__tests__/validate.test.js +122 -0
  23. package/dist/contracts/builder.d.ts +9 -1
  24. package/dist/contracts/builder.js +13 -0
  25. package/dist/contracts/contract-provider-router.d.ts +72 -0
  26. package/dist/contracts/contract-provider-router.js +164 -0
  27. package/dist/contracts/contract-proxy.d.ts +333 -0
  28. package/dist/contracts/contract-proxy.js +663 -0
  29. package/dist/contracts/contract-resolvers.d.ts +66 -0
  30. package/dist/contracts/contract-resolvers.js +138 -0
  31. package/dist/contracts/contract-transport.d.ts +94 -0
  32. package/dist/contracts/contract-transport.js +159 -0
  33. package/dist/contracts/contracts.table.d.ts +1 -1
  34. package/dist/contracts/contracts.table.js +2 -2
  35. package/dist/contracts/index.d.ts +6 -1
  36. package/dist/contracts/index.js +25 -1
  37. package/dist/contracts/persistent-registry.js +3 -1
  38. package/dist/contracts/system/logs-contract.d.ts +24 -0
  39. package/dist/contracts/system/logs-contract.js +60 -0
  40. package/dist/contracts/types.d.ts +27 -4
  41. package/dist/contracts/validate.d.ts +2 -2
  42. package/dist/contracts/validate.js +44 -2
  43. package/dist/data/orm/decorators.d.ts +6 -0
  44. package/dist/data/orm/decorators.js +21 -0
  45. package/dist/data/user-trust-levels.d.ts +2 -0
  46. package/dist/data/user-trust-levels.js +2 -1
  47. package/dist/data/user-trust-levels.test.d.ts +1 -0
  48. package/dist/data/user-trust-levels.test.js +37 -0
  49. package/dist/device/binary-peer-connection-v2.d.ts +2 -0
  50. package/dist/device/binary-peer-connection-v2.js +12 -2
  51. package/dist/device/binary-peer-connection-v2.test.js +23 -0
  52. package/dist/device/get-trust-level-fn.d.ts +6 -0
  53. package/dist/device/get-trust-level-fn.js +12 -7
  54. package/dist/device/get-trust-level-fn.test.js +16 -2
  55. package/dist/logging/console-logs.table.d.ts +3 -0
  56. package/dist/logging/console-logs.table.js +2 -1
  57. package/dist/system-ids.d.ts +11 -0
  58. package/dist/system-ids.js +12 -1
  59. package/package.json +1 -1
@@ -0,0 +1,213 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /**
4
+ * SCRATCH / PLAYGROUND — not a curated part of the suite.
5
+ *
6
+ * A place to hand-write contract *provider* (impl side) and *consumer* (caller
7
+ * side) tests and feel out the ergonomics. Rename, rewrite, or delete freely.
8
+ *
9
+ * Run just this file:
10
+ * cd peers-sdk && npx jest playground
11
+ * Watch it:
12
+ * cd peers-sdk && npx jest --watch playground
13
+ */
14
+ const observable_1 = require("../../observable");
15
+ const field_type_1 = require("../../types/field-type");
16
+ const utils_1 = require("../../utils");
17
+ const contract_proxy_1 = require("../contract-proxy");
18
+ const contract_transport_1 = require("../contract-transport");
19
+ const cleanups = [];
20
+ afterEach(() => {
21
+ for (const cleanup of cleanups.splice(0).reverse()) {
22
+ cleanup();
23
+ }
24
+ });
25
+ /**
26
+ * Build a consumer wired in-process to a provider *session* (via a transport pair).
27
+ *
28
+ * The session (not the stateless `createContractProvider`) is what a consumer should face:
29
+ * it serves request/response dispatch *and* the subscription/streaming traffic that every
30
+ * consumer now opens implicitly (observable mirrors fire a `subscribe` at construction, and
31
+ * the session is what can push updates back on the notify channel). Returns the live session
32
+ * too so a test can `session.dispose()`.
33
+ */
34
+ function inProcessConsumer(contract, resolution, opts = {}) {
35
+ const [consumerTransport, providerTransport] = (0, contract_transport_1.inProcessTransportPair)();
36
+ const session = (0, contract_proxy_1.createContractProviderSession)(resolution, providerTransport, opts);
37
+ const consumer = (0, contract_proxy_1.createContractConsumer)(contract, consumerTransport);
38
+ cleanups.push(() => {
39
+ consumer.dispose();
40
+ session.dispose();
41
+ });
42
+ return { consumer, session };
43
+ }
44
+ const CONTRACT_ID = "00mrbloudwk9svfxd42oyxqhi";
45
+ /** The contract *shape* a consumer sees: one table, one tool, one observable. */
46
+ function makeContract() {
47
+ return {
48
+ contractId: CONTRACT_ID,
49
+ version: 1,
50
+ devTag: "dev",
51
+ name: "Playground Contract",
52
+ description: "Scratch space",
53
+ tables: [
54
+ {
55
+ name: "Widgets",
56
+ description: "Widget table",
57
+ primaryKeyName: "widgetId",
58
+ fields: [{ name: "widgetId", type: field_type_1.FieldType.id, description: "PK" }],
59
+ },
60
+ ],
61
+ tools: [
62
+ {
63
+ name: "make-widget",
64
+ usageDescription: "Makes a widget",
65
+ inputFields: [{ name: "color", type: field_type_1.FieldType.string, description: "Color" }],
66
+ outputFields: [{ name: "widgetId", type: field_type_1.FieldType.id, description: "ID" }],
67
+ },
68
+ ],
69
+ observables: [
70
+ { name: "widgetCount", description: "Count", valueType: field_type_1.FieldType.number, writable: true },
71
+ ],
72
+ };
73
+ }
74
+ /** A live, in-memory implementation the *provider* dispatches to. */
75
+ function makeResolution() {
76
+ const rows = [];
77
+ const table = {
78
+ list: async (filter = {}) => rows.filter((r) => Object.entries(filter).every(([k, v]) => r[k] === v)),
79
+ get: async (id) => rows.find((r) => r.widgetId === id),
80
+ save: async (record) => {
81
+ rows.push(record);
82
+ return record;
83
+ },
84
+ };
85
+ const makeWidget = async ({ color }) => ({ widgetId: (0, utils_1.newid)(), color });
86
+ const widgetCount = (0, observable_1.observable)(0);
87
+ const resolution = {
88
+ contractId: CONTRACT_ID,
89
+ version: 1,
90
+ tables: { Widgets: table },
91
+ tools: {
92
+ "make-widget": { tool: { toolId: (0, utils_1.newid)(), name: "make-widget" }, toolFn: makeWidget },
93
+ },
94
+ observables: { widgetCount },
95
+ observableWritability: { widgetCount: true },
96
+ };
97
+ return { resolution, table, makeWidget, widgetCount, rows };
98
+ }
99
+ /** Wire a consumer straight to a provider session, in-process (over a transport pair). */
100
+ function wire() {
101
+ const contract = makeContract();
102
+ const { resolution, ...rest } = makeResolution();
103
+ const { consumer, session } = inProcessConsumer(contract, resolution);
104
+ return { consumer, session, contract, ...rest };
105
+ }
106
+ describe("playground", () => {
107
+ it("round-trips a save through consumer -> provider -> live table", async () => {
108
+ const { consumer } = wire();
109
+ const saved = await consumer.tables.Widgets.save({ widgetId: "w1", color: "red" });
110
+ expect(saved).toEqual({ widgetId: "w1", color: "red" });
111
+ const listed = await consumer.tables.Widgets.list({ color: "red" });
112
+ expect(listed).toEqual([{ widgetId: "w1", color: "red" }]);
113
+ });
114
+ // Start fiddling here — try a tool call, observable get/set, permission
115
+ // checks (sameUserContractPermissionCheck), or swap in your own contract shape.
116
+ it("keeps compatible contract versions isolated and reactive", async () => {
117
+ const logs = [];
118
+ let lastLogTime;
119
+ const inc = (0, observable_1.observable)(0);
120
+ const logCountObs = (0, observable_1.computed)(() => {
121
+ inc();
122
+ return logs.length;
123
+ });
124
+ const lastLogTimeObs = (0, observable_1.computed)(() => {
125
+ inc();
126
+ return lastLogTime;
127
+ });
128
+ const logTextFn = (text) => {
129
+ logs.push(text);
130
+ lastLogTime = Date.now();
131
+ inc(inc() + 1);
132
+ return logs.length - 1;
133
+ };
134
+ const logsContractV1 = {
135
+ contractId: CONTRACT_ID,
136
+ version: 1,
137
+ devTag: "dev",
138
+ name: "Playground Contract",
139
+ description: "Scratch space",
140
+ tables: [],
141
+ tools: [
142
+ {
143
+ name: "logText",
144
+ usageDescription: "logs text",
145
+ inputFields: [{ name: "text", type: field_type_1.FieldType.string }],
146
+ outputFields: [{ name: "logId", type: field_type_1.FieldType.id, description: "log id" }],
147
+ },
148
+ ],
149
+ observables: [
150
+ { name: "logCount", description: "Count", valueType: field_type_1.FieldType.number, writable: false },
151
+ ],
152
+ };
153
+ const providerV1 = {
154
+ contractId: CONTRACT_ID,
155
+ version: 1,
156
+ tools: {
157
+ logText: { tool: { toolId: "", name: "logText" }, toolFn: logTextFn },
158
+ },
159
+ observables: { logCount: logCountObs },
160
+ observableWritability: { logCount: false },
161
+ };
162
+ const logsContractV2 = {
163
+ ...logsContractV1,
164
+ version: 2,
165
+ devTag: "dev",
166
+ observables: [
167
+ ...logsContractV1.observables,
168
+ {
169
+ name: "lastLogTime",
170
+ description: "last time a log was written",
171
+ valueType: field_type_1.FieldType.number,
172
+ writable: false,
173
+ },
174
+ ],
175
+ };
176
+ const providerV2 = {
177
+ ...providerV1,
178
+ version: 2,
179
+ observables: {
180
+ ...providerV1.observables,
181
+ lastLogTime: lastLogTimeObs,
182
+ },
183
+ observableWritability: {
184
+ ...providerV1.observableWritability,
185
+ lastLogTime: false,
186
+ },
187
+ };
188
+ const { consumer: consumerV1 } = inProcessConsumer(logsContractV1, providerV1);
189
+ const { consumer: consumerV2 } = inProcessConsumer(logsContractV2, providerV2);
190
+ await Promise.all([consumerV1.loadingPromise, consumerV2.loadingPromise]);
191
+ await consumerV1.tools.logText("log1");
192
+ expect(logs).toEqual(["log1"]);
193
+ let observedLogCount = consumerV1.observables.logCount();
194
+ expect(observedLogCount).toBe(1);
195
+ let resolveSecondLog;
196
+ const secondLogObserved = new Promise((resolve) => {
197
+ resolveSecondLog = resolve;
198
+ });
199
+ const observedSub = consumerV2.observables.logCount.subscribe((value) => {
200
+ observedLogCount = value;
201
+ if (value === 2) {
202
+ resolveSecondLog();
203
+ }
204
+ });
205
+ cleanups.push(() => observedSub.dispose());
206
+ await consumerV2.tools.logText("log2");
207
+ await secondLogObserved;
208
+ expect(logs).toEqual(["log1", "log2"]);
209
+ expect(observedLogCount).toBe(2);
210
+ // TODO test tables so we can see what this looks like and how it works for both providers and consumers
211
+ // TODO consider renaming ContractResolution to ContractProviderInstance or something like that. ContractResolution is awkward.
212
+ });
213
+ });
@@ -15,6 +15,7 @@ function makeContract(overrides = {}) {
15
15
  tables: [],
16
16
  tools: [],
17
17
  observables: [],
18
+ events: [],
18
19
  ...overrides,
19
20
  };
20
21
  }
@@ -457,6 +458,76 @@ describe("validateProviderSatisfiesContract", () => {
457
458
  expect(result.errors[0].message).toContain("writable");
458
459
  });
459
460
  });
461
+ describe("events", () => {
462
+ it("passes when an implementation event has compatible payload fields", () => {
463
+ const contract = makeContract({
464
+ events: [
465
+ {
466
+ name: "taskChanged",
467
+ description: "A task changed",
468
+ payloadFields: [{ name: "taskId", type: field_type_1.FieldType.id }],
469
+ },
470
+ ],
471
+ });
472
+ const impl = makeContract({
473
+ events: [
474
+ {
475
+ name: "taskChanged",
476
+ description: "A task changed",
477
+ payloadFields: [
478
+ { name: "taskId", type: field_type_1.FieldType.id },
479
+ { name: "source", type: field_type_1.FieldType.string, optional: true },
480
+ ],
481
+ },
482
+ ],
483
+ });
484
+ expect((0, validate_1.validateProviderSatisfiesContract)(impl, contract)).toEqual({
485
+ valid: true,
486
+ errors: [],
487
+ });
488
+ });
489
+ it("fails when a required event is missing", () => {
490
+ const contract = makeContract({
491
+ events: [
492
+ {
493
+ name: "taskChanged",
494
+ description: "A task changed",
495
+ payloadFields: [],
496
+ },
497
+ ],
498
+ });
499
+ const result = (0, validate_1.validateProviderSatisfiesContract)(makeContract(), contract);
500
+ expect(result.valid).toBe(false);
501
+ expect(result.errors[0]).toEqual(expect.objectContaining({ kind: "event", name: "taskChanged" }));
502
+ });
503
+ it("fails when an event payload field is incompatible", () => {
504
+ const contract = makeContract({
505
+ events: [
506
+ {
507
+ name: "taskChanged",
508
+ description: "A task changed",
509
+ payloadFields: [{ name: "taskId", type: field_type_1.FieldType.id }],
510
+ },
511
+ ],
512
+ });
513
+ const impl = makeContract({
514
+ events: [
515
+ {
516
+ name: "taskChanged",
517
+ description: "A task changed",
518
+ payloadFields: [{ name: "taskId", type: field_type_1.FieldType.number }],
519
+ },
520
+ ],
521
+ });
522
+ const result = (0, validate_1.validateProviderSatisfiesContract)(impl, contract);
523
+ expect(result.valid).toBe(false);
524
+ expect(result.errors[0]).toEqual(expect.objectContaining({
525
+ kind: "field",
526
+ name: "taskChanged.payload.taskId",
527
+ message: expect.stringMatching(/type mismatch/),
528
+ }));
529
+ });
530
+ });
460
531
  });
461
532
  describe("validateImmutability", () => {
462
533
  it("allows any change for dev contracts", () => {
@@ -543,6 +614,57 @@ describe("validateImmutability", () => {
543
614
  expect(result.valid).toBe(false);
544
615
  expect(result.errors[0].message).toContain("frozen");
545
616
  });
617
+ it.each([
618
+ [
619
+ "addition",
620
+ [
621
+ {
622
+ name: "taskChanged",
623
+ description: "A task changed",
624
+ payloadFields: [{ name: "taskId", type: field_type_1.FieldType.id }],
625
+ },
626
+ {
627
+ name: "taskDeleted",
628
+ description: "A task was deleted",
629
+ payloadFields: [{ name: "taskId", type: field_type_1.FieldType.id }],
630
+ },
631
+ ],
632
+ ],
633
+ ["removal", []],
634
+ [
635
+ "rename",
636
+ [
637
+ {
638
+ name: "taskUpdated",
639
+ description: "A task changed",
640
+ payloadFields: [{ name: "taskId", type: field_type_1.FieldType.id }],
641
+ },
642
+ ],
643
+ ],
644
+ [
645
+ "payload change",
646
+ [
647
+ {
648
+ name: "taskChanged",
649
+ description: "A task changed",
650
+ payloadFields: [{ name: "taskId", type: field_type_1.FieldType.number }],
651
+ },
652
+ ],
653
+ ],
654
+ ])("rejects frozen event %s", (_change, incomingEvents) => {
655
+ const existing = makeContract({
656
+ events: [
657
+ {
658
+ name: "taskChanged",
659
+ description: "A task changed",
660
+ payloadFields: [{ name: "taskId", type: field_type_1.FieldType.id }],
661
+ },
662
+ ],
663
+ });
664
+ const result = (0, validate_1.validateImmutability)(existing, makeContract({ events: incomingEvents }));
665
+ expect(result.valid).toBe(false);
666
+ expect(result.errors[0].message).toContain("frozen");
667
+ });
546
668
  });
547
669
  describe("validateAlsoImplements", () => {
548
670
  const contractStore = new Map();
@@ -1,7 +1,7 @@
1
1
  import type { ITableDefinition } from "../data/orm/table-definitions.type";
2
2
  import type { IToolInstance } from "../data/tools";
3
3
  import type { IExtractableTableDef, IExtractableToolInstance } from "./extract";
4
- import type { IAlsoImplementsDeclaration, IContractDefinition, IContractObservable, IPackageDefinitionResult } from "./types";
4
+ import type { IAlsoImplementsDeclaration, IContractDefinition, IContractEvent, IContractObservable, IPackageDefinitionResult } from "./types";
5
5
  /**
6
6
  * Scoped builder for a single contract within a package. Returned by
7
7
  * `PackageBuilder.contract()`.
@@ -14,6 +14,7 @@ export declare class ContractBuilder {
14
14
  private _tables;
15
15
  private _tools;
16
16
  private _observables;
17
+ private _events;
17
18
  private _alsoImplements;
18
19
  /** Full runtime tool instances for this contract (used by the install flow). */
19
20
  private _toolInstances;
@@ -26,6 +27,13 @@ export declare class ContractBuilder {
26
27
  get tools(): IExtractableToolInstance[];
27
28
  set observables(obs: IContractObservable[]);
28
29
  get observables(): IContractObservable[];
30
+ /**
31
+ * Events this contract may emit. Shape only — the live {@link Event} for each is
32
+ * supplied by the provider at resolution time (in `IContractResolution.events`),
33
+ * not here.
34
+ */
35
+ set events(events: IContractEvent[]);
36
+ get events(): IContractEvent[];
29
37
  /** Full tool instances with executable toolFn for runtime registration. */
30
38
  set toolInstances(instances: IToolInstance[]);
31
39
  get toolInstances(): IToolInstance[];
@@ -17,6 +17,7 @@ class ContractBuilder {
17
17
  _tables = [];
18
18
  _tools = [];
19
19
  _observables = [];
20
+ _events = [];
20
21
  _alsoImplements = [];
21
22
  /** Full runtime tool instances for this contract (used by the install flow). */
22
23
  _toolInstances = [];
@@ -46,6 +47,17 @@ class ContractBuilder {
46
47
  get observables() {
47
48
  return this._observables;
48
49
  }
50
+ /**
51
+ * Events this contract may emit. Shape only — the live {@link Event} for each is
52
+ * supplied by the provider at resolution time (in `IContractResolution.events`),
53
+ * not here.
54
+ */
55
+ set events(events) {
56
+ this._events = events;
57
+ }
58
+ get events() {
59
+ return this._events;
60
+ }
49
61
  /** Full tool instances with executable toolFn for runtime registration. */
50
62
  set toolInstances(instances) {
51
63
  this._toolInstances = instances;
@@ -82,6 +94,7 @@ class ContractBuilder {
82
94
  tables: this._tables.map(extract_1.extractTableShape),
83
95
  tools: this._tools.map(extract_1.extractToolShape),
84
96
  observables: [...this._observables],
97
+ events: [...this._events],
85
98
  },
86
99
  alsoImplements: [...this._alsoImplements],
87
100
  toolInstances: [...this._toolInstances],
@@ -0,0 +1,72 @@
1
+ import type { ContractNotificationSink, IContractCall, IContractCallContext, IContractProviderEndpoint } from "./contract-proxy";
2
+ import { type IContractTransport } from "./contract-transport";
3
+ /**
4
+ * The trusted host's authorization result for one inbound contract call.
5
+ *
6
+ * `dataContextId` is the canonical route chosen by the host. Returning the same
7
+ * value for an omitted personal context and an explicitly named personal context
8
+ * makes both calls share one cached provider endpoint.
9
+ */
10
+ export interface IAuthorizedContractRoute {
11
+ /** Canonical data-context route selected by the trusted host. */
12
+ dataContextId?: string;
13
+ /** Optional caller context to pass to the resolved endpoint. */
14
+ context?: IContractCallContext;
15
+ }
16
+ /**
17
+ * Authorizes one inbound call before any provider resolver runs.
18
+ *
19
+ * Throw or reject to deny. The hook must derive authority from trusted transport
20
+ * state rather than caller-supplied identity fields.
21
+ */
22
+ export type ContractCallAuthorizer = (call: IContractCall, context: IContractCallContext) => IAuthorizedContractRoute | Promise<IAuthorizedContractRoute>;
23
+ /** Input supplied when the router needs a provider endpoint for a new route. */
24
+ export interface IContractProviderEndpointRequest {
25
+ /** Target contract id. */
26
+ contractId: string;
27
+ /** Target contract version. */
28
+ version: number;
29
+ /** Canonical data context returned by the authorization hook. */
30
+ dataContextId?: string;
31
+ /** Authorized caller context for this route. */
32
+ context: IContractCallContext;
33
+ /** Sink for provider-to-consumer subscription notifications. */
34
+ notify: ContractNotificationSink;
35
+ }
36
+ /**
37
+ * Resolves an endpoint for an authorized contract/version/data-context route.
38
+ *
39
+ * The callback may adapt local live objects or return a remote/sandbox-backed
40
+ * endpoint. The router caches the result until disposal.
41
+ */
42
+ export type ContractProviderEndpointResolver = (request: IContractProviderEndpointRequest) => IContractProviderEndpoint | Promise<IContractProviderEndpoint>;
43
+ /** Options for {@link createContractProviderRouter}. */
44
+ export interface IContractProviderRouterOptions {
45
+ /** Trusted host gate invoked before endpoint lookup or provider resolution. */
46
+ authorizeCall: ContractCallAuthorizer;
47
+ /** Resolves an endpoint after authorization succeeds. */
48
+ resolveEndpoint: ContractProviderEndpointResolver;
49
+ /** Caller identity derived from the trusted underlying connection/session. */
50
+ context?: IContractCallContext;
51
+ }
52
+ /** A connection-wide owner of contract request routing and endpoint lifecycle. */
53
+ export interface IContractProviderRouter {
54
+ /** Remove the request handler and dispose every resolved provider endpoint. */
55
+ dispose(): void;
56
+ }
57
+ /**
58
+ * Own the sole {@link CONTRACT_REQUEST_CHANNEL} handler for a shared connection.
59
+ *
60
+ * Every ordinary call and subscribe is authorized before endpoint resolution. Caller
61
+ * context comes only from `opts.context`; additional transport arguments are ignored so a
62
+ * peer cannot override trusted connection identity. Endpoints are lazily cached by normalized
63
+ * contract/version/data-context route and may therefore serve many request/response calls
64
+ * and subscriptions. Successful subscriptions are indexed by id so unsubscribe can remain
65
+ * idempotent and skip reauthorization while still reaching the endpoint that owns the live
66
+ * source. Disposing the router removes its request handler and disposes all cached endpoints,
67
+ * including endpoints whose asynchronous resolution finishes during teardown.
68
+ *
69
+ * @param transport Duplex contract transport shared by all contracts on one connection.
70
+ * @param opts Trusted authorization, endpoint resolution, and caller context hooks.
71
+ */
72
+ export declare function createContractProviderRouter(transport: IContractTransport, opts: IContractProviderRouterOptions): IContractProviderRouter;
@@ -0,0 +1,164 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createContractProviderRouter = createContractProviderRouter;
4
+ const contract_transport_1 = require("./contract-transport");
5
+ const routeKey = (contractId, version, dataContextId) => JSON.stringify([contractId, version, dataContextId ?? null]);
6
+ /**
7
+ * Own the sole {@link CONTRACT_REQUEST_CHANNEL} handler for a shared connection.
8
+ *
9
+ * Every ordinary call and subscribe is authorized before endpoint resolution. Caller
10
+ * context comes only from `opts.context`; additional transport arguments are ignored so a
11
+ * peer cannot override trusted connection identity. Endpoints are lazily cached by normalized
12
+ * contract/version/data-context route and may therefore serve many request/response calls
13
+ * and subscriptions. Successful subscriptions are indexed by id so unsubscribe can remain
14
+ * idempotent and skip reauthorization while still reaching the endpoint that owns the live
15
+ * source. Disposing the router removes its request handler and disposes all cached endpoints,
16
+ * including endpoints whose asynchronous resolution finishes during teardown.
17
+ *
18
+ * @param transport Duplex contract transport shared by all contracts on one connection.
19
+ * @param opts Trusted authorization, endpoint resolution, and caller context hooks.
20
+ */
21
+ function createContractProviderRouter(transport, opts) {
22
+ const endpointEntries = new Map();
23
+ const subscriptionRoutes = new Map();
24
+ const pendingSubscriptionIds = new Set();
25
+ const cancelledPendingSubscriptionIds = new Set();
26
+ let disposed = false;
27
+ const notify = (notification) => {
28
+ if (disposed) {
29
+ return Promise.reject(new Error("Contract provider router is disposed"));
30
+ }
31
+ return transport.emit(contract_transport_1.CONTRACT_NOTIFY_CHANNEL, notification);
32
+ };
33
+ const getEndpoint = (key, request) => {
34
+ const cached = endpointEntries.get(key);
35
+ if (cached) {
36
+ return cached.promise;
37
+ }
38
+ const entry = {};
39
+ entry.promise = Promise.resolve()
40
+ .then(() => opts.resolveEndpoint(request))
41
+ .then((endpoint) => {
42
+ entry.endpoint = endpoint;
43
+ if (disposed) {
44
+ endpoint.dispose();
45
+ throw new Error("Contract provider router is disposed");
46
+ }
47
+ return endpoint;
48
+ })
49
+ .catch((error) => {
50
+ if (endpointEntries.get(key) === entry) {
51
+ endpointEntries.delete(key);
52
+ }
53
+ throw error;
54
+ });
55
+ endpointEntries.set(key, entry);
56
+ return entry.promise;
57
+ };
58
+ const handleUnsubscribe = async (call) => {
59
+ if (!call.subscriptionId) {
60
+ throw new Error(`${call.member} '${call.name}'.unsubscribe requires a subscriptionId`);
61
+ }
62
+ const route = subscriptionRoutes.get(call.subscriptionId);
63
+ if (!route) {
64
+ if (pendingSubscriptionIds.has(call.subscriptionId)) {
65
+ cancelledPendingSubscriptionIds.add(call.subscriptionId);
66
+ }
67
+ return;
68
+ }
69
+ subscriptionRoutes.delete(call.subscriptionId);
70
+ const entry = endpointEntries.get(route.endpointKey);
71
+ if (!entry) {
72
+ return;
73
+ }
74
+ const endpoint = await entry.promise;
75
+ await endpoint.handleCall(route.unsubscribeCall, route.context);
76
+ };
77
+ const handleAuthorizedCall = async (call) => {
78
+ if (disposed) {
79
+ throw new Error("Contract provider router is disposed");
80
+ }
81
+ if (call.operation === "unsubscribe") {
82
+ return handleUnsubscribe(call);
83
+ }
84
+ const isSubscribe = call.operation === "subscribe";
85
+ const subscriptionId = call.subscriptionId;
86
+ if (isSubscribe &&
87
+ subscriptionId &&
88
+ (subscriptionRoutes.has(subscriptionId) || pendingSubscriptionIds.has(subscriptionId))) {
89
+ throw new Error(`Duplicate contract subscriptionId '${subscriptionId}'`);
90
+ }
91
+ if (isSubscribe && subscriptionId) {
92
+ pendingSubscriptionIds.add(subscriptionId);
93
+ }
94
+ try {
95
+ const inboundContext = opts.context ?? {};
96
+ const authorization = await opts.authorizeCall(call, inboundContext);
97
+ if (disposed) {
98
+ throw new Error("Contract provider router is disposed");
99
+ }
100
+ const context = authorization.context ?? inboundContext;
101
+ const routedCall = {
102
+ ...call,
103
+ dataContextId: authorization.dataContextId,
104
+ };
105
+ const endpointKey = routeKey(call.contractId, call.version, authorization.dataContextId);
106
+ const endpoint = await getEndpoint(endpointKey, {
107
+ contractId: call.contractId,
108
+ version: call.version,
109
+ dataContextId: authorization.dataContextId,
110
+ context,
111
+ notify,
112
+ });
113
+ const result = await endpoint.handleCall(routedCall, context);
114
+ if (isSubscribe && subscriptionId) {
115
+ const subscriptionRoute = {
116
+ endpointKey,
117
+ context,
118
+ unsubscribeCall: {
119
+ ...routedCall,
120
+ operation: "unsubscribe",
121
+ args: [],
122
+ },
123
+ };
124
+ if (cancelledPendingSubscriptionIds.has(subscriptionId)) {
125
+ if (!disposed) {
126
+ await endpoint.handleCall(subscriptionRoute.unsubscribeCall, context);
127
+ }
128
+ }
129
+ else if (!disposed) {
130
+ subscriptionRoutes.set(subscriptionId, subscriptionRoute);
131
+ }
132
+ }
133
+ return result;
134
+ }
135
+ finally {
136
+ if (isSubscribe && subscriptionId) {
137
+ pendingSubscriptionIds.delete(subscriptionId);
138
+ cancelledPendingSubscriptionIds.delete(subscriptionId);
139
+ }
140
+ }
141
+ };
142
+ const offRequest = transport.on(contract_transport_1.CONTRACT_REQUEST_CHANNEL, handleAuthorizedCall);
143
+ return {
144
+ dispose() {
145
+ if (disposed) {
146
+ return;
147
+ }
148
+ disposed = true;
149
+ offRequest();
150
+ for (const entry of endpointEntries.values()) {
151
+ if (entry.endpoint) {
152
+ entry.endpoint.dispose();
153
+ }
154
+ else {
155
+ void entry.promise.then((endpoint) => endpoint.dispose()).catch(() => { });
156
+ }
157
+ }
158
+ endpointEntries.clear();
159
+ subscriptionRoutes.clear();
160
+ pendingSubscriptionIds.clear();
161
+ cancelledPendingSubscriptionIds.clear();
162
+ },
163
+ };
164
+ }