@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.
- package/dist/contracts/__tests__/builder.test.js +24 -0
- package/dist/contracts/__tests__/contract-events.test.d.ts +1 -0
- package/dist/contracts/__tests__/contract-events.test.js +199 -0
- package/dist/contracts/__tests__/contract-observables.test.d.ts +1 -0
- package/dist/contracts/__tests__/contract-observables.test.js +290 -0
- package/dist/contracts/__tests__/contract-provider-router.test.d.ts +1 -0
- package/dist/contracts/__tests__/contract-provider-router.test.js +193 -0
- package/dist/contracts/__tests__/contract-proxy.test.d.ts +1 -0
- package/dist/contracts/__tests__/contract-proxy.test.js +424 -0
- package/dist/contracts/__tests__/contract-resolvers.test.d.ts +1 -0
- package/dist/contracts/__tests__/contract-resolvers.test.js +148 -0
- package/dist/contracts/__tests__/contract-subscription-lifecycle.test.d.ts +1 -0
- package/dist/contracts/__tests__/contract-subscription-lifecycle.test.js +353 -0
- package/dist/contracts/__tests__/contract-table-events.test.d.ts +1 -0
- package/dist/contracts/__tests__/contract-table-events.test.js +209 -0
- package/dist/contracts/__tests__/contract-transport.test.d.ts +1 -0
- package/dist/contracts/__tests__/contract-transport.test.js +163 -0
- package/dist/contracts/__tests__/persistent-registry.test.d.ts +1 -0
- package/dist/contracts/__tests__/persistent-registry.test.js +109 -0
- package/dist/contracts/__tests__/playground.test.d.ts +1 -0
- package/dist/contracts/__tests__/playground.test.js +213 -0
- package/dist/contracts/__tests__/validate.test.js +122 -0
- package/dist/contracts/builder.d.ts +9 -1
- package/dist/contracts/builder.js +13 -0
- package/dist/contracts/contract-provider-router.d.ts +72 -0
- package/dist/contracts/contract-provider-router.js +164 -0
- package/dist/contracts/contract-proxy.d.ts +333 -0
- package/dist/contracts/contract-proxy.js +663 -0
- package/dist/contracts/contract-resolvers.d.ts +66 -0
- package/dist/contracts/contract-resolvers.js +138 -0
- package/dist/contracts/contract-transport.d.ts +94 -0
- package/dist/contracts/contract-transport.js +159 -0
- package/dist/contracts/contracts.table.d.ts +1 -1
- package/dist/contracts/contracts.table.js +2 -2
- package/dist/contracts/index.d.ts +6 -1
- package/dist/contracts/index.js +25 -1
- package/dist/contracts/persistent-registry.js +3 -1
- package/dist/contracts/system/logs-contract.d.ts +24 -0
- package/dist/contracts/system/logs-contract.js +60 -0
- package/dist/contracts/types.d.ts +27 -4
- package/dist/contracts/validate.d.ts +2 -2
- package/dist/contracts/validate.js +44 -2
- package/dist/data/orm/decorators.d.ts +6 -0
- package/dist/data/orm/decorators.js +21 -0
- package/dist/data/user-trust-levels.d.ts +2 -0
- package/dist/data/user-trust-levels.js +2 -1
- package/dist/data/user-trust-levels.test.d.ts +1 -0
- package/dist/data/user-trust-levels.test.js +37 -0
- package/dist/device/binary-peer-connection-v2.d.ts +2 -0
- package/dist/device/binary-peer-connection-v2.js +12 -2
- package/dist/device/binary-peer-connection-v2.test.js +23 -0
- package/dist/device/get-trust-level-fn.d.ts +6 -0
- package/dist/device/get-trust-level-fn.js +12 -7
- package/dist/device/get-trust-level-fn.test.js +16 -2
- package/dist/logging/console-logs.table.d.ts +3 -0
- package/dist/logging/console-logs.table.js +2 -1
- package/dist/system-ids.d.ts +11 -0
- package/dist/system-ids.js +12 -1
- package/package.json +1 -1
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.sameUserContractPermissionCheck = exports.CONTRACT_CALL_RPC = void 0;
|
|
4
|
+
exports.createContractConsumer = createContractConsumer;
|
|
5
|
+
exports.createContractProvider = createContractProvider;
|
|
6
|
+
exports.createContractProviderEndpoint = createContractProviderEndpoint;
|
|
7
|
+
exports.createContractProviderSession = createContractProviderSession;
|
|
8
|
+
const decorators_1 = require("../data/orm/decorators");
|
|
9
|
+
const observable_1 = require("../observable");
|
|
10
|
+
const utils_1 = require("../utils");
|
|
11
|
+
const contract_transport_1 = require("./contract-transport");
|
|
12
|
+
const STANDARD_REMOTE_TABLE_METHODS = new Set([
|
|
13
|
+
"get",
|
|
14
|
+
"list",
|
|
15
|
+
"count",
|
|
16
|
+
"findOne",
|
|
17
|
+
"save",
|
|
18
|
+
"insert",
|
|
19
|
+
"update",
|
|
20
|
+
"delete",
|
|
21
|
+
]);
|
|
22
|
+
const FORBIDDEN_REMOTE_TABLE_PROPERTIES = new Set([
|
|
23
|
+
"__proto__",
|
|
24
|
+
"constructor",
|
|
25
|
+
"prototype",
|
|
26
|
+
"dataChanged",
|
|
27
|
+
"dataChangedEmitter",
|
|
28
|
+
"subscribe",
|
|
29
|
+
"unsubscribe",
|
|
30
|
+
"then",
|
|
31
|
+
"toString",
|
|
32
|
+
"valueOf",
|
|
33
|
+
]);
|
|
34
|
+
/**
|
|
35
|
+
* The RPC event name used to proxy a single {@link IContractCall} over a
|
|
36
|
+
* {@link Connection} (`connection.emit(CONTRACT_CALL_RPC, call)` on the consumer
|
|
37
|
+
* side; `connection.exposeRPC(CONTRACT_CALL_RPC, handler)` on the provider side).
|
|
38
|
+
*
|
|
39
|
+
* Alias of {@link CONTRACT_REQUEST_CHANNEL} — the fixed request/response channel of an
|
|
40
|
+
* {@link IContractTransport}. Kept for existing provider registrations that reference it.
|
|
41
|
+
*/
|
|
42
|
+
exports.CONTRACT_CALL_RPC = contract_transport_1.CONTRACT_REQUEST_CHANNEL;
|
|
43
|
+
/**
|
|
44
|
+
* Build the **consumer** side of a contract proxy from a contract definition and a
|
|
45
|
+
* transport. Member names come from the contract shape; table members are backed by a
|
|
46
|
+
* `Proxy` that forwards accessed method names. The provider accepts only standard data
|
|
47
|
+
* operations and custom methods marked by `ProxyClientTableMethodCalls`.
|
|
48
|
+
*
|
|
49
|
+
* Every access is turned into an {@link IContractCall} sent on the transport's fixed
|
|
50
|
+
* {@link CONTRACT_REQUEST_CHANNEL}; the resolved ack is the provider's return value.
|
|
51
|
+
*
|
|
52
|
+
* The consumer also subscribes to provider pushes: `events[name].subscribe(handler)` and
|
|
53
|
+
* `tables[name].dataChanged.subscribe(handler)` both send a `"subscribe"` request and
|
|
54
|
+
* demultiplex provider pushes arriving on {@link CONTRACT_NOTIFY_CHANNEL} by their
|
|
55
|
+
* `subscriptionId` (shared `subscribeVia` path). Observables use the same push channel but
|
|
56
|
+
* are *not* gated: each `observables[name]` is a live {@link Observable} mirror that subscribes
|
|
57
|
+
* implicitly at creation, loads an initial snapshot (awaitable via
|
|
58
|
+
* {@link IContractConsumer.loadingPromise}), and streams every subsequent change; local writes
|
|
59
|
+
* send a fire-and-forget `set`. Call {@link IContractConsumer.dispose} to remove the notify
|
|
60
|
+
* listener and unsubscribe every stream. Read-only observable writes are rejected on both
|
|
61
|
+
* consumer and provider boundaries. Disposal is idempotent and prevents new remote operations;
|
|
62
|
+
* cached observable reads remain available afterward.
|
|
63
|
+
*
|
|
64
|
+
* @param contract The contract definition (shape) being consumed.
|
|
65
|
+
* @param transport Bidirectional channel to the provider; requests go out on
|
|
66
|
+
* {@link CONTRACT_REQUEST_CHANNEL}, notifications arrive on {@link CONTRACT_NOTIFY_CHANNEL}.
|
|
67
|
+
* @param opts.dataContextId Optional data context to scope every call to.
|
|
68
|
+
*/
|
|
69
|
+
function createContractConsumer(contract, transport, opts = {}) {
|
|
70
|
+
const { contractId, version } = contract;
|
|
71
|
+
const { dataContextId } = opts;
|
|
72
|
+
let disposed = false;
|
|
73
|
+
const emitRequest = (call) => transport.emit(contract_transport_1.CONTRACT_REQUEST_CHANNEL, call);
|
|
74
|
+
const request = (call) => disposed
|
|
75
|
+
? Promise.reject(new Error(`Contract consumer ${contractId} v${version} is disposed`))
|
|
76
|
+
: emitRequest(call);
|
|
77
|
+
// Notify demux: one listener on the shared notify channel routes each push to the
|
|
78
|
+
// handler that owns its subscriptionId. Notifications for other consumers' ids
|
|
79
|
+
// (a shared transport is multiplexed) simply find no handler and are dropped.
|
|
80
|
+
const notifyHandlers = new Map();
|
|
81
|
+
const subscriptions = new Set();
|
|
82
|
+
const observableWriteSubscriptions = [];
|
|
83
|
+
const offNotify = transport.on(contract_transport_1.CONTRACT_NOTIFY_CHANNEL, (n) => {
|
|
84
|
+
notifyHandlers.get(n.subscriptionId)?.(n.value);
|
|
85
|
+
});
|
|
86
|
+
/**
|
|
87
|
+
* Shared subscribe path for every subscription-gated surface (generic events and table
|
|
88
|
+
* `dataChanged`). Registers `handler` under a fresh `subscriptionId` before asking the
|
|
89
|
+
* provider to start sending (so no push can arrive before its handler exists), fires the
|
|
90
|
+
* subscribe request fire-and-forget (like the local {@link Event} API), and returns an
|
|
91
|
+
* idempotent {@link ISubscriptionResult}. If unsubscribe or disposal happens before the
|
|
92
|
+
* subscribe ack, the wire unsubscribe is queued behind that ack; a failed subscribe sends
|
|
93
|
+
* no redundant unsubscribe. `buildCall` maps the `(subscriptionId, operation)` pair to the
|
|
94
|
+
* exact wire call for that surface.
|
|
95
|
+
*/
|
|
96
|
+
const subscribeVia = (buildCall) => (handler) => {
|
|
97
|
+
if (disposed) {
|
|
98
|
+
throw new Error(`Contract consumer ${contractId} v${version} is disposed`);
|
|
99
|
+
}
|
|
100
|
+
const subscriptionId = (0, utils_1.newid)();
|
|
101
|
+
let active = true;
|
|
102
|
+
let unsubscribeSent = false;
|
|
103
|
+
notifyHandlers.set(subscriptionId, handler);
|
|
104
|
+
const subscribeAck = emitRequest(buildCall(subscriptionId, "subscribe"))
|
|
105
|
+
.then(() => true)
|
|
106
|
+
.catch((err) => {
|
|
107
|
+
active = false;
|
|
108
|
+
notifyHandlers.delete(subscriptionId);
|
|
109
|
+
subscriptions.delete(subscription);
|
|
110
|
+
console.error("Contract subscribe failed", err);
|
|
111
|
+
return false;
|
|
112
|
+
});
|
|
113
|
+
const sendUnsubscribe = async () => {
|
|
114
|
+
if (unsubscribeSent) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
unsubscribeSent = true;
|
|
118
|
+
try {
|
|
119
|
+
await emitRequest(buildCall(subscriptionId, "unsubscribe"));
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
console.error("Contract unsubscribe failed", err);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
const subscription = {
|
|
126
|
+
unsubscribe: () => {
|
|
127
|
+
if (!active) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
active = false;
|
|
131
|
+
notifyHandlers.delete(subscriptionId);
|
|
132
|
+
subscriptions.delete(subscription);
|
|
133
|
+
void subscribeAck.then((subscribed) => {
|
|
134
|
+
if (subscribed) {
|
|
135
|
+
return sendUnsubscribe();
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
return true;
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
subscriptions.add(subscription);
|
|
142
|
+
return subscription;
|
|
143
|
+
};
|
|
144
|
+
const tables = {};
|
|
145
|
+
for (const table of contract.tables) {
|
|
146
|
+
const name = table.name;
|
|
147
|
+
// `dataChanged` is a subscribable that rides the notify channel (created lazily and
|
|
148
|
+
// cached); every other accessed name forwards as a remote method call.
|
|
149
|
+
let dataChanged;
|
|
150
|
+
tables[name] = new Proxy({}, {
|
|
151
|
+
get(_target, operation) {
|
|
152
|
+
if (typeof operation !== "string") {
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
if (operation === "dataChanged") {
|
|
156
|
+
dataChanged ??= {
|
|
157
|
+
subscribe: subscribeVia((subscriptionId, op) => ({
|
|
158
|
+
contractId,
|
|
159
|
+
version,
|
|
160
|
+
member: "table",
|
|
161
|
+
name,
|
|
162
|
+
operation: op,
|
|
163
|
+
event: "dataChanged",
|
|
164
|
+
subscriptionId,
|
|
165
|
+
args: [],
|
|
166
|
+
dataContextId,
|
|
167
|
+
})),
|
|
168
|
+
};
|
|
169
|
+
return dataChanged;
|
|
170
|
+
}
|
|
171
|
+
return (...args) => request({
|
|
172
|
+
contractId,
|
|
173
|
+
version,
|
|
174
|
+
member: "table",
|
|
175
|
+
name,
|
|
176
|
+
operation,
|
|
177
|
+
args,
|
|
178
|
+
dataContextId,
|
|
179
|
+
});
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
const tools = {};
|
|
184
|
+
for (const tool of contract.tools) {
|
|
185
|
+
const name = tool.name;
|
|
186
|
+
tools[name] = (...args) => request({
|
|
187
|
+
contractId,
|
|
188
|
+
version,
|
|
189
|
+
member: "tool",
|
|
190
|
+
name,
|
|
191
|
+
operation: "run",
|
|
192
|
+
args,
|
|
193
|
+
dataContextId,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
const events = {};
|
|
197
|
+
for (const evt of contract.events ?? []) {
|
|
198
|
+
const name = evt.name;
|
|
199
|
+
events[name] = {
|
|
200
|
+
subscribe: subscribeVia((subscriptionId, operation) => ({
|
|
201
|
+
contractId,
|
|
202
|
+
version,
|
|
203
|
+
member: "event",
|
|
204
|
+
name,
|
|
205
|
+
operation,
|
|
206
|
+
subscriptionId,
|
|
207
|
+
args: [],
|
|
208
|
+
dataContextId,
|
|
209
|
+
})),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
// Observables are backed by real, callable Observable mirrors (see below).
|
|
213
|
+
const observables = {};
|
|
214
|
+
const observableLoads = [];
|
|
215
|
+
const dispose = () => {
|
|
216
|
+
if (disposed) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
disposed = true;
|
|
220
|
+
for (const sub of [...subscriptions]) {
|
|
221
|
+
sub.unsubscribe();
|
|
222
|
+
}
|
|
223
|
+
for (const sub of observableWriteSubscriptions) {
|
|
224
|
+
sub.dispose();
|
|
225
|
+
}
|
|
226
|
+
offNotify();
|
|
227
|
+
notifyHandlers.clear();
|
|
228
|
+
};
|
|
229
|
+
const consumer = {
|
|
230
|
+
contractId,
|
|
231
|
+
version,
|
|
232
|
+
tables,
|
|
233
|
+
tools,
|
|
234
|
+
observables,
|
|
235
|
+
events,
|
|
236
|
+
loadingPromise: Promise.resolve(),
|
|
237
|
+
dispose,
|
|
238
|
+
};
|
|
239
|
+
// Build each observable as a locally-mirrored Observable kept current by provider pushes
|
|
240
|
+
// (decision 4: observables are always streamed so reads are synchronous). Writes are
|
|
241
|
+
// fire-and-forget and chained onto the proxy-level loadingPromise for awaitable durability
|
|
242
|
+
// — the same shape as a persistent var (see data/persistent-vars.ts).
|
|
243
|
+
for (const obs of contract.observables) {
|
|
244
|
+
const name = obs.name;
|
|
245
|
+
const mirror = (0, observable_1.observable)();
|
|
246
|
+
// Guards against echo loops: while applying a provider push we must not re-`set` it, and
|
|
247
|
+
// once a push has updated the mirror a later (possibly stale) initial `get` is ignored.
|
|
248
|
+
let applyingRemote = false;
|
|
249
|
+
let pushed = false;
|
|
250
|
+
const applyRemote = (value) => {
|
|
251
|
+
if (disposed) {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
pushed = true;
|
|
255
|
+
applyingRemote = true;
|
|
256
|
+
try {
|
|
257
|
+
mirror(value);
|
|
258
|
+
}
|
|
259
|
+
finally {
|
|
260
|
+
applyingRemote = false;
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
// Implicit, always-on subscription (not gated). `subscribeVia` registers the handler
|
|
264
|
+
// before asking the provider to start streaming, so no push can arrive unhandled.
|
|
265
|
+
subscribeVia((subscriptionId, operation) => ({
|
|
266
|
+
contractId,
|
|
267
|
+
version,
|
|
268
|
+
member: "observable",
|
|
269
|
+
name,
|
|
270
|
+
operation,
|
|
271
|
+
subscriptionId,
|
|
272
|
+
args: [],
|
|
273
|
+
dataContextId,
|
|
274
|
+
}))(applyRemote);
|
|
275
|
+
// Expose a guarded callable facade: cached reads remain available after disposal, while
|
|
276
|
+
// writes are rejected because they would otherwise mutate a dead mirror without reaching
|
|
277
|
+
// the provider. Provider pushes bypass this facade through applyRemote above.
|
|
278
|
+
const exposedMirror = ((...args) => {
|
|
279
|
+
if (args.length === 0) {
|
|
280
|
+
return mirror();
|
|
281
|
+
}
|
|
282
|
+
if (disposed) {
|
|
283
|
+
throw new Error(`Contract consumer ${contractId} v${version} is disposed`);
|
|
284
|
+
}
|
|
285
|
+
if (!obs.writable) {
|
|
286
|
+
throw new Error(`Observable '${name}' is read-only`);
|
|
287
|
+
}
|
|
288
|
+
return mirror(args[0]);
|
|
289
|
+
});
|
|
290
|
+
exposedMirror.subscribe = mirror.subscribe;
|
|
291
|
+
exposedMirror.notifySubscribers = mirror.notifySubscribers;
|
|
292
|
+
exposedMirror.subscriberCount = mirror.subscriberCount;
|
|
293
|
+
// Initial snapshot so the mirror has a value to read synchronously. A push that arrived
|
|
294
|
+
// first wins — the snapshot may already be stale by the time it resolves.
|
|
295
|
+
observableLoads.push(request({
|
|
296
|
+
contractId,
|
|
297
|
+
version,
|
|
298
|
+
member: "observable",
|
|
299
|
+
name,
|
|
300
|
+
operation: "get",
|
|
301
|
+
args: [],
|
|
302
|
+
dataContextId,
|
|
303
|
+
})
|
|
304
|
+
.then((value) => {
|
|
305
|
+
if (!disposed && !pushed) {
|
|
306
|
+
applyRemote(value);
|
|
307
|
+
}
|
|
308
|
+
})
|
|
309
|
+
.catch((err) => console.error("Contract observable initial get failed", err)));
|
|
310
|
+
// Local writes → fire-and-forget `set`, chained onto loadingPromise for durability.
|
|
311
|
+
observableWriteSubscriptions.push(mirror.subscribe((value) => {
|
|
312
|
+
if (applyingRemote || disposed) {
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
consumer.loadingPromise = consumer.loadingPromise.then(() => request({
|
|
316
|
+
contractId,
|
|
317
|
+
version,
|
|
318
|
+
member: "observable",
|
|
319
|
+
name,
|
|
320
|
+
operation: "set",
|
|
321
|
+
args: [value],
|
|
322
|
+
dataContextId,
|
|
323
|
+
})
|
|
324
|
+
.then(() => undefined)
|
|
325
|
+
.catch((err) => console.error("Contract observable set failed", err)));
|
|
326
|
+
}));
|
|
327
|
+
observables[name] = exposedMirror;
|
|
328
|
+
}
|
|
329
|
+
consumer.loadingPromise = Promise.all(observableLoads).then(() => undefined);
|
|
330
|
+
return consumer;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Legacy gate that allows calls only when caller and provider user ids match.
|
|
334
|
+
*
|
|
335
|
+
* @deprecated Production connection routing uses the signed-identity, provider-personal
|
|
336
|
+
* Self-trust policy in `createUserContextContractProviderRouter`. Keep this helper only
|
|
337
|
+
* for compatibility and isolated in-process providers that intentionally need the old gate.
|
|
338
|
+
*/
|
|
339
|
+
const sameUserContractPermissionCheck = (_call, context) => {
|
|
340
|
+
return Boolean(context.localUserId) && context.callerUserId === context.localUserId;
|
|
341
|
+
};
|
|
342
|
+
exports.sameUserContractPermissionCheck = sameUserContractPermissionCheck;
|
|
343
|
+
/**
|
|
344
|
+
* Build the **provider** side of a contract proxy: an async handler that permission-checks
|
|
345
|
+
* an {@link IContractCall} and dispatches it to the live table method / tool fn / observable
|
|
346
|
+
* accessor in the given resolution. Table dispatch is constrained to standard serializable
|
|
347
|
+
* data operations plus custom methods explicitly marked by `ProxyClientTableMethodCalls`;
|
|
348
|
+
* internal helpers, event properties, document/cursor helpers, and prototype properties
|
|
349
|
+
* are not remotely callable.
|
|
350
|
+
*
|
|
351
|
+
* @param resolution The live contract implementation to dispatch to.
|
|
352
|
+
* @param opts.permissionCheck Optional gate run before every dispatch.
|
|
353
|
+
*/
|
|
354
|
+
function createContractProvider(resolution, opts = {}) {
|
|
355
|
+
const { permissionCheck } = opts;
|
|
356
|
+
return async (call, context = {}) => {
|
|
357
|
+
if (call.contractId !== resolution.contractId || call.version !== resolution.version) {
|
|
358
|
+
throw new Error(`Contract mismatch: resolution provides ${resolution.contractId} v${resolution.version} ` +
|
|
359
|
+
`but call targets ${call.contractId} v${call.version}`);
|
|
360
|
+
}
|
|
361
|
+
if (permissionCheck) {
|
|
362
|
+
const allowed = await permissionCheck(call, context);
|
|
363
|
+
if (!allowed) {
|
|
364
|
+
throw new Error(`Permission denied: ${call.member} '${call.name}'.${call.operation} on contract ${call.contractId}`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
switch (call.member) {
|
|
368
|
+
case "table": {
|
|
369
|
+
const table = resolution.tables?.[call.name];
|
|
370
|
+
if (!table) {
|
|
371
|
+
throw new Error(`Contract ${call.contractId} does not provide table '${call.name}'`);
|
|
372
|
+
}
|
|
373
|
+
if (FORBIDDEN_REMOTE_TABLE_PROPERTIES.has(call.operation)) {
|
|
374
|
+
throw new Error(`Table method '${call.name}.${call.operation}' is not remotely callable`);
|
|
375
|
+
}
|
|
376
|
+
const method = table[call.operation];
|
|
377
|
+
// Unwrap client-proxy methods so we call the real implementation, not a
|
|
378
|
+
// method that would re-forward the call (see ProxyClientTableMethodCalls).
|
|
379
|
+
const impl = typeof method === "function" ? (method.__original ?? method) : undefined;
|
|
380
|
+
if (typeof impl !== "function") {
|
|
381
|
+
throw new Error(`Table '${call.name}' has no method '${call.operation}'`);
|
|
382
|
+
}
|
|
383
|
+
if (!STANDARD_REMOTE_TABLE_METHODS.has(call.operation) &&
|
|
384
|
+
!(0, decorators_1.isContractRemoteTableMethod)(method)) {
|
|
385
|
+
throw new Error(`Table method '${call.name}.${call.operation}' is not remotely callable`);
|
|
386
|
+
}
|
|
387
|
+
return impl.apply(table, call.args);
|
|
388
|
+
}
|
|
389
|
+
case "tool": {
|
|
390
|
+
if (call.operation !== "run") {
|
|
391
|
+
throw new Error(`Unsupported tool operation '${call.operation}' on '${call.name}'`);
|
|
392
|
+
}
|
|
393
|
+
const tool = resolution.tools?.[call.name];
|
|
394
|
+
if (!tool) {
|
|
395
|
+
throw new Error(`Contract ${call.contractId} does not provide tool '${call.name}'`);
|
|
396
|
+
}
|
|
397
|
+
return tool.toolFn(...call.args);
|
|
398
|
+
}
|
|
399
|
+
case "observable": {
|
|
400
|
+
const obs = resolution.observables?.[call.name];
|
|
401
|
+
if (!obs) {
|
|
402
|
+
throw new Error(`Contract ${call.contractId} does not provide observable '${call.name}'`);
|
|
403
|
+
}
|
|
404
|
+
if (call.operation === "get") {
|
|
405
|
+
return obs();
|
|
406
|
+
}
|
|
407
|
+
if (call.operation === "set") {
|
|
408
|
+
if (resolution.observableWritability?.[call.name] !== true) {
|
|
409
|
+
throw new Error(`Observable '${call.name}' is read-only`);
|
|
410
|
+
}
|
|
411
|
+
return obs(call.args[0]);
|
|
412
|
+
}
|
|
413
|
+
throw new Error(`Unsupported observable operation '${call.operation}' on '${call.name}'`);
|
|
414
|
+
}
|
|
415
|
+
default:
|
|
416
|
+
throw new Error(`Unsupported contract member type '${call.member}'`);
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Build a stateful **provider endpoint**: it serves the same request/response dispatch as
|
|
422
|
+
* {@link createContractProvider} for table/tool/observable calls, and additionally serves
|
|
423
|
+
* subscriptions on any streamable member — generic `event` calls, table events (a `table`
|
|
424
|
+
* member with `"subscribe"` / `"unsubscribe"`, resolving `dataChanged` by default), and
|
|
425
|
+
* observable value streams (an `observable` member with `"subscribe"` / `"unsubscribe"`;
|
|
426
|
+
* `get`/`set` still flow to the stateless dispatch).
|
|
427
|
+
*
|
|
428
|
+
* The live source — an {@link Event} (`resolution.events[name]` or a table's `dataChanged`)
|
|
429
|
+
* or an {@link Observable} (`resolution.observables[name]`) — is only subscribed to while at
|
|
430
|
+
* least one consumer holds a subscription (refcounted by a resolved key), and each payload is
|
|
431
|
+
* fanned out through the supplied {@link ContractNotificationSink} as an
|
|
432
|
+
* {@link IContractNotification}. The endpoint owns no request listener, which lets a
|
|
433
|
+
* connection-wide router multiplex many endpoints through one request channel and later
|
|
434
|
+
* substitute sandbox-backed endpoint implementations.
|
|
435
|
+
*
|
|
436
|
+
* @param resolution The live contract implementation to dispatch to and stream from.
|
|
437
|
+
* @param opts Notification sink plus optional permission gate and default context.
|
|
438
|
+
*/
|
|
439
|
+
function createContractProviderEndpoint(resolution, opts) {
|
|
440
|
+
const { notify, permissionCheck, context: defaultContext } = opts;
|
|
441
|
+
const dispatch = createContractProvider(resolution, { permissionCheck });
|
|
442
|
+
// One live subscription per resolved subscription key, refcounted by the set of
|
|
443
|
+
// consumer subscription ids currently attached to it, plus a reverse index for
|
|
444
|
+
// unsubscribe. Keys namespace generic events (`event:<name>`), table events
|
|
445
|
+
// (`table:<name>:<event>`), and observable streams (`observable:<name>`) so the same
|
|
446
|
+
// machinery serves all three.
|
|
447
|
+
const byKey = new Map();
|
|
448
|
+
const subIdToKey = new Map();
|
|
449
|
+
const pendingSubIds = new Set();
|
|
450
|
+
let disposed = false;
|
|
451
|
+
const notifySink = (subscriptionId, value, debugName) => notify({
|
|
452
|
+
subscriptionId,
|
|
453
|
+
value,
|
|
454
|
+
contractId: resolution.contractId,
|
|
455
|
+
name: debugName,
|
|
456
|
+
});
|
|
457
|
+
/**
|
|
458
|
+
* Resolve a subscription call to a stable refcount `key` plus an `attach` function that
|
|
459
|
+
* hooks a payload handler onto the live source and returns a detach callback. This
|
|
460
|
+
* normalizes the three streamable sources behind one interface:
|
|
461
|
+
* - generic `event` → `resolution.events[name]` (an {@link Event}; `subscribe` returns
|
|
462
|
+
* `{ unsubscribe }`), key `event:<name>`.
|
|
463
|
+
* - observable value stream → `resolution.observables[name]` (an {@link Observable};
|
|
464
|
+
* `subscribe` returns `{ dispose }`), key `observable:<name>`.
|
|
465
|
+
* - table event → `resolution.tables[name][event ?? "dataChanged"]` (an {@link Event};
|
|
466
|
+
* a `Table` exposes `dataChanged` as a plain `Event`), key `table:<name>:<event>`.
|
|
467
|
+
*/
|
|
468
|
+
const resolveSubscription = (call) => {
|
|
469
|
+
if (call.member === "event") {
|
|
470
|
+
const liveEvent = resolution.events?.[call.name];
|
|
471
|
+
if (!liveEvent) {
|
|
472
|
+
throw new Error(`Contract ${resolution.contractId} does not provide event '${call.name}'`);
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
key: `event:${call.name}`,
|
|
476
|
+
attach: (handler) => {
|
|
477
|
+
const sub = liveEvent.subscribe(handler);
|
|
478
|
+
return () => sub.unsubscribe();
|
|
479
|
+
},
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
if (call.member === "observable") {
|
|
483
|
+
const obs = resolution.observables?.[call.name];
|
|
484
|
+
if (!obs) {
|
|
485
|
+
throw new Error(`Contract ${resolution.contractId} does not provide observable '${call.name}'`);
|
|
486
|
+
}
|
|
487
|
+
return {
|
|
488
|
+
key: `observable:${call.name}`,
|
|
489
|
+
attach: (handler) => {
|
|
490
|
+
const sub = obs.subscribe(handler);
|
|
491
|
+
return () => sub.dispose();
|
|
492
|
+
},
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
const table = resolution.tables?.[call.name];
|
|
496
|
+
if (!table) {
|
|
497
|
+
throw new Error(`Contract ${resolution.contractId} does not provide table '${call.name}'`);
|
|
498
|
+
}
|
|
499
|
+
const evtName = call.event ?? "dataChanged";
|
|
500
|
+
const liveEvent = table[evtName];
|
|
501
|
+
if (!liveEvent || typeof liveEvent.subscribe !== "function") {
|
|
502
|
+
throw new Error(`Table '${call.name}' does not provide event '${evtName}'`);
|
|
503
|
+
}
|
|
504
|
+
return {
|
|
505
|
+
key: `table:${call.name}:${evtName}`,
|
|
506
|
+
attach: (handler) => {
|
|
507
|
+
const sub = liveEvent.subscribe(handler);
|
|
508
|
+
return () => sub.unsubscribe();
|
|
509
|
+
},
|
|
510
|
+
};
|
|
511
|
+
};
|
|
512
|
+
const subscribe = (key, attach, subscriptionId) => {
|
|
513
|
+
if (subIdToKey.has(subscriptionId)) {
|
|
514
|
+
throw new Error(`Duplicate contract subscriptionId '${subscriptionId}'`);
|
|
515
|
+
}
|
|
516
|
+
let entry = byKey.get(key);
|
|
517
|
+
if (!entry) {
|
|
518
|
+
// First subscriber for this key: attach the single live subscription.
|
|
519
|
+
const subIds = new Set();
|
|
520
|
+
const detach = attach((payload) => {
|
|
521
|
+
// Start each notification independently. A slow or rejected destination must not
|
|
522
|
+
// block sibling subscribers, and every rejection is handled because Observable
|
|
523
|
+
// callbacks do not await returned promises.
|
|
524
|
+
for (const sid of [...subIds]) {
|
|
525
|
+
void notifySink(sid, payload, key).catch((err) => {
|
|
526
|
+
console.error(`Contract notify failed for subscription '${sid}'`, err);
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
entry = { liveUnsub: detach, subIds };
|
|
531
|
+
byKey.set(key, entry);
|
|
532
|
+
}
|
|
533
|
+
subIdToKey.set(subscriptionId, key);
|
|
534
|
+
entry.subIds.add(subscriptionId);
|
|
535
|
+
};
|
|
536
|
+
const unsubscribe = (subscriptionId) => {
|
|
537
|
+
const key = subIdToKey.get(subscriptionId);
|
|
538
|
+
if (!key) {
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
subIdToKey.delete(subscriptionId);
|
|
542
|
+
const entry = byKey.get(key);
|
|
543
|
+
if (!entry) {
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
entry.subIds.delete(subscriptionId);
|
|
547
|
+
if (entry.subIds.size === 0) {
|
|
548
|
+
// Last subscriber left: detach the live subscription (nothing more is pushed).
|
|
549
|
+
entry.liveUnsub();
|
|
550
|
+
byKey.delete(key);
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
const validateSubscriptionCall = (call) => {
|
|
554
|
+
if (call.member !== "event" && call.member !== "table" && call.member !== "observable") {
|
|
555
|
+
throw new Error(`Contract member type '${call.member}' does not support subscriptions`);
|
|
556
|
+
}
|
|
557
|
+
if (call.operation !== "subscribe" && call.operation !== "unsubscribe") {
|
|
558
|
+
throw new Error(`Unsupported subscription operation '${call.operation}' on '${call.name}'`);
|
|
559
|
+
}
|
|
560
|
+
if (call.member === "table" && call.event !== undefined && call.event !== "dataChanged") {
|
|
561
|
+
throw new Error(`Unsupported table event '${call.event}' on '${call.name}'`);
|
|
562
|
+
}
|
|
563
|
+
if (call.member !== "table" && call.event !== undefined) {
|
|
564
|
+
throw new Error(`Contract ${call.member} '${call.name}'.${call.operation} cannot specify a table event`);
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
const handleSubscriptionCall = async (call, context) => {
|
|
568
|
+
if (call.contractId !== resolution.contractId || call.version !== resolution.version) {
|
|
569
|
+
throw new Error(`Contract mismatch: resolution provides ${resolution.contractId} v${resolution.version} ` +
|
|
570
|
+
`but call targets ${call.contractId} v${call.version}`);
|
|
571
|
+
}
|
|
572
|
+
validateSubscriptionCall(call);
|
|
573
|
+
if (!call.subscriptionId) {
|
|
574
|
+
throw new Error(`${call.member} '${call.name}'.${call.operation} requires a subscriptionId`);
|
|
575
|
+
}
|
|
576
|
+
if (call.operation === "subscribe") {
|
|
577
|
+
if (subIdToKey.has(call.subscriptionId) || pendingSubIds.has(call.subscriptionId)) {
|
|
578
|
+
throw new Error(`Duplicate contract subscriptionId '${call.subscriptionId}'`);
|
|
579
|
+
}
|
|
580
|
+
pendingSubIds.add(call.subscriptionId);
|
|
581
|
+
try {
|
|
582
|
+
if (permissionCheck) {
|
|
583
|
+
const allowed = await permissionCheck(call, context);
|
|
584
|
+
if (!allowed) {
|
|
585
|
+
throw new Error(`Permission denied: ${call.member} '${call.name}'.subscribe on contract ${call.contractId}`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
if (disposed) {
|
|
589
|
+
throw new Error(`Contract provider endpoint ${resolution.contractId} v${resolution.version} is disposed`);
|
|
590
|
+
}
|
|
591
|
+
const { key, attach } = resolveSubscription(call);
|
|
592
|
+
subscribe(key, attach, call.subscriptionId);
|
|
593
|
+
}
|
|
594
|
+
finally {
|
|
595
|
+
pendingSubIds.delete(call.subscriptionId);
|
|
596
|
+
}
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
if (call.operation === "unsubscribe") {
|
|
600
|
+
unsubscribe(call.subscriptionId);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
throw new Error(`Unsupported subscription operation '${call.operation}' on '${call.name}'`);
|
|
604
|
+
};
|
|
605
|
+
return {
|
|
606
|
+
handleCall(call, suppliedContext) {
|
|
607
|
+
if (disposed) {
|
|
608
|
+
return Promise.reject(new Error(`Contract provider endpoint ${resolution.contractId} v${resolution.version} is disposed`));
|
|
609
|
+
}
|
|
610
|
+
const context = suppliedContext ?? defaultContext ?? {};
|
|
611
|
+
// The reserved subscribe/unsubscribe operations are subscription traffic on every
|
|
612
|
+
// streamable member (generic events, table events, and observable value streams);
|
|
613
|
+
// everything else (table methods, tool runs, observable get/set) is stateless dispatch.
|
|
614
|
+
const isSubscription = call.member === "event" ||
|
|
615
|
+
call.operation === "subscribe" ||
|
|
616
|
+
call.operation === "unsubscribe";
|
|
617
|
+
return isSubscription ? handleSubscriptionCall(call, context) : dispatch(call, context);
|
|
618
|
+
},
|
|
619
|
+
dispose() {
|
|
620
|
+
if (disposed) {
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
disposed = true;
|
|
624
|
+
for (const entry of byKey.values()) {
|
|
625
|
+
entry.liveUnsub();
|
|
626
|
+
}
|
|
627
|
+
byKey.clear();
|
|
628
|
+
subIdToKey.clear();
|
|
629
|
+
pendingSubIds.clear();
|
|
630
|
+
},
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Build the backward-compatible transport-bound provider session. The session owns the
|
|
635
|
+
* transport's request listener while its underlying {@link IContractProviderEndpoint} owns
|
|
636
|
+
* request dispatch and live subscription state.
|
|
637
|
+
*
|
|
638
|
+
* Use {@link createContractProviderEndpoint} behind a connection-wide router when multiple
|
|
639
|
+
* contracts or data contexts share one transport.
|
|
640
|
+
*
|
|
641
|
+
* @param resolution The live contract implementation to dispatch to and stream from.
|
|
642
|
+
* @param transport Requests arrive on {@link CONTRACT_REQUEST_CHANNEL}; notifications are
|
|
643
|
+
* emitted on {@link CONTRACT_NOTIFY_CHANNEL}.
|
|
644
|
+
* @param opts Optional permission gate and default caller context.
|
|
645
|
+
*/
|
|
646
|
+
function createContractProviderSession(resolution, transport, opts = {}) {
|
|
647
|
+
const endpoint = createContractProviderEndpoint(resolution, {
|
|
648
|
+
...opts,
|
|
649
|
+
notify: (notification) => transport.emit(contract_transport_1.CONTRACT_NOTIFY_CHANNEL, notification),
|
|
650
|
+
});
|
|
651
|
+
const offRequest = transport.on(contract_transport_1.CONTRACT_REQUEST_CHANNEL, (call, context) => endpoint.handleCall(call, context));
|
|
652
|
+
let disposed = false;
|
|
653
|
+
return {
|
|
654
|
+
dispose() {
|
|
655
|
+
if (disposed) {
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
disposed = true;
|
|
659
|
+
offRequest();
|
|
660
|
+
endpoint.dispose();
|
|
661
|
+
},
|
|
662
|
+
};
|
|
663
|
+
}
|