@peers-app/peers-sdk 0.30.5 → 0.31.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/system/mesh-metrics-types.d.ts +9 -3
- package/dist/contracts/tool-catalog.d.ts +32 -0
- package/dist/contracts/tool-catalog.js +198 -28
- package/dist/data/orm/table.d.ts +8 -0
- package/dist/data/orm/table.js +35 -0
- package/dist/device/binary-peer-connection-v2.d.ts +3 -3
- package/dist/device/binary-peer-connection-v2.js +12 -2
- package/dist/device/socket.type.d.ts +10 -2
- package/dist/device/webtransport-address.d.ts +62 -0
- package/dist/device/webtransport-address.js +120 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
export type MeshEpisodeReason = "startup" | "resume" | "network-change" | "connection-loss";
|
|
3
3
|
/** Provenance of a dial; explicit requests and group joins are also distinguished. */
|
|
4
4
|
export type MeshDialReason = "own-device" | "group-network-info" | "broadcast-answer" | "configured-server" | "lan-scan" | "websocket-upgrade" | "incoming" | "direct-request" | "group-join";
|
|
5
|
-
/**
|
|
6
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Bounded path categories used for lifetime counters; attempts also retain the address.
|
|
7
|
+
* `webtransport` is the PWA's direct LAN session to a desktop (absent from older devices' stats).
|
|
8
|
+
*/
|
|
9
|
+
export type MeshPath = "websocket" | "webrtc" | "webtransport" | "cloud" | "discovery";
|
|
7
10
|
/** Failure categories, independent of free-form transport error messages. */
|
|
8
11
|
export type MeshFailureReason = "ttl0" | "no-route" | "signaling-timeout" | "socket-error" | "socket-timeout" | "handshake" | "rejected" | "closed" | "superseded" | "unsupported" | "sender-not-known"
|
|
9
12
|
/** This device can't encrypt to the target yet: no owner-signed row or profile for it here. */
|
|
@@ -137,7 +140,10 @@ export interface IMeshStats {
|
|
|
137
140
|
recentEpisodes: IMeshEpisode[];
|
|
138
141
|
percentiles: Record<keyof IMeshDurations, IMeshPercentiles>;
|
|
139
142
|
counters: {
|
|
140
|
-
|
|
143
|
+
/** By path; `webtransport` is missing from devices older than that path. */
|
|
144
|
+
paths: Record<Exclude<MeshPath, "webtransport">, IMeshPathCounters> & {
|
|
145
|
+
webtransport?: IMeshPathCounters;
|
|
146
|
+
};
|
|
141
147
|
lanScan: {
|
|
142
148
|
scans: number;
|
|
143
149
|
probes: number;
|
|
@@ -49,11 +49,42 @@ export declare class ToolCatalog {
|
|
|
49
49
|
private subscription?;
|
|
50
50
|
private disposed;
|
|
51
51
|
private reconciliationQueued;
|
|
52
|
+
/**
|
|
53
|
+
* This context's Tools rows, read once by {@link ready} and kept current from the table's
|
|
54
|
+
* change events (local writes and synced changes alike). Publication checks against it:
|
|
55
|
+
* every start publishes every provider in every context, and reading rows for each one
|
|
56
|
+
* cost thousands of queries per start on a phone.
|
|
57
|
+
*/
|
|
58
|
+
private rows?;
|
|
59
|
+
/** Rows changed since they were last reconciled. */
|
|
60
|
+
private readonly changed;
|
|
61
|
+
private readonly pendingRows;
|
|
62
|
+
private readonly revisions;
|
|
52
63
|
/** Bind to the registry shared by host and isolated providers in this context. */
|
|
53
64
|
constructor(dataContext: DataContext, registry: ContractRegistry);
|
|
54
65
|
private enqueue;
|
|
55
66
|
/** Await successful backfill and system publication. Failed initialization can be retried. */
|
|
56
67
|
ready(): Promise<void>;
|
|
68
|
+
private initialized;
|
|
69
|
+
/** True while change events keep {@link rows} current. */
|
|
70
|
+
private live;
|
|
71
|
+
/**
|
|
72
|
+
* Read this context's rows into memory, after subscribing to the table's changes so none
|
|
73
|
+
* is missed while the read is in flight. A row that changes during the read keeps the
|
|
74
|
+
* changed version. Where the table can't report changes (a client process whose table
|
|
75
|
+
* events are off), the rows are read again before each use instead.
|
|
76
|
+
*/
|
|
77
|
+
private load;
|
|
78
|
+
/** Read partial notifications in batches, retrying only rows changed during the read. */
|
|
79
|
+
private loadPendingRows;
|
|
80
|
+
/** Stop watching the table and forget its rows (after a failed start, or on dispose). */
|
|
81
|
+
private forget;
|
|
82
|
+
/**
|
|
83
|
+
* Record silent writes, but never replace a newer event with an older write result.
|
|
84
|
+
*/
|
|
85
|
+
private write;
|
|
86
|
+
private get rowMap();
|
|
87
|
+
private queueReconciliation;
|
|
57
88
|
private assertLive;
|
|
58
89
|
private validate;
|
|
59
90
|
/**
|
|
@@ -80,6 +111,7 @@ export declare class ToolCatalog {
|
|
|
80
111
|
* leaves every other column, including concurrently synced policy, untouched.
|
|
81
112
|
*/
|
|
82
113
|
private refresh;
|
|
114
|
+
/** Re-derive catalog state for these rows (all of them at startup, then those that change). */
|
|
83
115
|
private reconcile;
|
|
84
116
|
/**
|
|
85
117
|
* Patch supplemental metadata of existing system members, preserving identity,
|
|
@@ -6,6 +6,7 @@ exports.retainToolCatalogFields = retainToolCatalogFields;
|
|
|
6
6
|
exports.validateToolCatalogWrite = validateToolCatalogWrite;
|
|
7
7
|
const lodash_1 = require("lodash");
|
|
8
8
|
const tools_1 = require("../data/tools");
|
|
9
|
+
const rpc_types_1 = require("../rpc-types");
|
|
9
10
|
const registry_1 = require("./registry");
|
|
10
11
|
const system_contracts_1 = require("./system-contracts");
|
|
11
12
|
const tool_catalog_identity_1 = require("./tool-catalog-identity");
|
|
@@ -238,6 +239,14 @@ function resolvedFields(target, toolId) {
|
|
|
238
239
|
catalogReason: undefined,
|
|
239
240
|
};
|
|
240
241
|
}
|
|
242
|
+
/** Table writes normally report full rows; incomplete notifications need a storage read. */
|
|
243
|
+
function isCompleteRow(row) {
|
|
244
|
+
return (row.name !== undefined &&
|
|
245
|
+
row.usageDescription !== undefined &&
|
|
246
|
+
row.inputSchema !== undefined &&
|
|
247
|
+
row.outputSchema !== undefined &&
|
|
248
|
+
row.code !== undefined);
|
|
249
|
+
}
|
|
241
250
|
/**
|
|
242
251
|
* Per-context runtime owner of permanent Tools identities. Construction is inert;
|
|
243
252
|
* ready() explicitly initializes storage after UserContext construction completes.
|
|
@@ -251,6 +260,17 @@ class ToolCatalog {
|
|
|
251
260
|
subscription;
|
|
252
261
|
disposed = false;
|
|
253
262
|
reconciliationQueued = false;
|
|
263
|
+
/**
|
|
264
|
+
* This context's Tools rows, read once by {@link ready} and kept current from the table's
|
|
265
|
+
* change events (local writes and synced changes alike). Publication checks against it:
|
|
266
|
+
* every start publishes every provider in every context, and reading rows for each one
|
|
267
|
+
* cost thousands of queries per start on a phone.
|
|
268
|
+
*/
|
|
269
|
+
rows;
|
|
270
|
+
/** Rows changed since they were last reconciled. */
|
|
271
|
+
changed = new Set();
|
|
272
|
+
pendingRows = new Set();
|
|
273
|
+
revisions = new Map();
|
|
254
274
|
/** Bind to the registry shared by host and isolated providers in this context. */
|
|
255
275
|
constructor(dataContext, registry) {
|
|
256
276
|
this.dataContext = dataContext;
|
|
@@ -274,7 +294,8 @@ class ToolCatalog {
|
|
|
274
294
|
return Promise.reject(new Error("Tool catalog disposed"));
|
|
275
295
|
if (!this.initialization) {
|
|
276
296
|
this.initialization = this.enqueue(async () => {
|
|
277
|
-
await this.
|
|
297
|
+
await this.load();
|
|
298
|
+
await this.reconcile([...this.rowMap.keys()]);
|
|
278
299
|
const groups = new Map();
|
|
279
300
|
for (const entry of (0, system_contracts_1.listSystemContracts)()) {
|
|
280
301
|
const entries = groups.get(entry.providerPackageId) ?? [];
|
|
@@ -295,25 +316,148 @@ class ToolCatalog {
|
|
|
295
316
|
this.registry.replaceBatch(provider, registrations);
|
|
296
317
|
}
|
|
297
318
|
});
|
|
298
|
-
// dispose() may have run while initialization awaited storage
|
|
299
|
-
// already unsubscribed, so a late subscription would never be released.
|
|
319
|
+
// dispose() may have run while initialization awaited storage.
|
|
300
320
|
this.assertLive();
|
|
301
|
-
this.
|
|
302
|
-
|
|
303
|
-
return;
|
|
304
|
-
this.reconciliationQueued = true;
|
|
305
|
-
void this.enqueue(async () => {
|
|
306
|
-
this.reconciliationQueued = false;
|
|
307
|
-
await this.reconcile();
|
|
308
|
-
}).catch((error) => console.error("Tool catalog reconciliation failed", error));
|
|
309
|
-
});
|
|
321
|
+
this.initialized = true;
|
|
322
|
+
this.queueReconciliation();
|
|
310
323
|
}).catch((error) => {
|
|
311
324
|
this.initialization = undefined;
|
|
325
|
+
this.forget();
|
|
312
326
|
throw error;
|
|
313
327
|
});
|
|
314
328
|
}
|
|
315
329
|
return this.initialization;
|
|
316
330
|
}
|
|
331
|
+
initialized = false;
|
|
332
|
+
/** True while change events keep {@link rows} current. */
|
|
333
|
+
live = false;
|
|
334
|
+
/**
|
|
335
|
+
* Read this context's rows into memory, after subscribing to the table's changes so none
|
|
336
|
+
* is missed while the read is in flight. A row that changes during the read keeps the
|
|
337
|
+
* changed version. Where the table can't report changes (a client process whose table
|
|
338
|
+
* events are off), the rows are read again before each use instead.
|
|
339
|
+
*/
|
|
340
|
+
async load() {
|
|
341
|
+
// Work queued before dispose() would otherwise subscribe again, and nothing would release it.
|
|
342
|
+
this.assertLive();
|
|
343
|
+
if (this.rows && this.live)
|
|
344
|
+
return this.loadPendingRows();
|
|
345
|
+
const table = (0, tools_1.Tools)(this.dataContext);
|
|
346
|
+
const rows = new Map();
|
|
347
|
+
const seen = new Set();
|
|
348
|
+
if (!this.subscription && table.dataChanged && !(rpc_types_1.isClient && !(0, rpc_types_1.isSingleProcessClient)())) {
|
|
349
|
+
this.subscription = table.dataChanged.subscribe((event) => {
|
|
350
|
+
const toolId = event.dataObject.toolId;
|
|
351
|
+
this.revisions.set(toolId, (this.revisions.get(toolId) ?? 0) + 1);
|
|
352
|
+
if (!this.rows)
|
|
353
|
+
seen.add(toolId);
|
|
354
|
+
const target = this.rows ?? rows;
|
|
355
|
+
if (event.op === "delete")
|
|
356
|
+
target.delete(toolId);
|
|
357
|
+
// Full replacements clear omitted optional fields in SQL. Merging would retain
|
|
358
|
+
// removed provenance, descriptions or quarantine evidence indefinitely.
|
|
359
|
+
else if (isCompleteRow(event.dataObject))
|
|
360
|
+
target.set(toolId, (0, lodash_1.cloneDeep)(event.dataObject));
|
|
361
|
+
else
|
|
362
|
+
this.pendingRows.add(toolId);
|
|
363
|
+
this.changed.add(toolId);
|
|
364
|
+
if (this.initialized)
|
|
365
|
+
this.queueReconciliation();
|
|
366
|
+
});
|
|
367
|
+
this.live = true;
|
|
368
|
+
}
|
|
369
|
+
for (const row of await table.list()) {
|
|
370
|
+
if (!seen.has(row.toolId))
|
|
371
|
+
rows.set(row.toolId, row);
|
|
372
|
+
}
|
|
373
|
+
this.assertLive();
|
|
374
|
+
this.rows = rows;
|
|
375
|
+
if (this.pendingRows.size)
|
|
376
|
+
await this.loadPendingRows();
|
|
377
|
+
}
|
|
378
|
+
/** Read partial notifications in batches, retrying only rows changed during the read. */
|
|
379
|
+
async loadPendingRows() {
|
|
380
|
+
while (this.pendingRows.size) {
|
|
381
|
+
const ids = [...this.pendingRows].slice(0, 500);
|
|
382
|
+
const revisions = ids.map((id) => this.revisions.get(id));
|
|
383
|
+
const rows = new Map((await (0, tools_1.Tools)(this.dataContext).list({ toolId: { $in: ids } })).map((row) => [
|
|
384
|
+
row.toolId,
|
|
385
|
+
row,
|
|
386
|
+
]));
|
|
387
|
+
this.assertLive();
|
|
388
|
+
ids.forEach((id, index) => {
|
|
389
|
+
if (this.revisions.get(id) !== revisions[index])
|
|
390
|
+
return;
|
|
391
|
+
const row = rows.get(id);
|
|
392
|
+
if (row)
|
|
393
|
+
this.rowMap.set(id, row);
|
|
394
|
+
else
|
|
395
|
+
this.rowMap.delete(id);
|
|
396
|
+
this.pendingRows.delete(id);
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
/** Stop watching the table and forget its rows (after a failed start, or on dispose). */
|
|
401
|
+
forget() {
|
|
402
|
+
this.subscription?.unsubscribe();
|
|
403
|
+
this.subscription = undefined;
|
|
404
|
+
this.live = false;
|
|
405
|
+
this.rows = undefined;
|
|
406
|
+
this.changed.clear();
|
|
407
|
+
this.pendingRows.clear();
|
|
408
|
+
this.revisions.clear();
|
|
409
|
+
this.initialized = false;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Record silent writes, but never replace a newer event with an older write result.
|
|
413
|
+
*/
|
|
414
|
+
async write(toolId, save) {
|
|
415
|
+
const revision = this.revisions.get(toolId);
|
|
416
|
+
let row;
|
|
417
|
+
try {
|
|
418
|
+
row = await save();
|
|
419
|
+
}
|
|
420
|
+
catch (error) {
|
|
421
|
+
// The row write can succeed before history persistence fails and suppresses its event.
|
|
422
|
+
if (this.rows)
|
|
423
|
+
this.pendingRows.add(toolId);
|
|
424
|
+
throw error;
|
|
425
|
+
}
|
|
426
|
+
if (this.rows && this.revisions.get(toolId) === revision) {
|
|
427
|
+
// Table returns and event payloads can still be held and edited by their caller.
|
|
428
|
+
if (isCompleteRow(row))
|
|
429
|
+
this.rows.set(toolId, (0, lodash_1.cloneDeep)(row));
|
|
430
|
+
else
|
|
431
|
+
this.pendingRows.add(toolId);
|
|
432
|
+
}
|
|
433
|
+
return row;
|
|
434
|
+
}
|
|
435
|
+
get rowMap() {
|
|
436
|
+
if (!this.rows)
|
|
437
|
+
throw new Error("Tool catalog is not ready");
|
|
438
|
+
return this.rows;
|
|
439
|
+
}
|
|
440
|
+
queueReconciliation() {
|
|
441
|
+
if (this.reconciliationQueued || this.disposed || !this.changed.size)
|
|
442
|
+
return;
|
|
443
|
+
this.reconciliationQueued = true;
|
|
444
|
+
void this.enqueue(async () => {
|
|
445
|
+
this.reconciliationQueued = false;
|
|
446
|
+
const ids = [...this.changed];
|
|
447
|
+
this.changed.clear();
|
|
448
|
+
try {
|
|
449
|
+
if (this.pendingRows.size)
|
|
450
|
+
await this.loadPendingRows();
|
|
451
|
+
await this.reconcile(ids);
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
// Keep failed and unvisited rows for the next event, like the old full pass.
|
|
455
|
+
for (const id of ids)
|
|
456
|
+
this.changed.add(id);
|
|
457
|
+
throw error;
|
|
458
|
+
}
|
|
459
|
+
}).catch((error) => console.error("Tool catalog reconciliation failed", error));
|
|
460
|
+
}
|
|
317
461
|
assertLive() {
|
|
318
462
|
if (this.disposed)
|
|
319
463
|
throw new Error("Tool catalog disposed");
|
|
@@ -349,6 +493,7 @@ class ToolCatalog {
|
|
|
349
493
|
}
|
|
350
494
|
await this.ready();
|
|
351
495
|
return this.enqueue(async () => {
|
|
496
|
+
await this.load();
|
|
352
497
|
this.validate(provider, registrations);
|
|
353
498
|
await this.materialize(registrations, false);
|
|
354
499
|
await prepare();
|
|
@@ -356,7 +501,9 @@ class ToolCatalog {
|
|
|
356
501
|
await this.materialize(registrations, true, provider);
|
|
357
502
|
for (const metadata of toolMetadata) {
|
|
358
503
|
for (const toolId of (0, tool_catalog_migrations_1.toolCatalogIds)(metadata)) {
|
|
359
|
-
|
|
504
|
+
if (this.pendingRows.size)
|
|
505
|
+
await this.loadPendingRows();
|
|
506
|
+
const stored = this.rowMap.get(toolId);
|
|
360
507
|
if (!stored || stored.catalogState !== "resolved")
|
|
361
508
|
throw new Error(`Missing or quarantined tool identity: ${toolId}`);
|
|
362
509
|
await this.refresh({ ...metadata, toolId }, stored, tools_1.toolCatalogSupplementalFields);
|
|
@@ -392,6 +539,7 @@ class ToolCatalog {
|
|
|
392
539
|
if (!expected.has(toolId))
|
|
393
540
|
return;
|
|
394
541
|
try {
|
|
542
|
+
// Validate the payload as written: a partial row that omits identity fails closed.
|
|
395
543
|
validateRow(toolId, event.op === "delete" ? undefined : event.dataObject);
|
|
396
544
|
}
|
|
397
545
|
catch (error) {
|
|
@@ -405,8 +553,10 @@ class ToolCatalog {
|
|
|
405
553
|
this.validate(provider, registrations);
|
|
406
554
|
};
|
|
407
555
|
try {
|
|
556
|
+
// Clients without table events need the same final storage check as before caching.
|
|
557
|
+
await this.load();
|
|
408
558
|
for (const toolId of expected.keys())
|
|
409
|
-
validateRow(toolId,
|
|
559
|
+
validateRow(toolId, this.rowMap.get(toolId));
|
|
410
560
|
assertCatalogCurrent();
|
|
411
561
|
return await activate(assertCatalogCurrent);
|
|
412
562
|
}
|
|
@@ -443,8 +593,9 @@ class ToolCatalog {
|
|
|
443
593
|
* definition's name, usage description and IO schemas.
|
|
444
594
|
*/
|
|
445
595
|
async materialize(registrations, refresh, provider) {
|
|
596
|
+
await this.load();
|
|
446
597
|
const table = (0, tools_1.Tools)(this.dataContext);
|
|
447
|
-
const existing =
|
|
598
|
+
const existing = this.rowMap;
|
|
448
599
|
const identities = new Map();
|
|
449
600
|
// Reserve the entire reviewed inventory, even on an empty database.
|
|
450
601
|
for (const entry of tool_catalog_migrations_1.toolCatalogMigrations)
|
|
@@ -486,23 +637,32 @@ class ToolCatalog {
|
|
|
486
637
|
// Validate the entire batch before the first write. Catalog metadata never
|
|
487
638
|
// overwrites provider-specific policy or another provider's descriptions.
|
|
488
639
|
for (const row of candidates.values()) {
|
|
640
|
+
if (this.pendingRows.size)
|
|
641
|
+
await this.loadPendingRows();
|
|
489
642
|
const previous = existing.get(row.toolId);
|
|
643
|
+
let restored;
|
|
490
644
|
// Restore missing identity columns only after validating the entire batch.
|
|
491
645
|
// This marked patch leaves concurrent policy and historical fields intact.
|
|
492
646
|
if (previous &&
|
|
493
647
|
(canRestoreBareDeterministicRow(previous, row) || canResolveMissingTarget(previous, row))) {
|
|
494
|
-
|
|
648
|
+
restored = await this.write(row.toolId, () => table.update((0, tools_1.toolCatalogPatch)({
|
|
495
649
|
toolId: row.toolId,
|
|
496
650
|
...resolvedFields(toolCatalogTarget(row), row.toolId),
|
|
497
|
-
}));
|
|
651
|
+
})));
|
|
498
652
|
validateToolCatalogWrite(row, restored);
|
|
499
653
|
if (restored.catalogState === "quarantined")
|
|
500
654
|
throw new Error(`Quarantined tool identity: ${row.toolId}`);
|
|
501
655
|
}
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
|
|
656
|
+
// A row already in memory needs no insert-only save, which would only read it back:
|
|
657
|
+
// every start would cost a query per tool per context. A row that changes later is
|
|
658
|
+
// caught by guardActivation, which checks again under a subscription before anything
|
|
659
|
+
// activates. Older runtimes allowed provider removal to leave tombstones, which aren't
|
|
660
|
+
// in memory: only a validated public target may restore one, and insert-only still
|
|
661
|
+
// preserves a row that arrived concurrently.
|
|
662
|
+
let stored = restored ?? previous;
|
|
663
|
+
if (!stored) {
|
|
664
|
+
stored = await this.write(row.toolId, () => table.save(row, { insertOnly: true, restoreIfDeleted: true }));
|
|
665
|
+
}
|
|
506
666
|
validateToolCatalogWrite(row, stored);
|
|
507
667
|
if (!stored || stored.catalogState === "quarantined")
|
|
508
668
|
throw new Error(`Catalog insert did not succeed: ${row.toolId}`);
|
|
@@ -521,14 +681,20 @@ class ToolCatalog {
|
|
|
521
681
|
const patch = { toolId: desired.toolId };
|
|
522
682
|
for (const field of changed)
|
|
523
683
|
Object.assign(patch, { [field]: desired[field] });
|
|
524
|
-
const refreshed = await (0, tools_1.Tools)(this.dataContext).update((0, tools_1.toolCatalogPatch)(patch));
|
|
684
|
+
const refreshed = await this.write(desired.toolId, () => (0, tools_1.Tools)(this.dataContext).update((0, tools_1.toolCatalogPatch)(patch)));
|
|
525
685
|
validateToolCatalogWrite(stored, refreshed);
|
|
526
686
|
if (refreshed.catalogState === "quarantined")
|
|
527
687
|
throw new Error(`Quarantined tool identity: ${desired.toolId}`);
|
|
528
688
|
}
|
|
529
|
-
|
|
689
|
+
/** Re-derive catalog state for these rows (all of them at startup, then those that change). */
|
|
690
|
+
async reconcile(toolIds) {
|
|
530
691
|
const table = (0, tools_1.Tools)(this.dataContext);
|
|
531
|
-
for (const
|
|
692
|
+
for (const toolId of toolIds) {
|
|
693
|
+
if (this.pendingRows.size)
|
|
694
|
+
await this.loadPendingRows();
|
|
695
|
+
const row = this.rowMap.get(toolId);
|
|
696
|
+
if (!row)
|
|
697
|
+
continue;
|
|
532
698
|
let fields;
|
|
533
699
|
// Quarantine is sticky: conflicting evidence is never silently rehabilitated. The one
|
|
534
700
|
// exception is a row quarantined for its missing target that has since received the
|
|
@@ -540,7 +706,10 @@ class ToolCatalog {
|
|
|
540
706
|
const resolved = { ...row, ...resolvedFields(target, row.toolId) };
|
|
541
707
|
if (!canResolveMissingTarget(row, resolved))
|
|
542
708
|
continue;
|
|
543
|
-
await table.update((0, tools_1.toolCatalogPatch)({
|
|
709
|
+
await this.write(row.toolId, () => table.update((0, tools_1.toolCatalogPatch)({
|
|
710
|
+
toolId: row.toolId,
|
|
711
|
+
...resolvedFields(target, row.toolId),
|
|
712
|
+
})));
|
|
544
713
|
continue;
|
|
545
714
|
}
|
|
546
715
|
try {
|
|
@@ -554,7 +723,7 @@ class ToolCatalog {
|
|
|
554
723
|
if (Object.entries(fields).some(([key, value]) => row[key] !== value)) {
|
|
555
724
|
// Update only catalog-owned fields. A sync write may have changed legacy
|
|
556
725
|
// metadata since list() returned; writing the snapshot back would lose it.
|
|
557
|
-
await table.update((0, tools_1.toolCatalogPatch)({ toolId: row.toolId, ...fields }));
|
|
726
|
+
await this.write(row.toolId, () => table.update((0, tools_1.toolCatalogPatch)({ toolId: row.toolId, ...fields })));
|
|
558
727
|
}
|
|
559
728
|
}
|
|
560
729
|
}
|
|
@@ -566,6 +735,7 @@ class ToolCatalog {
|
|
|
566
735
|
async saveToolMetadata(entries) {
|
|
567
736
|
await this.ready();
|
|
568
737
|
await this.enqueue(async () => {
|
|
738
|
+
await this.load();
|
|
569
739
|
const members = (0, system_contracts_1.listSystemContracts)().flatMap((entry) => {
|
|
570
740
|
const definition = entry.getDefinition();
|
|
571
741
|
return definition.tools.map((tool) => ({
|
|
@@ -578,7 +748,7 @@ class ToolCatalog {
|
|
|
578
748
|
for (const entry of entries) {
|
|
579
749
|
const target = (0, tool_catalog_migrations_1.resolveLegacyToolId)(entry.toolId) ??
|
|
580
750
|
members.find((member) => (0, tool_catalog_migrations_1.toolCatalogIds)(member).includes(entry.toolId));
|
|
581
|
-
const stored =
|
|
751
|
+
const stored = this.rowMap.get(entry.toolId);
|
|
582
752
|
if (!target ||
|
|
583
753
|
!members.some((member) => (0, tool_catalog_identity_1.toolCatalogTargetKey)(member) === (0, tool_catalog_identity_1.toolCatalogTargetKey)(target)) ||
|
|
584
754
|
!stored ||
|
|
@@ -626,7 +796,7 @@ class ToolCatalog {
|
|
|
626
796
|
/** Stop subscriptions and queued lifecycle work; permanent rows are retained. */
|
|
627
797
|
async dispose() {
|
|
628
798
|
this.disposed = true;
|
|
629
|
-
this.
|
|
799
|
+
this.forget();
|
|
630
800
|
await this.tail;
|
|
631
801
|
}
|
|
632
802
|
}
|
package/dist/data/orm/table.d.ts
CHANGED
|
@@ -21,6 +21,8 @@ export declare class Table<T extends {
|
|
|
21
21
|
protected readonly dataChangedEmitter: Emitter<IDataChangedEvent<T>>;
|
|
22
22
|
readonly dataChanged: Event<IDataChangedEvent<T>>;
|
|
23
23
|
private readonly localOperationCache;
|
|
24
|
+
/** Rows committed by an in-progress local write, for {@link reportCommittedLocalWrite}. */
|
|
25
|
+
private readonly committedLocalEvents;
|
|
24
26
|
readonly dataSource: IDataSource<T>;
|
|
25
27
|
readonly metaData: ITableMetaData;
|
|
26
28
|
readonly groupId?: string;
|
|
@@ -28,6 +30,12 @@ export declare class Table<T extends {
|
|
|
28
30
|
private getRecordId;
|
|
29
31
|
private setLocalOperation;
|
|
30
32
|
private clearLocalOperation;
|
|
33
|
+
/**
|
|
34
|
+
* A local write failed after its row was committed (for example, a tracked table's history
|
|
35
|
+
* write failed). Tell subscribers about the committed row, as a successful write would, so
|
|
36
|
+
* a cache of this table (the Tools catalog's) doesn't miss it.
|
|
37
|
+
*/
|
|
38
|
+
private reportCommittedLocalWrite;
|
|
31
39
|
private cachedRecords;
|
|
32
40
|
get(id: string, opts?: {
|
|
33
41
|
useCache?: boolean;
|
package/dist/data/orm/table.js
CHANGED
|
@@ -28,6 +28,8 @@ class Table {
|
|
|
28
28
|
dataChangedEmitter;
|
|
29
29
|
dataChanged;
|
|
30
30
|
localOperationCache = new Map();
|
|
31
|
+
/** Rows committed by an in-progress local write, for {@link reportCommittedLocalWrite}. */
|
|
32
|
+
committedLocalEvents = new Map();
|
|
31
33
|
dataSource;
|
|
32
34
|
metaData;
|
|
33
35
|
groupId;
|
|
@@ -79,6 +81,12 @@ class Table {
|
|
|
79
81
|
source: "remote",
|
|
80
82
|
});
|
|
81
83
|
}
|
|
84
|
+
else {
|
|
85
|
+
// A local write's row is committed, but the write can still fail after it (a
|
|
86
|
+
// tracked table's history write). Keep the event, so a failed write still reports
|
|
87
|
+
// the committed row; a successful one emits its own event instead.
|
|
88
|
+
this.committedLocalEvents.set(recordId, event);
|
|
89
|
+
}
|
|
82
90
|
});
|
|
83
91
|
}
|
|
84
92
|
}
|
|
@@ -92,6 +100,17 @@ class Table {
|
|
|
92
100
|
clearLocalOperation(recordId, operation) {
|
|
93
101
|
const cacheKey = `${recordId}-${operation}`;
|
|
94
102
|
this.localOperationCache.delete(cacheKey);
|
|
103
|
+
this.committedLocalEvents.delete(recordId);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* A local write failed after its row was committed (for example, a tracked table's history
|
|
107
|
+
* write failed). Tell subscribers about the committed row, as a successful write would, so
|
|
108
|
+
* a cache of this table (the Tools catalog's) doesn't miss it.
|
|
109
|
+
*/
|
|
110
|
+
reportCommittedLocalWrite(recordId) {
|
|
111
|
+
const event = this.committedLocalEvents.get(recordId);
|
|
112
|
+
if (event)
|
|
113
|
+
this.dataChangedEmitter.emit({ ...event, source: "local" });
|
|
95
114
|
}
|
|
96
115
|
cachedRecords = {};
|
|
97
116
|
async get(id, opts = {}) {
|
|
@@ -161,6 +180,10 @@ class Table {
|
|
|
161
180
|
// TODO: try to avoid the double-read, probably by passing the just-read result as `opts.dbData`
|
|
162
181
|
result = await this.dataSource.save(record, opts);
|
|
163
182
|
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
this.reportCommittedLocalWrite(recordId);
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
164
187
|
finally {
|
|
165
188
|
this.clearLocalOperation(recordId, operation);
|
|
166
189
|
}
|
|
@@ -178,6 +201,10 @@ class Table {
|
|
|
178
201
|
try {
|
|
179
202
|
result = await this.dataSource.insert(record);
|
|
180
203
|
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
this.reportCommittedLocalWrite(recordId);
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
181
208
|
finally {
|
|
182
209
|
this.clearLocalOperation(recordId, "insert");
|
|
183
210
|
}
|
|
@@ -196,6 +223,10 @@ class Table {
|
|
|
196
223
|
try {
|
|
197
224
|
result = await this.dataSource.update(record);
|
|
198
225
|
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
this.reportCommittedLocalWrite(recordId);
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
199
230
|
finally {
|
|
200
231
|
this.clearLocalOperation(recordId, "update");
|
|
201
232
|
}
|
|
@@ -217,6 +248,10 @@ class Table {
|
|
|
217
248
|
try {
|
|
218
249
|
await this.dataSource.delete(idOrRecord);
|
|
219
250
|
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
this.reportCommittedLocalWrite(primaryKey);
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
220
255
|
finally {
|
|
221
256
|
this.clearLocalOperation(primaryKey, "delete");
|
|
222
257
|
}
|
|
@@ -59,8 +59,8 @@ export interface BinaryPeerReceiveLimits {
|
|
|
59
59
|
export declare const DEFAULT_BINARY_PEER_RECEIVE_LIMITS: Readonly<BinaryPeerReceiveLimits>;
|
|
60
60
|
/** Options for wrapping a binary transport in a verified Peers connection. */
|
|
61
61
|
export interface WrapBinaryPeerOptions {
|
|
62
|
-
/** Protocol identifier used for server address
|
|
63
|
-
protocol: "wrtc" | "ws";
|
|
62
|
+
/** Protocol identifier, used for the server address and the transport's frame sizes. */
|
|
63
|
+
protocol: "wrtc" | "ws" | "wt";
|
|
64
64
|
/** Optional hard limits for untrusted inbound messages. */
|
|
65
65
|
receiveLimits?: Partial<BinaryPeerReceiveLimits>;
|
|
66
66
|
}
|
|
@@ -70,7 +70,7 @@ export interface WrapBinaryPeerOptions {
|
|
|
70
70
|
*/
|
|
71
71
|
export declare function createBinaryPeerSocket(connectionId: string, peer: IBinaryPeer, protocol?: string, receiveLimitOverrides?: Partial<BinaryPeerReceiveLimits>): ISocket;
|
|
72
72
|
/**
|
|
73
|
-
* Wraps a binary peer (WebRTC or WebSocket) into a Connection.
|
|
73
|
+
* Wraps a binary peer (WebRTC, WebTransport or WebSocket) into a Connection.
|
|
74
74
|
* This is the main implementation that handles all binary protocols.
|
|
75
75
|
*/
|
|
76
76
|
export declare function wrapBinaryPeer(connectionId: string, peer: IBinaryPeer, localDevice: Device, initiator: boolean, options: WrapBinaryPeerOptions, getTrustLevel?: ITrustLevelFn): Connection;
|
|
@@ -57,6 +57,14 @@ const WRTC_CONFIG = {
|
|
|
57
57
|
lowWaterMark: 32 * 1024, // 32KB
|
|
58
58
|
yieldBetweenChunks: true, // CRITICAL for Go sidecar pipeline
|
|
59
59
|
};
|
|
60
|
+
// WebTransport reaches Node through the same sidecar pipe as WebRTC, so it is paced the same
|
|
61
|
+
// way. The sidecar caps a frame at 1 MB, well above this.
|
|
62
|
+
const WT_CONFIG = {
|
|
63
|
+
maxFramePayload: 64 * 1024,
|
|
64
|
+
highWaterMark: 128 * 1024,
|
|
65
|
+
lowWaterMark: 32 * 1024,
|
|
66
|
+
yieldBetweenChunks: true,
|
|
67
|
+
};
|
|
60
68
|
const WS_CONFIG = {
|
|
61
69
|
maxFramePayload: 512 * 1024, // 512KB
|
|
62
70
|
highWaterMark: 4 * 1024 * 1024, // 4MB
|
|
@@ -67,6 +75,8 @@ function getTransportConfig(protocol) {
|
|
|
67
75
|
switch (protocol) {
|
|
68
76
|
case "wrtc":
|
|
69
77
|
return WRTC_CONFIG;
|
|
78
|
+
case "wt":
|
|
79
|
+
return WT_CONFIG;
|
|
70
80
|
case "ws":
|
|
71
81
|
return WS_CONFIG;
|
|
72
82
|
default:
|
|
@@ -252,7 +262,7 @@ function createBinaryPeerSocket(connectionId, peer, protocol = "binary", receive
|
|
|
252
262
|
const rawBytesHandlers = {};
|
|
253
263
|
let isClosed = false;
|
|
254
264
|
const stats = (0, socket_type_1.createSocketStats)();
|
|
255
|
-
const transportType = protocol === "wrtc"
|
|
265
|
+
const transportType = protocol === "wrtc" || protocol === "ws" || protocol === "wt" ? protocol : "unknown";
|
|
256
266
|
const config = getTransportConfig(protocol);
|
|
257
267
|
const receiveLimits = {
|
|
258
268
|
...exports.DEFAULT_BINARY_PEER_RECEIVE_LIMITS,
|
|
@@ -836,7 +846,7 @@ function createBinaryPeerSocket(connectionId, peer, protocol = "binary", receive
|
|
|
836
846
|
}
|
|
837
847
|
// ─── Connection Wrappers ─────────────────────────────────────────────
|
|
838
848
|
/**
|
|
839
|
-
* Wraps a binary peer (WebRTC or WebSocket) into a Connection.
|
|
849
|
+
* Wraps a binary peer (WebRTC, WebTransport or WebSocket) into a Connection.
|
|
840
850
|
* This is the main implementation that handles all binary protocols.
|
|
841
851
|
*/
|
|
842
852
|
function wrapBinaryPeer(connectionId, peer, localDevice, initiator, options, getTrustLevel) {
|
|
@@ -34,8 +34,15 @@ export interface ISocketStats {
|
|
|
34
34
|
}
|
|
35
35
|
/** Creates a new stats object for tracking socket throughput */
|
|
36
36
|
export declare function createSocketStats(): ISocketStats;
|
|
37
|
-
/**
|
|
38
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Underlying transport family of a socket.
|
|
39
|
+
*
|
|
40
|
+
* - `ws`: Socket.IO over a WebSocket (a desktop's TCP listener).
|
|
41
|
+
* - `wrtc`: a WebRTC data channel.
|
|
42
|
+
* - `wt`: a WebTransport session to a desktop's sidecar (the PWA on the LAN). Not WebRTC, so
|
|
43
|
+
* WebRTC-only policy (upgrades, route checks) leaves it alone.
|
|
44
|
+
*/
|
|
45
|
+
export type TransportType = "ws" | "wrtc" | "wt" | "unknown";
|
|
39
46
|
/** Minimal RPC + raw-byte transport abstraction consumed by `Connection`. */
|
|
40
47
|
export interface ISocket {
|
|
41
48
|
id?: string;
|
|
@@ -43,6 +50,7 @@ export interface ISocket {
|
|
|
43
50
|
/**
|
|
44
51
|
* Transport type for this socket. Used to optimize chunk sizes for raw byte streaming.
|
|
45
52
|
* - 'wrtc': WebRTC (64KB max due to SCTP limits)
|
|
53
|
+
* - 'wt': WebTransport through the sidecar (sized like WebRTC's sidecar path)
|
|
46
54
|
* - 'ws': WebSocket (1MB+ chunks OK)
|
|
47
55
|
*/
|
|
48
56
|
transportType?: TransportType;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebTransport addresses: how a desktop tells the PWA where to reach it directly on the LAN.
|
|
3
|
+
*
|
|
4
|
+
* A desktop's sidecar serves WebTransport (HTTP/3 over QUIC) with a self-signed certificate.
|
|
5
|
+
* Browsers accept that certificate when the page passes its SHA-256 hash
|
|
6
|
+
* (`serverCertificateHashes`), so the address carries the hashes:
|
|
7
|
+
*
|
|
8
|
+
* ```text
|
|
9
|
+
* https://192.168.4.31:3333/peers#sha256=<current>,<next>
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* The address goes in the device's owner-signed `reachability.addresses`, beside its WebSocket
|
|
13
|
+
* origins, so the hashes are covered by the same signature and older devices verify it unchanged.
|
|
14
|
+
* The `#sha256=` fragment is what marks an address as WebTransport: WebSocket dialers skip it and
|
|
15
|
+
* WebTransport dialers take nothing else. The server publishes the current week's hash and the
|
|
16
|
+
* next one's, so a phone holding last week's row still matches after the desktop rolls over.
|
|
17
|
+
*
|
|
18
|
+
* To dial, the client picks a connection id and opens {@link webTransportSessionUrl}; both ends
|
|
19
|
+
* then name the connection `wt:<id>` ({@link webTransportConnectionId}), because the Peers
|
|
20
|
+
* handshake binds the connection id into its frame keys.
|
|
21
|
+
*/
|
|
22
|
+
/** The path a desktop's WebTransport server accepts sessions on. */
|
|
23
|
+
export declare const WEBTRANSPORT_PATH = "/peers";
|
|
24
|
+
/** Every WebTransport connection id starts with this, on both ends. */
|
|
25
|
+
export declare const WEBTRANSPORT_CONNECTION_PREFIX = "wt:";
|
|
26
|
+
/** The most certificate hashes one address carries (the server publishes two). */
|
|
27
|
+
export declare const MAX_WEBTRANSPORT_CERTIFICATE_HASHES = 4;
|
|
28
|
+
/** A parsed WebTransport address. */
|
|
29
|
+
export interface WebTransportAddress {
|
|
30
|
+
/** The session URL without the client's connection id: `https://<host>:<port>/peers`. */
|
|
31
|
+
url: string;
|
|
32
|
+
/** SHA-256 of each certificate the server may present, as unpadded base64url (43 characters). */
|
|
33
|
+
certificateHashes: string[];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Formats the address a desktop publishes for one of its LAN addresses.
|
|
37
|
+
*
|
|
38
|
+
* @param host An IPv4 address, an IPv6 address (bracketed for you), or a hostname.
|
|
39
|
+
* @param port The server's UDP port.
|
|
40
|
+
* @param certificateHashes The hashes to pin, current window first.
|
|
41
|
+
* @throws When the port or a hash is malformed, or there are no hashes or too many.
|
|
42
|
+
*/
|
|
43
|
+
export declare function formatWebTransportAddress(host: string, port: number, certificateHashes: readonly string[]): string;
|
|
44
|
+
/** True when an address carries WebTransport certificate hashes (whether or not it's valid). */
|
|
45
|
+
export declare function isWebTransportAddress(address: string): boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Parses a WebTransport address, or returns `undefined` for anything else, including a
|
|
48
|
+
* malformed one: an `https:` URL on {@link WEBTRANSPORT_PATH} with an explicit port, no
|
|
49
|
+
* credentials or query, and one to {@link MAX_WEBTRANSPORT_CERTIFICATE_HASHES} well-formed hashes.
|
|
50
|
+
*/
|
|
51
|
+
export declare function parseWebTransportAddress(address: string): WebTransportAddress | undefined;
|
|
52
|
+
/**
|
|
53
|
+
* The URL a client opens for one session: the address's URL with the client's connection id.
|
|
54
|
+
*
|
|
55
|
+
* @param clientId 16 to 64 letters and digits, such as a `newid()`.
|
|
56
|
+
* @throws When the id doesn't fit that shape (the server would refuse it).
|
|
57
|
+
*/
|
|
58
|
+
export declare function webTransportSessionUrl(address: WebTransportAddress, clientId: string): string;
|
|
59
|
+
/** The connection id both ends give a session the client opened with `clientId`. */
|
|
60
|
+
export declare function webTransportConnectionId(clientId: string): string;
|
|
61
|
+
/** Decodes one certificate hash to the 32 bytes `serverCertificateHashes` wants. */
|
|
62
|
+
export declare function decodeWebTransportCertificateHash(hash: string): Uint8Array;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* WebTransport addresses: how a desktop tells the PWA where to reach it directly on the LAN.
|
|
4
|
+
*
|
|
5
|
+
* A desktop's sidecar serves WebTransport (HTTP/3 over QUIC) with a self-signed certificate.
|
|
6
|
+
* Browsers accept that certificate when the page passes its SHA-256 hash
|
|
7
|
+
* (`serverCertificateHashes`), so the address carries the hashes:
|
|
8
|
+
*
|
|
9
|
+
* ```text
|
|
10
|
+
* https://192.168.4.31:3333/peers#sha256=<current>,<next>
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* The address goes in the device's owner-signed `reachability.addresses`, beside its WebSocket
|
|
14
|
+
* origins, so the hashes are covered by the same signature and older devices verify it unchanged.
|
|
15
|
+
* The `#sha256=` fragment is what marks an address as WebTransport: WebSocket dialers skip it and
|
|
16
|
+
* WebTransport dialers take nothing else. The server publishes the current week's hash and the
|
|
17
|
+
* next one's, so a phone holding last week's row still matches after the desktop rolls over.
|
|
18
|
+
*
|
|
19
|
+
* To dial, the client picks a connection id and opens {@link webTransportSessionUrl}; both ends
|
|
20
|
+
* then name the connection `wt:<id>` ({@link webTransportConnectionId}), because the Peers
|
|
21
|
+
* handshake binds the connection id into its frame keys.
|
|
22
|
+
*/
|
|
23
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
|
+
exports.MAX_WEBTRANSPORT_CERTIFICATE_HASHES = exports.WEBTRANSPORT_CONNECTION_PREFIX = exports.WEBTRANSPORT_PATH = void 0;
|
|
25
|
+
exports.formatWebTransportAddress = formatWebTransportAddress;
|
|
26
|
+
exports.isWebTransportAddress = isWebTransportAddress;
|
|
27
|
+
exports.parseWebTransportAddress = parseWebTransportAddress;
|
|
28
|
+
exports.webTransportSessionUrl = webTransportSessionUrl;
|
|
29
|
+
exports.webTransportConnectionId = webTransportConnectionId;
|
|
30
|
+
exports.decodeWebTransportCertificateHash = decodeWebTransportCertificateHash;
|
|
31
|
+
const keys_1 = require("../keys");
|
|
32
|
+
/** The path a desktop's WebTransport server accepts sessions on. */
|
|
33
|
+
exports.WEBTRANSPORT_PATH = "/peers";
|
|
34
|
+
/** Every WebTransport connection id starts with this, on both ends. */
|
|
35
|
+
exports.WEBTRANSPORT_CONNECTION_PREFIX = "wt:";
|
|
36
|
+
/** The most certificate hashes one address carries (the server publishes two). */
|
|
37
|
+
exports.MAX_WEBTRANSPORT_CERTIFICATE_HASHES = 4;
|
|
38
|
+
const HASH_FRAGMENT = "sha256=";
|
|
39
|
+
const HASH_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
40
|
+
const CLIENT_ID_PATTERN = /^[A-Za-z0-9]{16,64}$/;
|
|
41
|
+
/**
|
|
42
|
+
* Formats the address a desktop publishes for one of its LAN addresses.
|
|
43
|
+
*
|
|
44
|
+
* @param host An IPv4 address, an IPv6 address (bracketed for you), or a hostname.
|
|
45
|
+
* @param port The server's UDP port.
|
|
46
|
+
* @param certificateHashes The hashes to pin, current window first.
|
|
47
|
+
* @throws When the port or a hash is malformed, or there are no hashes or too many.
|
|
48
|
+
*/
|
|
49
|
+
function formatWebTransportAddress(host, port, certificateHashes) {
|
|
50
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
51
|
+
throw new Error(`Invalid WebTransport port ${port}`);
|
|
52
|
+
}
|
|
53
|
+
if (certificateHashes.length === 0 ||
|
|
54
|
+
certificateHashes.length > exports.MAX_WEBTRANSPORT_CERTIFICATE_HASHES ||
|
|
55
|
+
!certificateHashes.every((hash) => HASH_PATTERN.test(hash))) {
|
|
56
|
+
throw new Error("Invalid WebTransport certificate hashes");
|
|
57
|
+
}
|
|
58
|
+
const hostPart = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
59
|
+
return `https://${hostPart}:${port}${exports.WEBTRANSPORT_PATH}#${HASH_FRAGMENT}${certificateHashes.join(",")}`;
|
|
60
|
+
}
|
|
61
|
+
/** True when an address carries WebTransport certificate hashes (whether or not it's valid). */
|
|
62
|
+
function isWebTransportAddress(address) {
|
|
63
|
+
return address.includes(`#${HASH_FRAGMENT}`);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Parses a WebTransport address, or returns `undefined` for anything else, including a
|
|
67
|
+
* malformed one: an `https:` URL on {@link WEBTRANSPORT_PATH} with an explicit port, no
|
|
68
|
+
* credentials or query, and one to {@link MAX_WEBTRANSPORT_CERTIFICATE_HASHES} well-formed hashes.
|
|
69
|
+
*/
|
|
70
|
+
function parseWebTransportAddress(address) {
|
|
71
|
+
if (!isWebTransportAddress(address))
|
|
72
|
+
return undefined;
|
|
73
|
+
let url;
|
|
74
|
+
try {
|
|
75
|
+
url = new URL(address);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
if (url.protocol !== "https:" ||
|
|
81
|
+
url.pathname !== exports.WEBTRANSPORT_PATH ||
|
|
82
|
+
// URL normalizes an explicit :443 away. It is still a valid configured UDP port.
|
|
83
|
+
(!url.port && !/^https:\/\/[^/?#]+:443\//i.test(address)) ||
|
|
84
|
+
url.username ||
|
|
85
|
+
url.password ||
|
|
86
|
+
url.search ||
|
|
87
|
+
!url.hash.startsWith(`#${HASH_FRAGMENT}`)) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
const certificateHashes = url.hash.slice(1 + HASH_FRAGMENT.length).split(",");
|
|
91
|
+
if (certificateHashes.length > exports.MAX_WEBTRANSPORT_CERTIFICATE_HASHES ||
|
|
92
|
+
!certificateHashes.every((hash) => HASH_PATTERN.test(hash))) {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
return { url: `https://${url.host}${exports.WEBTRANSPORT_PATH}`, certificateHashes };
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The URL a client opens for one session: the address's URL with the client's connection id.
|
|
99
|
+
*
|
|
100
|
+
* @param clientId 16 to 64 letters and digits, such as a `newid()`.
|
|
101
|
+
* @throws When the id doesn't fit that shape (the server would refuse it).
|
|
102
|
+
*/
|
|
103
|
+
function webTransportSessionUrl(address, clientId) {
|
|
104
|
+
if (!CLIENT_ID_PATTERN.test(clientId)) {
|
|
105
|
+
throw new Error("Invalid WebTransport client id");
|
|
106
|
+
}
|
|
107
|
+
return `${address.url}?c=${clientId}`;
|
|
108
|
+
}
|
|
109
|
+
/** The connection id both ends give a session the client opened with `clientId`. */
|
|
110
|
+
function webTransportConnectionId(clientId) {
|
|
111
|
+
return `${exports.WEBTRANSPORT_CONNECTION_PREFIX}${clientId}`;
|
|
112
|
+
}
|
|
113
|
+
/** Decodes one certificate hash to the 32 bytes `serverCertificateHashes` wants. */
|
|
114
|
+
function decodeWebTransportCertificateHash(hash) {
|
|
115
|
+
if (!HASH_PATTERN.test(hash)) {
|
|
116
|
+
throw new Error("Invalid WebTransport certificate hash");
|
|
117
|
+
}
|
|
118
|
+
const base64 = `${hash.replace(/-/g, "+").replace(/_/g, "/")}=`;
|
|
119
|
+
return (0, keys_1.decodeBase64)(base64);
|
|
120
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ export * from "./device/get-trust-level-fn";
|
|
|
19
19
|
export * from "./device/socket.type";
|
|
20
20
|
export * from "./device/socket-io-binary-peer";
|
|
21
21
|
export * from "./device/tx-encoding";
|
|
22
|
+
export * from "./device/webtransport-address";
|
|
22
23
|
export * from "./device-pairing";
|
|
23
24
|
export * from "./events";
|
|
24
25
|
export * from "./hlc";
|
package/dist/index.js
CHANGED
|
@@ -42,6 +42,7 @@ __exportStar(require("./device/get-trust-level-fn"), exports);
|
|
|
42
42
|
__exportStar(require("./device/socket.type"), exports);
|
|
43
43
|
__exportStar(require("./device/socket-io-binary-peer"), exports);
|
|
44
44
|
__exportStar(require("./device/tx-encoding"), exports);
|
|
45
|
+
__exportStar(require("./device/webtransport-address"), exports);
|
|
45
46
|
__exportStar(require("./device-pairing"), exports);
|
|
46
47
|
__exportStar(require("./events"), exports);
|
|
47
48
|
__exportStar(require("./hlc"), exports);
|