@peers-app/peers-sdk 0.28.12 → 0.29.1

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.
@@ -14,7 +14,7 @@ export { hostContractConsumer, type IHostContractConsumer, type IHostContractCon
14
14
  export { getHostContractTransport, type HostContractTransportListener, registerHostContractTransport, subscribeHostContractTransport, waitForHostContractTransport, } from "./host-contract-transport";
15
15
  export { PersistentContractRegistry } from "./persistent-registry";
16
16
  export { ContractRegistry, type IContractProviderRegistration } from "./registry";
17
- export { createRemoteContractEndpoint, getRemoteContractTransportFactory, type IRemoteContractHost, type IRemoteContractTarget, type RemoteContractTransportFactory, resolveRemoteTargetEndpoint, setRemoteContractTransportFactory, } from "./remote-contract-transport";
17
+ export { connectToRemoteDevice, createRemoteContractEndpoint, getRemoteContractTransportFactory, type IRemoteContractHost, type IRemoteContractTarget, type RemoteContractTransportFactory, type RemoteDeviceConnector, resolveRemoteTargetEndpoint, setRemoteContractTransportFactory, setRemoteDeviceConnector, } from "./remote-contract-transport";
18
18
  export * from "./system/app-updates-contract";
19
19
  export { CONTRACT_CATALOG_VERSION, contractCatalogProviderPackageId, getContractCatalogDefinition, type IContractCatalogConsumer, type IContractDefinitionWithPolicy, type IContractProviderInfo, type IContractToolPolicy, type IExposedContractInfo, type IExposedToolInfo, type IGetContractDefinitionInput, type IListContractProvidersInput, } from "./system/contract-catalog-contract";
20
20
  export * from "./system/desktop-tools-contract";
@@ -15,8 +15,8 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  exports.ContractRegistry = exports.PersistentContractRegistry = exports.waitForHostContractTransport = exports.subscribeHostContractTransport = exports.registerHostContractTransport = exports.getHostContractTransport = exports.hostContractConsumer = exports.hostContract = exports.freezeContracts = exports.FROZEN_CONTRACTS_FILE = exports.describeFrozenContractChanges = exports.checkFrozenContracts = exports.setSchemaToFieldsFn = exports.extractToolShape = exports.extractTableShape = exports.createLocalContractConsumer = exports.contractsSchema = exports.contractsMetaData = exports.ContractsTable = exports.Contracts = exports.inProcessTransportPair = exports.connectionContractTransport = exports.CONTRACT_REQUEST_CHANNEL = exports.CONTRACT_NOTIFY_CHANNEL = exports.verifyRemoteContractIdentity = exports.registerContractResolver = exports.isSelfTrustedContractCaller = exports.isLocalOnlyContractId = exports.handleContractCall = exports.getContractResolver = exports.createUserContextContractProviderRouter = exports.createLocalUserContextContractProviderRouter = exports.authorizeRemoteContractCall = exports.sameUserContractPermissionCheck = exports.requireWorkflowRunContext = exports.createRemoteContractConsumer = exports.createContractProviderSession = exports.createContractProviderEndpoint = exports.createContractProvider = exports.createContractConsumer = exports.CONTRACT_CALL_RPC = exports.contractProvidersSchema = exports.contractProvidersMetaData = exports.ContractProvidersTable = exports.ContractProviders = exports.RetiredContractProviderEndpointError = exports.createContractProviderRouter = exports.PackageBuilder = exports.definePackage = exports.ContractBuilder = void 0;
18
- exports.listSystemContracts = exports.isSystemContractId = exports.installSystemContracts = exports.TOOL_ACCESS_CONTROL_CONTRACT_VERSION = exports.getToolAccessControlContractDefinition = exports.schedulerProviderPackageId = exports.SCHEDULER_MAX_DELAY_MS = exports.SCHEDULER_CONTRACT_VERSION = exports.getSchedulerContractDefinition = exports.resolveLogsContract = exports.LOGS_CONTRACT_VERSION = exports.getLogsContractDefinition = exports.localFoldersProviderPackageId = exports.LOCAL_FOLDERS_RESERVED_DIR = exports.LOCAL_FOLDERS_READ_RANGE_MAX = exports.LOCAL_FOLDERS_JOB_TTL_MS = exports.LOCAL_FOLDERS_HAVE_BATCH = exports.LOCAL_FOLDERS_GETBLOCKS_MAX_BYTES = exports.LOCAL_FOLDERS_GETBLOCKS_MAX_BLOCKS = exports.LOCAL_FOLDERS_CONTRACT_VERSION = exports.LOCAL_FOLDERS_CHUNK_MIN = exports.LOCAL_FOLDERS_CHUNK_MAX = exports.LOCAL_FOLDERS_CHUNK_AVG = exports.LOCAL_FOLDERS_ASSEMBLE_STALL_MS = exports.getLocalFoldersContractDefinition = exports.INVITES_CONTRACT_VERSION = exports.getInvitesContractDefinition = exports.IDENTITY_CONTRACT_VERSION = exports.getIdentityContractDefinition = exports.httpProviderPackageId = exports.HTTP_MAX_TIMEOUT_MS = exports.HTTP_MAX_BODY_BYTES = exports.HTTP_DEFAULT_TIMEOUT_MS = exports.HTTP_CONTRACT_VERSION = exports.getHttpContractDefinition = exports.getDeviceOperationsContractDefinition = exports.deviceOperationsToolIds = exports.deviceOperationsProviderPackageId = exports.DEVICE_OPERATIONS_MAX_TOOLS = exports.DEVICE_OPERATIONS_MAX_ROWS = exports.DEVICE_OPERATIONS_MAX_RESULT_BYTES = exports.DEVICE_OPERATIONS_MAX_QUERY_LENGTH = exports.DEVICE_OPERATIONS_CONTRACT_VERSION = exports.getContractCatalogDefinition = exports.contractCatalogProviderPackageId = exports.CONTRACT_CATALOG_VERSION = exports.setRemoteContractTransportFactory = exports.resolveRemoteTargetEndpoint = exports.getRemoteContractTransportFactory = exports.createRemoteContractEndpoint = void 0;
19
- exports.validateProviderSatisfiesContract = exports.validateImmutability = exports.validateAlsoImplements = exports.contractShapeChanges = exports.contractKey = exports.registerSystemContract = void 0;
18
+ exports.installSystemContracts = exports.TOOL_ACCESS_CONTROL_CONTRACT_VERSION = exports.getToolAccessControlContractDefinition = exports.schedulerProviderPackageId = exports.SCHEDULER_MAX_DELAY_MS = exports.SCHEDULER_CONTRACT_VERSION = exports.getSchedulerContractDefinition = exports.resolveLogsContract = exports.LOGS_CONTRACT_VERSION = exports.getLogsContractDefinition = exports.localFoldersProviderPackageId = exports.LOCAL_FOLDERS_RESERVED_DIR = exports.LOCAL_FOLDERS_READ_RANGE_MAX = exports.LOCAL_FOLDERS_JOB_TTL_MS = exports.LOCAL_FOLDERS_HAVE_BATCH = exports.LOCAL_FOLDERS_GETBLOCKS_MAX_BYTES = exports.LOCAL_FOLDERS_GETBLOCKS_MAX_BLOCKS = exports.LOCAL_FOLDERS_CONTRACT_VERSION = exports.LOCAL_FOLDERS_CHUNK_MIN = exports.LOCAL_FOLDERS_CHUNK_MAX = exports.LOCAL_FOLDERS_CHUNK_AVG = exports.LOCAL_FOLDERS_ASSEMBLE_STALL_MS = exports.getLocalFoldersContractDefinition = exports.INVITES_CONTRACT_VERSION = exports.getInvitesContractDefinition = exports.IDENTITY_CONTRACT_VERSION = exports.getIdentityContractDefinition = exports.httpProviderPackageId = exports.HTTP_MAX_TIMEOUT_MS = exports.HTTP_MAX_BODY_BYTES = exports.HTTP_DEFAULT_TIMEOUT_MS = exports.HTTP_CONTRACT_VERSION = exports.getHttpContractDefinition = exports.getDeviceOperationsContractDefinition = exports.deviceOperationsToolIds = exports.deviceOperationsProviderPackageId = exports.DEVICE_OPERATIONS_MAX_TOOLS = exports.DEVICE_OPERATIONS_MAX_ROWS = exports.DEVICE_OPERATIONS_MAX_RESULT_BYTES = exports.DEVICE_OPERATIONS_MAX_QUERY_LENGTH = exports.DEVICE_OPERATIONS_CONTRACT_VERSION = exports.getContractCatalogDefinition = exports.contractCatalogProviderPackageId = exports.CONTRACT_CATALOG_VERSION = exports.setRemoteDeviceConnector = exports.setRemoteContractTransportFactory = exports.resolveRemoteTargetEndpoint = exports.getRemoteContractTransportFactory = exports.createRemoteContractEndpoint = exports.connectToRemoteDevice = void 0;
19
+ exports.validateProviderSatisfiesContract = exports.validateImmutability = exports.validateAlsoImplements = exports.contractShapeChanges = exports.contractKey = exports.registerSystemContract = exports.listSystemContracts = exports.isSystemContractId = void 0;
20
20
  __exportStar(require("./action-dispatch"), exports);
21
21
  var builder_1 = require("./builder");
22
22
  Object.defineProperty(exports, "ContractBuilder", { enumerable: true, get: function () { return builder_1.ContractBuilder; } });
@@ -84,10 +84,12 @@ Object.defineProperty(exports, "PersistentContractRegistry", { enumerable: true,
84
84
  var registry_1 = require("./registry");
85
85
  Object.defineProperty(exports, "ContractRegistry", { enumerable: true, get: function () { return registry_1.ContractRegistry; } });
86
86
  var remote_contract_transport_1 = require("./remote-contract-transport");
87
+ Object.defineProperty(exports, "connectToRemoteDevice", { enumerable: true, get: function () { return remote_contract_transport_1.connectToRemoteDevice; } });
87
88
  Object.defineProperty(exports, "createRemoteContractEndpoint", { enumerable: true, get: function () { return remote_contract_transport_1.createRemoteContractEndpoint; } });
88
89
  Object.defineProperty(exports, "getRemoteContractTransportFactory", { enumerable: true, get: function () { return remote_contract_transport_1.getRemoteContractTransportFactory; } });
89
90
  Object.defineProperty(exports, "resolveRemoteTargetEndpoint", { enumerable: true, get: function () { return remote_contract_transport_1.resolveRemoteTargetEndpoint; } });
90
91
  Object.defineProperty(exports, "setRemoteContractTransportFactory", { enumerable: true, get: function () { return remote_contract_transport_1.setRemoteContractTransportFactory; } });
92
+ Object.defineProperty(exports, "setRemoteDeviceConnector", { enumerable: true, get: function () { return remote_contract_transport_1.setRemoteDeviceConnector; } });
91
93
  __exportStar(require("./system/app-updates-contract"), exports);
92
94
  var contract_catalog_contract_1 = require("./system/contract-catalog-contract");
93
95
  Object.defineProperty(exports, "CONTRACT_CATALOG_VERSION", { enumerable: true, get: function () { return contract_catalog_contract_1.CONTRACT_CATALOG_VERSION; } });
@@ -43,6 +43,33 @@ export declare function setRemoteContractTransportFactory(factory: RemoteContrac
43
43
  * @param userContext Optional owning user context.
44
44
  */
45
45
  export declare function getRemoteContractTransportFactory(userContext?: UserContext): RemoteContractTransportFactory | undefined;
46
+ /**
47
+ * Host-registered function that asks for a direct connection to one device, for a caller about
48
+ * to make calls that benefit from one (a large transfer, say). Calls never dial on their own:
49
+ * they use a direct connection when one exists and the mesh otherwise.
50
+ *
51
+ * Resolves `true` when a verified direct connection to `target.deviceId` exists, `false` when
52
+ * none could be made in time (unreachable, no free connection slot, a recent failed attempt).
53
+ * Never rejects for an unreachable device, and promises nothing about how long the connection
54
+ * stays up. Only devices that share `target.dataContextId` answer.
55
+ */
56
+ export type RemoteDeviceConnector = (target: IRemoteContractTarget) => Promise<boolean>;
57
+ /**
58
+ * Register (or clear, with `undefined`) the {@link RemoteDeviceConnector} for one user context.
59
+ * A connection manager registers its own beside its transport factory.
60
+ *
61
+ * @param connector Connector, or `undefined` to unregister.
62
+ * @param userContext Owning user context.
63
+ */
64
+ export declare function setRemoteDeviceConnector(connector: RemoteDeviceConnector | undefined, userContext: UserContext): void;
65
+ /**
66
+ * Ask for a direct connection to `target.deviceId` through the connector registered for
67
+ * `userContext`. Resolves `false` when none is registered (a host without device connections).
68
+ *
69
+ * @param target Device to connect to, and the data context it must share.
70
+ * @param userContext Owning user context.
71
+ */
72
+ export declare function connectToRemoteDevice(target: IRemoteContractTarget, userContext: UserContext | undefined): Promise<boolean>;
46
73
  /**
47
74
  * Shared chokepoint for trusted local hosts: when an authorized route names a device
48
75
  * other than this one, return a forwarding endpoint; otherwise return `undefined` so the
@@ -2,6 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.setRemoteContractTransportFactory = setRemoteContractTransportFactory;
4
4
  exports.getRemoteContractTransportFactory = getRemoteContractTransportFactory;
5
+ exports.setRemoteDeviceConnector = setRemoteDeviceConnector;
6
+ exports.connectToRemoteDevice = connectToRemoteDevice;
5
7
  exports.resolveRemoteTargetEndpoint = resolveRemoteTargetEndpoint;
6
8
  exports.createRemoteContractEndpoint = createRemoteContractEndpoint;
7
9
  const contract_transport_1 = require("./contract-transport");
@@ -47,6 +49,31 @@ function setRemoteContractTransportFactory(factory, opts = {}) {
47
49
  function getRemoteContractTransportFactory(userContext) {
48
50
  return (userContext && factoriesByUserContext.get(userContext)) ?? defaultFactory;
49
51
  }
52
+ const connectorsByUserContext = new WeakMap();
53
+ /**
54
+ * Register (or clear, with `undefined`) the {@link RemoteDeviceConnector} for one user context.
55
+ * A connection manager registers its own beside its transport factory.
56
+ *
57
+ * @param connector Connector, or `undefined` to unregister.
58
+ * @param userContext Owning user context.
59
+ */
60
+ function setRemoteDeviceConnector(connector, userContext) {
61
+ if (connector)
62
+ connectorsByUserContext.set(userContext, connector);
63
+ else
64
+ connectorsByUserContext.delete(userContext);
65
+ }
66
+ /**
67
+ * Ask for a direct connection to `target.deviceId` through the connector registered for
68
+ * `userContext`. Resolves `false` when none is registered (a host without device connections).
69
+ *
70
+ * @param target Device to connect to, and the data context it must share.
71
+ * @param userContext Owning user context.
72
+ */
73
+ function connectToRemoteDevice(target, userContext) {
74
+ const connector = userContext && connectorsByUserContext.get(userContext);
75
+ return connector ? connector(target) : Promise.resolve(false);
76
+ }
50
77
  /**
51
78
  * Shared chokepoint for trusted local hosts: when an authorized route names a device
52
79
  * other than this one, return a forwarding endpoint; otherwise return `undefined` so the
@@ -158,7 +158,7 @@ function getNetworkControlContractDefinition() {
158
158
  },
159
159
  {
160
160
  name: "connectToDevice",
161
- usageDescription: "Start a connection attempt to one known device.",
161
+ usageDescription: "Connect directly to one known device, waiting up to 15 seconds, and report whether a direct connection exists.",
162
162
  inputFields: deviceInput,
163
163
  outputFields: resultFields,
164
164
  },
@@ -8,7 +8,10 @@ export declare const PACKAGES_CONTRACT_VERSION = 1;
8
8
  export interface IPackagesHostCapabilities {
9
9
  /** The host can read package folders: import, create, clone, and reload from disk. */
10
10
  fileSystem: boolean;
11
- /** The host can create and link a remote repository (`linkRemoteRepo`). */
11
+ /**
12
+ * The host can create and link a remote repository (`linkRemoteRepo` without a URL). Recording
13
+ * a given URL needs no host tooling.
14
+ */
12
15
  linkRemote: boolean;
13
16
  /** The host can install the default packages into a context (`installDefaultPackages`). */
14
17
  installDefaults: boolean;
@@ -172,6 +175,14 @@ export declare function packageVersionPromotionTargets(versionTag: string | unde
172
175
  * @param target - Input as typed.
173
176
  */
174
177
  export declare function isPackageRepositoryUrl(target: string): boolean;
178
+ /**
179
+ * Whether a URL may be recorded as a package's source (`Packages.remoteRepo`) by
180
+ * `linkRemoteRepo`: an `https://` or `http://` URL, a `git@host:` remote, or a Git package
181
+ * remote, `peers://<groupId>/<repoId>`. No whitespace; at most 2,048 characters.
182
+ *
183
+ * @param url - The URL to record.
184
+ */
185
+ export declare function isPackageSourceUrl(url: string): boolean;
175
186
  /** Typed consumer proxy for System Packages v1. */
176
187
  export interface IPackagesConsumer extends IContractConsumerBase {
177
188
  tools: {
@@ -223,9 +234,14 @@ export interface IPackagesConsumer extends IContractConsumerBase {
223
234
  }): Promise<IPackageOperationResult>;
224
235
  /** Re-read every package folder on this device and reload packages whose bundles changed. */
225
236
  reloadAllPackages(): Promise<IReloadAllPackagesResult>;
226
- /** Create and link a remote repository for a local package. */
237
+ /**
238
+ * Record a package's source repository. With `remoteRepo` (see {@link isPackageSourceUrl}),
239
+ * record that URL; otherwise create a remote repository with the host's tooling and link it.
240
+ * A package that already has a source keeps it and the result carries a warning.
241
+ */
227
242
  linkRemoteRepo(input: {
228
243
  packageId: string;
244
+ remoteRepo?: string;
229
245
  }): Promise<IPackageOperationResult>;
230
246
  /** Remove a package from the group and unload it here. */
231
247
  uninstallPackage(input: {
@@ -4,6 +4,7 @@ exports.PACKAGES_CONTRACT_VERSION = void 0;
4
4
  exports.packageGroupVersionWarning = packageGroupVersionWarning;
5
5
  exports.packageVersionPromotionTargets = packageVersionPromotionTargets;
6
6
  exports.isPackageRepositoryUrl = isPackageRepositoryUrl;
7
+ exports.isPackageSourceUrl = isPackageSourceUrl;
7
8
  exports.getPackagesContractDefinition = getPackagesContractDefinition;
8
9
  const system_ids_1 = require("../../system-ids");
9
10
  const field_type_1 = require("../../types/field-type");
@@ -43,6 +44,29 @@ function packageVersionPromotionTargets(versionTag) {
43
44
  function isPackageRepositoryUrl(target) {
44
45
  return /^(https?:\/\/|git@[^:/\s]+:)/.test(target.trim());
45
46
  }
47
+ /**
48
+ * Whether a URL may be recorded as a package's source (`Packages.remoteRepo`) by
49
+ * `linkRemoteRepo`: an `https://` or `http://` URL, a `git@host:` remote, or a Git package
50
+ * remote, `peers://<groupId>/<repoId>`. No whitespace; at most 2,048 characters.
51
+ *
52
+ * @param url - The URL to record.
53
+ */
54
+ function isPackageSourceUrl(url) {
55
+ if (url.length > 2048 || /\s|\p{Cc}/u.test(url))
56
+ return false;
57
+ if (/^peers:\/\/[A-Za-z0-9]{25}\/[A-Za-z0-9]{25}$/.test(url))
58
+ return true;
59
+ if (/^git@[^:/\\]+:.+$/.test(url))
60
+ return true;
61
+ if (!/^https?:\/\//.test(url) || url.includes("\\"))
62
+ return false;
63
+ try {
64
+ return !!new URL(url).hostname;
65
+ }
66
+ catch {
67
+ return false;
68
+ }
69
+ }
46
70
  const field = (name, type, description, opts = {}) => ({ name, type, description, ...opts });
47
71
  const packageIdField = field("packageId", field_type_1.FieldType.id, "Package id.");
48
72
  const operationResultFields = [
@@ -162,8 +186,11 @@ function getPackagesContractDefinition() {
162
186
  },
163
187
  {
164
188
  name: "linkRemoteRepo",
165
- usageDescription: "Create a remote repository for a local package and link it.",
166
- inputFields: [packageIdField],
189
+ usageDescription: "Record a package's source repository: the given URL, or one created with the host's tooling. A package that already has a source keeps it.",
190
+ inputFields: [
191
+ packageIdField,
192
+ field("remoteRepo", field_type_1.FieldType.string, "Record this URL (https://, git@host:, or a Git package peers:// URL) instead of creating a repository.", { optional: true }),
193
+ ],
167
194
  outputFields: operationResultFields,
168
195
  },
169
196
  {
@@ -29,6 +29,15 @@ export interface IPackageContext {
29
29
  readonly deviceId: string;
30
30
  /** Run a tool on a contract this package consumes (or provides), as this call's caller. */
31
31
  callConsumedTool(contractId: string, version: number, toolName: string, args?: unknown, options?: IPackageProviderOptions): Promise<unknown>;
32
+ /**
33
+ * Ask for a direct connection to another device in this package's data context, before calls
34
+ * that benefit from one (a large transfer, say). Remote calls use a direct connection when one
35
+ * exists and the Peers mesh otherwise; they never dial on their own. Resolves `true` when a
36
+ * direct connection exists (at most about 15 seconds), `false` when none could be made; never
37
+ * rejects for an unreachable device. The connection may still drop later, and calls then go
38
+ * through the mesh.
39
+ */
40
+ connectToDevice(deviceId: string): Promise<boolean>;
32
41
  /** Run a tool on another device. The remote device authorizes the call. */
33
42
  callRemoteTool(contractId: string, version: number, deviceId: string, toolName: string, args?: unknown): Promise<unknown>;
34
43
  /** Subscribe to an event on a consumed or provided contract. */
@@ -5,8 +5,8 @@
5
5
  * separated modes:
6
6
  *
7
7
  * 1. **RPC mode** (emit/on): request/response interactions with automatic
8
- * encoding (txEncode for objects, passthrough for Uint8Array) and transparent
9
- * chunking for large payloads.
8
+ * encoding (txEncode with bytes kept binary for objects, passthrough for a
9
+ * top-level Uint8Array) and transparent chunking for large payloads.
10
10
  *
11
11
  * 2. **Stream mode** (sendRawBytes/onRawBytes): continuous byte transfer for
12
12
  * file syncing, log streaming, etc. Each sendRawBytes
@@ -6,8 +6,8 @@
6
6
  * separated modes:
7
7
  *
8
8
  * 1. **RPC mode** (emit/on): request/response interactions with automatic
9
- * encoding (txEncode for objects, passthrough for Uint8Array) and transparent
10
- * chunking for large payloads.
9
+ * encoding (txEncode with bytes kept binary for objects, passthrough for a
10
+ * top-level Uint8Array) and transparent chunking for large payloads.
11
11
  *
12
12
  * 2. **Stream mode** (sendRawBytes/onRawBytes): continuous byte transfer for
13
13
  * file syncing, log streaming, etc. Each sendRawBytes
@@ -27,6 +27,7 @@ exports.createBinaryPeerSocket = createBinaryPeerSocket;
27
27
  exports.wrapBinaryPeer = wrapBinaryPeer;
28
28
  exports.wrapWrtc = wrapWrtc;
29
29
  const msgpack_1 = require("@msgpack/msgpack");
30
+ const peer_device_1 = require("../types/peer-device");
30
31
  const utils_1 = require("../utils");
31
32
  const connection_1 = require("./connection");
32
33
  const frame_cipher_1 = require("./frame-cipher");
@@ -100,6 +101,8 @@ function decodeStreamId(bytes) {
100
101
  streamIdStringCache.set(key, cached);
101
102
  return cached;
102
103
  }
104
+ /** RPC payloads carry bytes as msgpack binary, not base64 text. */
105
+ const RPC_ENCODING = { binaryBytes: true };
103
106
  function encodeMessage(message) {
104
107
  let isUint8ArrayData = false;
105
108
  if (message.type === "call" && message.args instanceof Uint8Array) {
@@ -122,18 +125,18 @@ function encodeMessage(message) {
122
125
  dataBytes = message.args;
123
126
  }
124
127
  else {
125
- dataBytes = (0, tx_encoding_1.txEncode)(message.args);
128
+ dataBytes = (0, tx_encoding_1.txEncode)(message.args, RPC_ENCODING);
126
129
  }
127
130
  }
128
131
  else if (message.type === "callback") {
129
132
  if (message.error !== undefined) {
130
- dataBytes = (0, tx_encoding_1.txEncode)({ error: message.error });
133
+ dataBytes = (0, tx_encoding_1.txEncode)({ error: message.error }, RPC_ENCODING);
131
134
  }
132
135
  else if (isUint8ArrayData) {
133
136
  dataBytes = message.result;
134
137
  }
135
138
  else {
136
- dataBytes = (0, tx_encoding_1.txEncode)({ result: message.result });
139
+ dataBytes = (0, tx_encoding_1.txEncode)({ result: message.result }, RPC_ENCODING);
137
140
  }
138
141
  }
139
142
  else {
@@ -244,7 +247,8 @@ function buildStreamCont(sidBytes, chunkData) {
244
247
  */
245
248
  function createBinaryPeerSocket(connectionId, peer, protocol = "binary", receiveLimitOverrides = {}) {
246
249
  const handlers = {};
247
- const callbacks = {};
250
+ /** Reply callbacks for this side's calls that are still waiting, by callback id. */
251
+ const callbacks = new Map();
248
252
  const rawBytesHandlers = {};
249
253
  let isClosed = false;
250
254
  const stats = (0, socket_type_1.createSocketStats)();
@@ -326,11 +330,9 @@ function createBinaryPeerSocket(connectionId, peer, protocol = "binary", receive
326
330
  }
327
331
  // ─── Drain / Backpressure ────────────────────────────────────────
328
332
  // Single persistent drain handler to avoid leaking listeners
329
- let drainResolve = null;
333
+ const drainWaiters = new Set();
330
334
  peer.on("drain", () => {
331
- if (drainResolve) {
332
- const resolve = drainResolve;
333
- drainResolve = null;
335
+ for (const resolve of drainWaiters) {
334
336
  resolve();
335
337
  }
336
338
  });
@@ -347,15 +349,18 @@ function createBinaryPeerSocket(connectionId, peer, protocol = "binary", receive
347
349
  // Backpressure: wait for buffer to drain before sending more
348
350
  while ((peer.getBufferedAmount?.() ?? 0) > config.highWaterMark) {
349
351
  await new Promise((resolve) => {
350
- drainResolve = resolve;
352
+ const resume = () => {
353
+ clearInterval(timer);
354
+ drainWaiters.delete(resume);
355
+ resolve();
356
+ };
351
357
  // Also poll as fallback (drain event might not fire on all transports)
352
358
  const timer = setInterval(() => {
353
359
  if ((peer.getBufferedAmount?.() ?? 0) <= config.lowWaterMark) {
354
- clearInterval(timer);
355
- drainResolve = null;
356
- resolve();
360
+ resume();
357
361
  }
358
362
  }, 5);
363
+ drainWaiters.add(resume);
359
364
  });
360
365
  }
361
366
  // A retired cipher must not fall back to a clear frame; the peer would close
@@ -432,6 +437,26 @@ function createBinaryPeerSocket(connectionId, peer, protocol = "binary", receive
432
437
  streamReassemblyBuffers.clear();
433
438
  inFlightReassemblyBytes = 0;
434
439
  }
440
+ /**
441
+ * Fail every call still waiting for a reply. The peer can no longer answer, so waiting would
442
+ * only hold each caller until its own timer fired; failing now lets it move on (a remote
443
+ * contract call's next attempt picks another route).
444
+ */
445
+ function rejectPendingCalls() {
446
+ const pending = [...callbacks.values()];
447
+ callbacks.clear();
448
+ // A transport may close synchronously from send(), inside emit().
449
+ queueMicrotask(() => {
450
+ for (const callback of pending) {
451
+ try {
452
+ callback(new Error(peer_device_1.CONNECTION_CLOSED_MESSAGE));
453
+ }
454
+ catch (err) {
455
+ console.error(`Error failing a pending ${protocol} call on close:`, err);
456
+ }
457
+ }
458
+ });
459
+ }
435
460
  function closeForProtocolViolation(reason) {
436
461
  if (isClosed) {
437
462
  return;
@@ -441,6 +466,7 @@ function createBinaryPeerSocket(connectionId, peer, protocol = "binary", receive
441
466
  clearAllReassemblies();
442
467
  destroyFrameCipher();
443
468
  socket.connected = false;
469
+ rejectPendingCalls();
444
470
  peer.destroy();
445
471
  }
446
472
  function reserveReassembly(key, totalSize, maximumMessageBytes, onTimeout) {
@@ -469,9 +495,9 @@ function createBinaryPeerSocket(connectionId, peer, protocol = "binary", receive
469
495
  function handleIncomingRPC(bytes) {
470
496
  const message = decodeMessage(bytes);
471
497
  if (message.type === "callback") {
472
- const callback = callbacks[message.callbackId];
498
+ const callback = callbacks.get(message.callbackId);
473
499
  if (callback) {
474
- delete callbacks[message.callbackId];
500
+ callbacks.delete(message.callbackId);
475
501
  callback(message.error, message.result);
476
502
  }
477
503
  return;
@@ -739,16 +765,21 @@ function createBinaryPeerSocket(connectionId, peer, protocol = "binary", receive
739
765
  console.error(`Error parsing ${protocol} frame:`, err);
740
766
  }
741
767
  });
742
- // Handle closure. Cipher teardown waits until queued RPC replies have sealed;
743
- // see {@link releaseCipherAfterTurn}.
744
- peer.on("close", () => {
768
+ /**
769
+ * Handle closure, from the peer or from {@link ISocket.disconnect}. Calls waiting for a reply
770
+ * fail now. Cipher teardown waits until queued replies to the peer's calls have sealed; see
771
+ * {@link releaseCipherAfterTurn}.
772
+ */
773
+ function markClosed() {
745
774
  if (isClosed)
746
775
  return;
747
776
  isClosed = true;
748
777
  socket.connected = false;
749
778
  clearAllReassemblies();
779
+ rejectPendingCalls();
750
780
  releaseCipherAfterTurn();
751
- });
781
+ }
782
+ peer.on("close", markClosed);
752
783
  // ─── ISocket Implementation ──────────────────────────────────────
753
784
  const socket = {
754
785
  id: connectionId,
@@ -763,10 +794,16 @@ function createBinaryPeerSocket(connectionId, peer, protocol = "binary", receive
763
794
  plaintextStreamsAllowed = allowed;
764
795
  },
765
796
  emit(eventName, args, callback) {
797
+ if (isClosed) {
798
+ // Nothing can answer; fail on a later turn so the caller never sees a synchronous reply.
799
+ queueMicrotask(() => callback(new Error(peer_device_1.CONNECTION_CLOSED_MESSAGE)));
800
+ return;
801
+ }
766
802
  const callbackId = (0, utils_1.newid)();
767
- callbacks[callbackId] = callback;
768
803
  const message = { type: "call", eventName, args, callbackId };
804
+ // Encoded first: a call that can't be encoded throws to its caller and leaves no callback.
769
805
  const encoded = encodeMessage(message);
806
+ callbacks.set(callbackId, callback);
770
807
  sendRPC(encoded);
771
808
  },
772
809
  on(eventName, handler) {
@@ -783,6 +820,8 @@ function createBinaryPeerSocket(connectionId, peer, protocol = "binary", receive
783
820
  }
784
821
  },
785
822
  disconnect() {
823
+ // The WebRTC peers don't report their own destroy() as a close.
824
+ markClosed();
786
825
  peer.destroy();
787
826
  },
788
827
  sendRawBytes: sendStreamData,
@@ -1,2 +1,23 @@
1
- export declare function txEncode(data: unknown): Uint8Array;
1
+ /** Options for {@link txEncode}. */
2
+ export interface TxEncodeOptions {
3
+ /**
4
+ * Write `Uint8Array` and `Buffer` values as msgpack binary instead of {@link toJSON}'s base64
5
+ * strings. Device connections' RPC messages use it; a payload that is signed or boxed keeps the
6
+ * default, so its format is unchanged. {@link txDecode} reads both.
7
+ */
8
+ binaryBytes?: boolean;
9
+ }
10
+ /**
11
+ * Encodes `data` for a device connection or a box: {@link toJSON} (cycles, dates, bytes), msgpack,
12
+ * then gzip when that saves at least 20% (judged from a 64 KiB sample first for a large payload).
13
+ * The first byte is a flag: 0 plain, 1 gzip. With `binaryBytes`, bytes stay binary (see
14
+ * {@link TxEncodeOptions}).
15
+ */
16
+ export declare function txEncode(data: unknown, options?: TxEncodeOptions): Uint8Array;
17
+ /**
18
+ * Inverse of {@link txEncode}, with or without `binaryBytes`. Bytes that were written as binary
19
+ * come back as views into `data` (or into its inflated copy), so a caller that keeps a small piece
20
+ * of a large message for long should copy it.
21
+ * @throws If `data` is empty, has an unknown flag, or doesn't inflate or decode
22
+ */
2
23
  export declare function txDecode(data: Uint8Array): unknown;
@@ -4,6 +4,7 @@ exports.txEncode = txEncode;
4
4
  exports.txDecode = txDecode;
5
5
  const msgpack_1 = require("@msgpack/msgpack");
6
6
  const fflate_1 = require("fflate");
7
+ const native_codecs_1 = require("../native-codecs");
7
8
  const serial_json_1 = require("../serial-json");
8
9
  // Minimum size before attempting compression (bytes)
9
10
  // Small payloads don't benefit much from compression
@@ -14,35 +15,70 @@ const MIN_COMPRESSION_RATIO = 0.8;
14
15
  // Compression level: 1 (fastest) to 9 (best compression)
15
16
  // Level 1 is ~5-10x faster than level 6 with reasonable compression
16
17
  const COMPRESSION_LEVEL = 1;
17
- function txEncode(data) {
18
- const noCycles = (0, serial_json_1.toJSON)(data); // remove cycles and encode dates, etc.
19
- // First encode to msgpack
20
- const encoded = (0, msgpack_1.encode)(noCycles);
21
- let body;
22
- let flag;
23
- if (encoded.length > COMPRESSION_THRESHOLD) {
24
- // Try to compress - use fast compression level
25
- const compressed = (0, fflate_1.compressSync)(encoded, { level: COMPRESSION_LEVEL });
26
- // Only use compression if it actually helps significantly
27
- if (compressed.length < encoded.length * MIN_COMPRESSION_RATIO) {
28
- body = compressed;
29
- flag = 1; // compressed
30
- }
31
- else {
32
- // Compression didn't help much, skip it
33
- body = encoded;
34
- flag = 0; // not compressed
35
- }
36
- }
37
- else {
38
- body = encoded;
39
- flag = 0; // not compressed
18
+ /**
19
+ * A payload over twice this size is gzipped only when its first this-many bytes compress well, so
20
+ * bytes that are already compressed (Git packs, media, ciphertext) skip gzip at both ends.
21
+ */
22
+ const COMPRESSION_SAMPLE_BYTES = 64 * 1024;
23
+ /**
24
+ * msgpack extension type for a Node `Buffer` in a `binaryBytes` payload. Without it a `Buffer`
25
+ * would arrive as a plain `Uint8Array`, and {@link toJSON} encodes the two differently, so a
26
+ * signature over a received value that held a `Buffer` would no longer verify.
27
+ */
28
+ const BUFFER_EXTENSION_TYPE = 1;
29
+ const extensionCodec = new msgpack_1.ExtensionCodec();
30
+ extensionCodec.register({
31
+ type: BUFFER_EXTENSION_TYPE,
32
+ encode: (value) => (Buffer.isBuffer(value) ? value : null),
33
+ decode: (data) => Buffer.from(data.buffer, data.byteOffset, data.byteLength),
34
+ });
35
+ function gzip(data) {
36
+ // Both produce gzip, which either side inflates.
37
+ return ((0, native_codecs_1.getNativeCodecs)()?.gzip(data, COMPRESSION_LEVEL) ??
38
+ (0, fflate_1.compressSync)(data, { level: COMPRESSION_LEVEL }));
39
+ }
40
+ /** True when gzip saves enough of `data` to be worth inflating at the other end. */
41
+ function compressesWell(data, compressed) {
42
+ return compressed.length < data.length * MIN_COMPRESSION_RATIO;
43
+ }
44
+ /** `encoded` gzipped when that saves at least 20%, else `undefined`. */
45
+ function compressIfWorthwhile(encoded) {
46
+ if (encoded.length <= COMPRESSION_THRESHOLD)
47
+ return undefined;
48
+ if (encoded.length > COMPRESSION_SAMPLE_BYTES * 2) {
49
+ const sample = encoded.subarray(0, COMPRESSION_SAMPLE_BYTES);
50
+ if (!compressesWell(sample, gzip(sample)))
51
+ return undefined;
40
52
  }
53
+ const compressed = gzip(encoded);
54
+ return compressesWell(encoded, compressed) ? compressed : undefined;
55
+ }
56
+ /**
57
+ * Encodes `data` for a device connection or a box: {@link toJSON} (cycles, dates, bytes), msgpack,
58
+ * then gzip when that saves at least 20% (judged from a 64 KiB sample first for a large payload).
59
+ * The first byte is a flag: 0 plain, 1 gzip. With `binaryBytes`, bytes stay binary (see
60
+ * {@link TxEncodeOptions}).
61
+ */
62
+ function txEncode(data, options) {
63
+ // Remove cycles and encode dates, etc.
64
+ const tree = options?.binaryBytes ? (0, serial_json_1.toJSONKeepingBytes)(data) : (0, serial_json_1.toJSON)(data);
65
+ const encoded = (0, msgpack_1.encode)(tree, { extensionCodec });
66
+ const compressed = compressIfWorthwhile(encoded);
67
+ const body = compressed ?? encoded;
41
68
  const out = new Uint8Array(1 + body.length);
42
- out[0] = flag;
69
+ out[0] = compressed ? 1 : 0;
43
70
  out.set(body, 1);
44
71
  return out;
45
72
  }
73
+ function isGzip(data) {
74
+ return data.length > 2 && data[0] === 0x1f && data[1] === 0x8b;
75
+ }
76
+ /**
77
+ * Inverse of {@link txEncode}, with or without `binaryBytes`. Bytes that were written as binary
78
+ * come back as views into `data` (or into its inflated copy), so a caller that keeps a small piece
79
+ * of a large message for long should copy it.
80
+ * @throws If `data` is empty, has an unknown flag, or doesn't inflate or decode
81
+ */
46
82
  function txDecode(data) {
47
83
  if (data.length === 0) {
48
84
  throw new Error("Empty payload");
@@ -50,13 +86,14 @@ function txDecode(data) {
50
86
  const flag = data[0];
51
87
  let body = data.subarray(1);
52
88
  if (flag === 1) {
53
- body = (0, fflate_1.decompressSync)(body);
89
+ const native = (0, native_codecs_1.getNativeCodecs)();
90
+ body = native && isGzip(body) ? native.gunzip(body) : (0, fflate_1.decompressSync)(body);
54
91
  }
55
92
  else if (flag !== 0) {
56
93
  // Optional: future versioning or legacy handling
57
94
  throw new Error(`Unknown tx flag: ${flag}`);
58
95
  }
59
- const unpacked = (0, msgpack_1.decode)(body);
96
+ const unpacked = (0, msgpack_1.decode)(body, { extensionCodec });
60
97
  const restored = (0, serial_json_1.fromJSON)(unpacked); // restore cycles, dates, etc.
61
98
  return restored;
62
99
  }
package/dist/index.d.ts CHANGED
@@ -33,6 +33,7 @@ export * from "./invites";
33
33
  export * from "./keys";
34
34
  export * from "./logging";
35
35
  export * from "./mentions";
36
+ export * from "./native-codecs";
36
37
  export * from "./observable";
37
38
  export * from "./package-installer";
38
39
  export * from "./package-loader";
package/dist/index.js CHANGED
@@ -55,6 +55,7 @@ __exportStar(require("./invites"), exports);
55
55
  __exportStar(require("./keys"), exports);
56
56
  __exportStar(require("./logging"), exports);
57
57
  __exportStar(require("./mentions"), exports);
58
+ __exportStar(require("./native-codecs"), exports);
58
59
  __exportStar(require("./observable"), exports);
59
60
  __exportStar(require("./package-installer"), exports);
60
61
  __exportStar(require("./package-loader"), exports);
package/dist/keys.d.ts CHANGED
@@ -21,11 +21,16 @@ export declare function hashObject(obj: {
21
21
  * legacy insertion-order fallback that keeps pre-existing signatures valid.
22
22
  */
23
23
  export declare function canonicalJSONString(obj: any): string;
24
- /** Base64 with URL-safe alphabet and no `=` padding (used throughout keys and wire formats). */
24
+ /**
25
+ * Base64 with URL-safe alphabet and no `=` padding (used throughout keys and wire formats).
26
+ * Uses `Buffer` (native in Node, the `buffer` package in browsers).
27
+ */
25
28
  export declare function encodeBase64(data: Uint8Array): string;
26
29
  /**
27
30
  * Inverse of {@link encodeBase64}; accepts standard or URL-safe base64 and repads as needed.
28
- * @throws TypeError if `data` is not a string
31
+ * Accepts exactly what `tweetnacl-util` did (whole groups of four, then at most two `=`), and
32
+ * returns a fresh `Uint8Array` that owns its buffer.
33
+ * @throws TypeError if `data` is not a string or not valid base64
29
34
  */
30
35
  export declare function decodeBase64(data: string): Uint8Array;
31
36
  /** Random token as base64url string (`size` raw bytes, default 32). */
package/dist/keys.js CHANGED
@@ -33,9 +33,9 @@ const sha2_1 = require("@noble/hashes/sha2");
33
33
  const buffer_1 = require("buffer");
34
34
  const ed2curve = require("ed2curve");
35
35
  const nacl = require("tweetnacl");
36
- const utils = require("tweetnacl-util");
37
36
  const tweetnacl_util_1 = require("tweetnacl-util");
38
37
  const tx_encoding_1 = require("./device/tx-encoding");
38
+ const native_codecs_1 = require("./native-codecs");
39
39
  const serial_json_1 = require("./serial-json");
40
40
  globalThis.Buffer = buffer_1.Buffer; // shim for browsers/RN
41
41
  const _stableStringify = require("fast-json-stable-stringify");
@@ -113,15 +113,42 @@ function canonicalJSONString(obj) {
113
113
  function legacySignatureJSONString(obj) {
114
114
  return (0, serial_json_1.toJSONString)(obj);
115
115
  }
116
- /** Base64 with URL-safe alphabet and no `=` padding (used throughout keys and wire formats). */
116
+ const utf8Encoder = new TextEncoder();
117
+ const supportsBase64Url = buffer_1.Buffer.isEncoding("base64url");
118
+ /**
119
+ * UTF-8 bytes of `str`, rejecting what `tweetnacl-util`'s `decodeUTF8` rejected (non-strings and
120
+ * lone surrogates), so signatures are unchanged. `TextEncoder` is native and far faster.
121
+ * @throws TypeError if `str` is not a string; URIError if it has a lone surrogate
122
+ */
123
+ function decodeUTF8(str) {
124
+ if (typeof str !== "string")
125
+ throw new TypeError("expected string");
126
+ const wellFormed = str.isWellFormed;
127
+ if (wellFormed ? !wellFormed.call(str) : /[\uD800-\uDFFF]/.test(str)) {
128
+ // Slow path only when a surrogate may be unpaired; matches encodeURIComponent's check.
129
+ encodeURIComponent(str);
130
+ }
131
+ return utf8Encoder.encode(str);
132
+ }
133
+ /**
134
+ * Base64 with URL-safe alphabet and no `=` padding (used throughout keys and wire formats).
135
+ * Uses `Buffer` (native in Node, the `buffer` package in browsers).
136
+ */
117
137
  function encodeBase64(data) {
118
- // return Buffer.from(data).toString("base64url"); // no padding by default
119
- const str = utils.encodeBase64(data);
120
- return str.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
138
+ // Keep tweetnacl-util's coercion and errors for callers passing other array-like values.
139
+ if (!(data instanceof Uint8Array)) {
140
+ return (0, tweetnacl_util_1.encodeBase64)(data).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
141
+ }
142
+ const bytes = buffer_1.Buffer.from(data.buffer, data.byteOffset, data.byteLength);
143
+ if (supportsBase64Url)
144
+ return bytes.toString("base64url");
145
+ return bytes.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
121
146
  }
122
147
  /**
123
148
  * Inverse of {@link encodeBase64}; accepts standard or URL-safe base64 and repads as needed.
124
- * @throws TypeError if `data` is not a string
149
+ * Accepts exactly what `tweetnacl-util` did (whole groups of four, then at most two `=`), and
150
+ * returns a fresh `Uint8Array` that owns its buffer.
151
+ * @throws TypeError if `data` is not a string or not valid base64
125
152
  */
126
153
  function decodeBase64(data) {
127
154
  if (typeof data !== "string") {
@@ -131,7 +158,11 @@ function decodeBase64(data) {
131
158
  while (data.length % 4 !== 0) {
132
159
  data += "=";
133
160
  }
134
- return utils.decodeBase64(data);
161
+ // A single character class, so it doesn't grow V8's regex stack on large inputs.
162
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(data)) {
163
+ throw new TypeError("invalid encoding");
164
+ }
165
+ return new Uint8Array(buffer_1.Buffer.from(data, "base64"));
135
166
  }
136
167
  /** Random token as base64url string (`size` raw bytes, default 32). */
137
168
  function newToken(size = 32) {
@@ -178,10 +209,28 @@ function derivePublicBoxKey(publicKey) {
178
209
  const converted = ed2curve.convertPublicKey(bytes);
179
210
  return converted ? encodeBase64(converted) : null;
180
211
  }
212
+ /** Ed25519 detached signature; native when the host installed it (identical bytes), else tweetnacl. */
213
+ function signDetached(message, secretKey) {
214
+ const native = secretKey.length === 64 ? (0, native_codecs_1.getNativeCodecs)()?.sign(message, secretKey) : undefined;
215
+ return native ?? nacl.sign.detached(message, secretKey);
216
+ }
217
+ /**
218
+ * Ed25519 detached verify. A native "valid" is trusted (OpenSSL is at least as strict as tweetnacl:
219
+ * it also rejects a non-canonical S); anything else is decided by tweetnacl, so every signature
220
+ * tweetnacl accepted still verifies, and its size errors still throw.
221
+ */
222
+ function verifyDetached(message, signature, publicKey) {
223
+ const native = (0, native_codecs_1.getNativeCodecs)();
224
+ if (native && signature.length === 64 && publicKey.length === 32) {
225
+ if (native.verify(message, signature, publicKey))
226
+ return true;
227
+ }
228
+ return nacl.sign.detached.verify(message, signature, publicKey);
229
+ }
181
230
  /** Signs a UTF-8 message with Ed25519; returns base64url signed message (NaCl `sign` combined format). */
182
231
  function signMessageWithSecretKey(msg, secretKey) {
183
232
  const _secretKey = decodeBase64(secretKey);
184
- const msgDecoded = (0, tweetnacl_util_1.decodeUTF8)(msg);
233
+ const msgDecoded = decodeUTF8(msg);
185
234
  const msgSigned = nacl.sign(msgDecoded, _secretKey);
186
235
  return encodeBase64(msgSigned);
187
236
  }
@@ -213,8 +262,8 @@ function signObjectWithSecretKey(obj, secretKey) {
213
262
  obj = { __isNull: true };
214
263
  }
215
264
  const objStr = canonicalJSONString(obj);
216
- const objDecoded = (0, tweetnacl_util_1.decodeUTF8)(objStr);
217
- const signatureByteArray = nacl.sign.detached(objDecoded, _secretKey);
265
+ const objDecoded = decodeUTF8(objStr);
266
+ const signatureByteArray = signDetached(objDecoded, _secretKey);
218
267
  return {
219
268
  contents: obj,
220
269
  signature: encodeBase64(signatureByteArray),
@@ -231,7 +280,7 @@ function signObjectWithSecretKey(obj, secretKey) {
231
280
  function openSignedObject(signedObj) {
232
281
  const _publicKey = decodeBase64(signedObj.publicKey);
233
282
  const signature = decodeBase64(signedObj.signature);
234
- const verify = (objStr) => nacl.sign.detached.verify((0, tweetnacl_util_1.decodeUTF8)(objStr), signature, _publicKey);
283
+ const verify = (objStr) => verifyDetached(decodeUTF8(objStr), signature, _publicKey);
235
284
  const valid = verify(canonicalJSONString(signedObj.contents)) ||
236
285
  verify(legacySignatureJSONString(signedObj.contents));
237
286
  if (!valid) {
@@ -317,7 +366,7 @@ function openBoxWithSecretKey(box, mySecretKey) {
317
366
  }
318
367
  function encryptString(data, secretKey) {
319
368
  const _secretKey = decodeBase64(secretKey);
320
- const _data = (0, tweetnacl_util_1.decodeUTF8)(data);
369
+ const _data = decodeUTF8(data);
321
370
  const nonce = nacl.randomBytes(24);
322
371
  const encryptedData = nacl.secretbox(_data, nonce, _secretKey.slice(0, 32));
323
372
  return `${encodeBase64(encryptedData)}~${encodeBase64(nonce)}`;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Native implementations a Node host can install for the two most expensive steps of a device
3
+ * message: gzip and Ed25519. Without them the pure-JS paths (`fflate`, `tweetnacl`) run, so
4
+ * browsers keep working; with them a large device message costs a fraction of the CPU.
5
+ *
6
+ * Every implementation must be interchangeable with the pure-JS one on the wire: a peer without
7
+ * native codecs must accept what this one sends, and the reverse.
8
+ */
9
+ export interface INativeCodecs {
10
+ /** gzip `data` at `level` (1-9). Any valid gzip stream will do; receivers only inflate it. */
11
+ gzip(data: Uint8Array, level: number): Uint8Array;
12
+ /** Inflate one gzip stream (from {@link INativeCodecs.gzip} or from `fflate`). */
13
+ gunzip(data: Uint8Array): Uint8Array;
14
+ /**
15
+ * Ed25519 detached signature of `message` with a tweetnacl-format 64-byte secret key (seed then
16
+ * public key). Ed25519 is deterministic, so this must equal tweetnacl's signature byte for byte;
17
+ * return `undefined` when that can't be guaranteed (for example, a key whose public half doesn't
18
+ * match its seed) and the caller signs with tweetnacl.
19
+ */
20
+ sign(message: Uint8Array, secretKey: Uint8Array): Uint8Array | undefined;
21
+ /**
22
+ * Whether `signature` is a valid Ed25519 signature of `message` by the 32-byte `publicKey`.
23
+ * Callers fall back to tweetnacl when this returns false, so it may be stricter than tweetnacl
24
+ * but never more lenient.
25
+ */
26
+ verify(message: Uint8Array, signature: Uint8Array, publicKey: Uint8Array): boolean;
27
+ }
28
+ /**
29
+ * Install (or with no argument, remove) native codecs for this process. Node hosts call this once
30
+ * at startup, before any device message is signed or sent.
31
+ */
32
+ export declare function installNativeCodecs(codecs?: INativeCodecs): void;
33
+ /** The installed native codecs, or `undefined` when the pure-JS paths should run. */
34
+ export declare function getNativeCodecs(): INativeCodecs | undefined;
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.installNativeCodecs = installNativeCodecs;
4
+ exports.getNativeCodecs = getNativeCodecs;
5
+ let installed;
6
+ /**
7
+ * Install (or with no argument, remove) native codecs for this process. Node hosts call this once
8
+ * at startup, before any device message is signed or sent.
9
+ */
10
+ function installNativeCodecs(codecs) {
11
+ installed = codecs;
12
+ }
13
+ /** The installed native codecs, or `undefined` when the pure-JS paths should run. */
14
+ function getNativeCodecs() {
15
+ return installed;
16
+ }
@@ -270,6 +270,8 @@ export declare const ISOLATED_PERSISTENT_VAR_HOST_MEMBER = "package.pvar";
270
270
  export declare const ISOLATED_EVENT_HOST_MEMBER = "contract.emit";
271
271
  /** Host-contract member guests use to publish an in-memory provided-contract observable. */
272
272
  export declare const ISOLATED_OBSERVABLE_HOST_MEMBER = "package.observable";
273
+ /** Host-contract member guests use to ask for a direct connection to another device. */
274
+ export declare const ISOLATED_CONNECT_HOST_MEMBER = "device.connect";
273
275
  /**
274
276
  * How deep field lists may nest through object `fields`: a tool's inputs, say, are level 1.
275
277
  * Deeper nesting is refused, so a hostile manifest can't exhaust the validator's stack.
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ISOLATED_MAX_FIELD_DEPTH = exports.ISOLATED_OBSERVABLE_HOST_MEMBER = exports.ISOLATED_EVENT_HOST_MEMBER = exports.ISOLATED_PERSISTENT_VAR_HOST_MEMBER = exports.ISOLATED_TABLE_HOST_MEMBER = exports.ISOLATED_CONSUME_HOST_MEMBER = exports.ISOLATED_TOOL_TIMEOUT_MAX_MS = exports.ISOLATED_GUEST_API_VERSION = exports.ISOLATED_PACKAGE_MAGIC = void 0;
3
+ exports.ISOLATED_MAX_FIELD_DEPTH = exports.ISOLATED_CONNECT_HOST_MEMBER = exports.ISOLATED_OBSERVABLE_HOST_MEMBER = exports.ISOLATED_EVENT_HOST_MEMBER = exports.ISOLATED_PERSISTENT_VAR_HOST_MEMBER = exports.ISOLATED_TABLE_HOST_MEMBER = exports.ISOLATED_CONSUME_HOST_MEMBER = exports.ISOLATED_TOOL_TIMEOUT_MAX_MS = exports.ISOLATED_GUEST_API_VERSION = exports.ISOLATED_PACKAGE_MAGIC = void 0;
4
4
  exports.enumValueFits = enumValueFits;
5
5
  exports.looksLikeIsolatedPackageArtifact = looksLikeIsolatedPackageArtifact;
6
6
  exports.validateIsolatedPackageArtifact = validateIsolatedPackageArtifact;
@@ -45,6 +45,8 @@ exports.ISOLATED_PERSISTENT_VAR_HOST_MEMBER = "package.pvar";
45
45
  exports.ISOLATED_EVENT_HOST_MEMBER = "contract.emit";
46
46
  /** Host-contract member guests use to publish an in-memory provided-contract observable. */
47
47
  exports.ISOLATED_OBSERVABLE_HOST_MEMBER = "package.observable";
48
+ /** Host-contract member guests use to ask for a direct connection to another device. */
49
+ exports.ISOLATED_CONNECT_HOST_MEMBER = "device.connect";
48
50
  const FIELD_TYPES = new Set(Object.values(field_type_1.FieldType));
49
51
  const OBSERVABLE_FIELD_TYPES = new Set([
50
52
  field_type_1.FieldType.id,
@@ -351,6 +351,12 @@ export interface IIsolatedPackageRuntimeFactoryOptions {
351
351
  onWorkerOutput?: (output: IIsolatedWorkerOutput) => void;
352
352
  /** This device's id, which package code reads as `ctx.deviceId`. */
353
353
  getDeviceId?: () => string | undefined;
354
+ /**
355
+ * Ask for a direct connection to `deviceId` within this data context, for package code's
356
+ * `ctx.connectToDevice`. Resolves whether a verified direct connection exists; never rejects
357
+ * for an unreachable device. Without it, `ctx.connectToDevice` resolves false.
358
+ */
359
+ connectToDevice?: (deviceId: string) => Promise<boolean>;
354
360
  }
355
361
  /**
356
362
  * Host callback that builds an {@link IIsolatedPackageRuntime} for one
@@ -127,6 +127,7 @@ class PackageLoader {
127
127
  },
128
128
  onWorkerOutput: (output) => this.logWorkerOutput(output),
129
129
  getDeviceId: () => this.dataContext.userContext?.deviceId(),
130
+ connectToDevice: (deviceId) => (0, remote_contract_transport_1.connectToRemoteDevice)({ deviceId, dataContextId: this.dataContext.dataContextId }, this.dataContext.userContext),
130
131
  });
131
132
  this.unsubscribePackageHealth = this.isolatedRuntime.subscribePackageHealth?.((packageId) => {
132
133
  const previous = this.runtimeStates()[packageId];
@@ -26,6 +26,13 @@ export declare function isObject(x: any): x is Record<string, any>;
26
26
  * @returns A new tree; the input is never modified. Top-level primitives are returned as-is.
27
27
  */
28
28
  export declare function toJSON(obj: any): any;
29
+ /**
30
+ * {@link toJSON}, except that `Uint8Array` and `Buffer` values stay in the tree as they are (the
31
+ * same instances), for an encoder that writes bytes natively: `txEncode` with `binaryBytes`
32
+ * hands the tree to msgpack. {@link fromJSON} passes them through, so the round trip still
33
+ * restores everything else. Never sign this output; signatures cover {@link toJSON}'s.
34
+ */
35
+ export declare function toJSONKeepingBytes(obj: any): any;
29
36
  /**
30
37
  * Reverses {@link toJSON}: restores the sentinel values, shared objects and cycles, and keys
31
38
  * escaped with `__DOLLAR_`.
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.isObject = isObject;
4
4
  exports.toJSON = toJSON;
5
+ exports.toJSONKeepingBytes = toJSONKeepingBytes;
5
6
  exports.fromJSON = fromJSON;
6
7
  exports.toJSONString = toJSONString;
7
8
  exports.fromJSONString = fromJSONString;
@@ -40,6 +41,19 @@ const ARRAY_TAG_PREFIX = "__this_ref:";
40
41
  * @returns A new tree; the input is never modified. Top-level primitives are returned as-is.
41
42
  */
42
43
  function toJSON(obj) {
44
+ return encodeTree(obj, false);
45
+ }
46
+ /**
47
+ * {@link toJSON}, except that `Uint8Array` and `Buffer` values stay in the tree as they are (the
48
+ * same instances), for an encoder that writes bytes natively: `txEncode` with `binaryBytes`
49
+ * hands the tree to msgpack. {@link fromJSON} passes them through, so the round trip still
50
+ * restores everything else. Never sign this output; signatures cover {@link toJSON}'s.
51
+ */
52
+ function toJSONKeepingBytes(obj) {
53
+ return encodeTree(obj, true);
54
+ }
55
+ /** The walk behind {@link toJSON} and {@link toJSONKeepingBytes}. */
56
+ function encodeTree(obj, keepBytes) {
43
57
  if (!_.isObject(obj)) {
44
58
  return obj;
45
59
  }
@@ -76,9 +90,9 @@ function toJSON(obj) {
76
90
  if (_.isError(value))
77
91
  return `__ERROR ${value.stack}`;
78
92
  if (Buffer.isBuffer(value))
79
- return `__BUFFER ${value.toString("base64")}`;
93
+ return keepBytes ? value : `__BUFFER ${value.toString("base64")}`;
80
94
  if (value instanceof Uint8Array)
81
- return `__UINT8ARRAY ${Buffer.from(value).toString("base64")}`;
95
+ return keepBytes ? value : `__UINT8ARRAY ${Buffer.from(value).toString("base64")}`;
82
96
  const known = seen.get(value);
83
97
  if (known) {
84
98
  const target = known.copy;
@@ -73,6 +73,12 @@ export interface IDeviceConnection {
73
73
  /** Whether the connection has already closed. */
74
74
  closed?: boolean;
75
75
  }
76
+ /**
77
+ * Message of the error a device RPC fails with when its connection closes before the reply
78
+ * arrives, or when it is sent on a connection that has already closed. Sync treats it as an
79
+ * expected disconnect, not a failure.
80
+ */
81
+ export declare const CONNECTION_CLOSED_MESSAGE = "connection has been closed";
76
82
  /** Shared tuning constants for device synchronization and routing. */
77
83
  export declare const PeerDeviceConsts: Readonly<{
78
84
  readonly MAX_CONNECTIONS: 30;
@@ -1,6 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.PeerDeviceConsts = void 0;
3
+ exports.PeerDeviceConsts = exports.CONNECTION_CLOSED_MESSAGE = void 0;
4
+ /**
5
+ * Message of the error a device RPC fails with when its connection closes before the reply
6
+ * arrives, or when it is sent on a connection that has already closed. Sync treats it as an
7
+ * expected disconnect, not a failure.
8
+ */
9
+ exports.CONNECTION_CLOSED_MESSAGE = "connection has been closed";
4
10
  /** Shared tuning constants for device synchronization and routing. */
5
11
  exports.PeerDeviceConsts = Object.freeze({
6
12
  // TODO set this based on device type (desktops and servers should be able to handle 100 connections)
package/dist/utils.js CHANGED
@@ -293,7 +293,7 @@ async function retryOnErrorOrTimeout({ fn, connection, opts = {}, }) {
293
293
  timeoutCount++;
294
294
  finalError = undefined;
295
295
  if (connection.closed) {
296
- throw new Error(`connection has been closed`);
296
+ throw new Error(peer_device_1.CONNECTION_CLOSED_MESSAGE);
297
297
  }
298
298
  }
299
299
  catch (error) {
@@ -303,7 +303,7 @@ async function retryOnErrorOrTimeout({ fn, connection, opts = {}, }) {
303
303
  if (opts.shouldRetry?.(error) === false)
304
304
  throw error;
305
305
  if (connection.closed) {
306
- throw new Error(`connection has been closed`);
306
+ throw new Error(peer_device_1.CONNECTION_CLOSED_MESSAGE);
307
307
  }
308
308
  }
309
309
  finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peers-app/peers-sdk",
3
- "version": "0.28.12",
3
+ "version": "0.29.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/peers-app/peers-sdk.git"