@lix-js/sdk 0.16.1 → 0.17.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/README.md +162 -44
- package/dist/binding-types.d.ts +15 -5
- package/dist/binding.browser.d.ts +2 -0
- package/dist/binding.browser.js +14 -4
- package/dist/binding.node-wasm.d.ts +1 -1
- package/dist/binding.node-wasm.js +5 -3
- package/dist/binding.node.d.ts +2 -0
- package/dist/binding.node.js +34 -8
- package/dist/bundled-plugins/plugin_csv.lixplugin +0 -0
- package/dist/bundled-plugins/plugin_markdown.lixplugin +0 -0
- package/dist/compatibility.d.ts +6 -0
- package/dist/compatibility.js +6 -0
- package/dist/component-host/dispatch.d.ts +12 -0
- package/dist/component-host/dispatch.js +364 -0
- package/dist/component-host/index.d.ts +15 -0
- package/dist/component-host/index.js +84 -0
- package/dist/component-host/instrument.d.ts +6 -0
- package/dist/component-host/instrument.js +260 -0
- package/dist/conversion-provider.d.ts +4 -0
- package/dist/conversion-provider.js +20 -0
- package/dist/hosted-lix.js +1 -1
- package/dist/http-transport.d.ts +25 -0
- package/dist/http-transport.js +162 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/lix.d.ts +14 -9
- package/dist/lix.js +44 -9
- package/dist/migration-binding.browser.d.ts +33 -0
- package/dist/migration-binding.browser.js +46 -0
- package/dist/migration-binding.node.d.ts +6 -0
- package/dist/migration-binding.node.js +5 -0
- package/dist/migration-wasm/lix_js_sdk.d.ts +271 -0
- package/dist/migration-wasm/lix_js_sdk.js +1852 -0
- package/dist/migration-wasm/lix_js_sdk_bg.wasm +4 -0
- package/dist/migration-wasm/lix_js_sdk_bg.wasm.d.ts +101 -0
- package/dist/migration.d.ts +23 -0
- package/dist/migration.js +55 -0
- package/dist/open-lix.js +29 -18
- package/dist/open-progress.d.ts +10 -0
- package/dist/open-progress.js +59 -0
- package/dist/remote/client.d.ts +2 -1
- package/dist/remote/client.js +11 -1
- package/dist/result.d.ts +4 -3
- package/dist/result.js +3 -4
- package/dist/storage-adapter.d.ts +7 -1
- package/dist/storage-ownership.d.ts +5 -0
- package/dist/storage-ownership.js +4 -0
- package/dist/types.d.ts +65 -31
- package/dist/wasm/lix_js_sdk.d.ts +45 -16
- package/dist/wasm/lix_js_sdk.js +214 -60
- package/dist/wasm/lix_js_sdk_bg.wasm +2 -2
- package/dist/wasm/lix_js_sdk_bg.wasm.d.ts +17 -9
- package/dist/worker/client.d.ts +12 -3
- package/dist/worker/client.js +95 -81
- package/dist/worker/durable-local-admission.d.ts +20 -0
- package/dist/worker/durable-local-admission.js +87 -0
- package/dist/worker/entry.shared.browser.d.ts +1 -0
- package/dist/worker/entry.shared.browser.js +211 -0
- package/dist/worker/factory.browser.d.ts +2 -0
- package/dist/worker/factory.browser.js +65 -1
- package/dist/worker/factory.node.d.ts +1 -0
- package/dist/worker/factory.node.js +3 -0
- package/dist/worker/host.d.ts +4 -2
- package/dist/worker/host.js +86 -32
- package/dist/worker/protocol.d.ts +25 -8
- package/dist/worker/protocol.js +25 -7
- package/dist/worker/shared-admission.d.ts +26 -0
- package/dist/worker/shared-admission.js +116 -0
- package/dist/worker/shared-engine.d.ts +34 -0
- package/dist/worker/shared-engine.js +194 -0
- package/package.json +21 -9
package/dist/worker/client.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ADMISSION_PROTOCOL_EPOCH, ADMISSION_STORAGE_EPOCH } from "./shared-admission.js";
|
|
2
|
+
import { observeOpenProgress, emitOpenProgress } from "../open-progress.js";
|
|
3
|
+
import { fetchTransport } from "../http-transport.js";
|
|
4
|
+
import { createWorkerConnection, createSharedWorkerConnection, openDirectLixBinding } from "#worker-factory";
|
|
2
5
|
import { ownedSnapshotRestoreChunks } from "../snapshot-restore.js";
|
|
3
6
|
import { deserializeWorkerError, serializeWorkerError, } from "./protocol.js";
|
|
4
7
|
const MAX_IDLE_WORKERS = 1;
|
|
@@ -6,7 +9,12 @@ const MAX_IDLE_WORKERS = 1;
|
|
|
6
9
|
// survives close(). Concurrent opens still receive isolated workers.
|
|
7
10
|
const idleWorkers = [];
|
|
8
11
|
export async function openLixWorker(storage, onDisposed, telemetry, server, onProgress, snapshot) {
|
|
9
|
-
|
|
12
|
+
const providerOptions = storage.kind === "jsStorage" ? storage.options : undefined;
|
|
13
|
+
const sharedKey = !snapshot && server && providerOptions && typeof providerOptions === "object"
|
|
14
|
+
&& "sharedEngineKey" in providerOptions && typeof providerOptions.sharedEngineKey === "string"
|
|
15
|
+
&& providerOptions.sharedEngineKey.startsWith("lix:opfs:") ? providerOptions.sharedEngineKey : undefined;
|
|
16
|
+
const sharedConnection = sharedKey ? createSharedWorkerConnection(`${sharedKey}:protocol-${ADMISSION_PROTOCOL_EPOCH}:storage-${ADMISSION_STORAGE_EPOCH}`) : undefined;
|
|
17
|
+
let client = sharedConnection ? new LixWorkerClient(sharedConnection, false) : idleWorkers.pop();
|
|
10
18
|
while (client?.isDisposed)
|
|
11
19
|
client = idleWorkers.pop();
|
|
12
20
|
client ??= new LixWorkerClient();
|
|
@@ -104,6 +112,20 @@ export async function pumpSnapshotToWorker(client, reader, snapshotId, open) {
|
|
|
104
112
|
}
|
|
105
113
|
/** Opens the local worker transport behind the semantic Lix binding. */
|
|
106
114
|
export async function openLixWorkerBinding(storage, onDisposed, telemetry, server, onProgress, snapshot) {
|
|
115
|
+
const progress = observeOpenProgress(onProgress);
|
|
116
|
+
const routed = server && onProgress ? { ...server,
|
|
117
|
+
transport: progress.transport(server.transport ?? fetchTransport(server.fetch)),
|
|
118
|
+
} : server;
|
|
119
|
+
try {
|
|
120
|
+
const binding = await openLixWorkerBindingInner(storage, onDisposed, telemetry, routed, onProgress ? value => emitOpenProgress(onProgress, value) : undefined, snapshot);
|
|
121
|
+
progress.complete();
|
|
122
|
+
return binding;
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
progress.stop();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async function openLixWorkerBindingInner(storage, onDisposed, telemetry, server, onProgress, snapshot) {
|
|
107
129
|
if (openDirectLixBinding) {
|
|
108
130
|
const telemetryDispatch = telemetry
|
|
109
131
|
? (span) => {
|
|
@@ -277,6 +299,8 @@ export function workerBinding(client, lease, sessionId) {
|
|
|
277
299
|
replicaRecoverySources: () => request({ kind: "replicaRecoverySources" }),
|
|
278
300
|
exportReplicaRecovery: (id) => request({ kind: "exportReplicaRecovery", id }),
|
|
279
301
|
recoverReplica: (id) => request({ kind: "recoverReplica", id }),
|
|
302
|
+
recoverReplicaWithServer: (id, server) => client.withRecoveryServer(server, (transportScope, serialized) => request({ kind: "recoverReplicaWithServer", id, server: serialized, transportScope })),
|
|
303
|
+
syncHealth: () => request({ kind: "syncHealth" }),
|
|
280
304
|
activeBranchId: () => request({ kind: "activeBranchId" }),
|
|
281
305
|
activeAccountId: () => request({ kind: "activeAccountId" }),
|
|
282
306
|
createBranch: (options) => request({ kind: "createBranch", options }),
|
|
@@ -353,7 +377,7 @@ function workerObserveBinding(request, notify, observeId) {
|
|
|
353
377
|
}
|
|
354
378
|
async function releaseWorker(client) {
|
|
355
379
|
client.endLease();
|
|
356
|
-
if (!client.isDisposed && idleWorkers.length < MAX_IDLE_WORKERS) {
|
|
380
|
+
if (client.reusable && !client.isDisposed && idleWorkers.length < MAX_IDLE_WORKERS) {
|
|
357
381
|
idleWorkers.push(client);
|
|
358
382
|
return;
|
|
359
383
|
}
|
|
@@ -361,6 +385,7 @@ async function releaseWorker(client) {
|
|
|
361
385
|
}
|
|
362
386
|
export class LixWorkerClient {
|
|
363
387
|
connection;
|
|
388
|
+
reusable;
|
|
364
389
|
nextRequestId = 1;
|
|
365
390
|
nextSnapshotInputId = 1;
|
|
366
391
|
pending = new Map();
|
|
@@ -369,12 +394,26 @@ export class LixWorkerClient {
|
|
|
369
394
|
onDisposed;
|
|
370
395
|
telemetry;
|
|
371
396
|
syncServer;
|
|
397
|
+
scopedServers = new Map();
|
|
398
|
+
nextTransportScope = 1;
|
|
399
|
+
async withRecoveryServer(server, operation) {
|
|
400
|
+
const scope = this.nextTransportScope++;
|
|
401
|
+
const runtime = { url: server.url, headers: server.headerProvider ?? server.headers, transport: server.transport };
|
|
402
|
+
this.scopedServers.set(scope, runtime);
|
|
403
|
+
try {
|
|
404
|
+
return await operation(scope, serializeSyncServer(runtime));
|
|
405
|
+
}
|
|
406
|
+
finally {
|
|
407
|
+
this.scopedServers.delete(scope);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
372
410
|
onProgress;
|
|
373
411
|
openReport;
|
|
374
412
|
syncFetchControllers = new Map();
|
|
375
413
|
syncFetchStreams = new Map();
|
|
376
|
-
constructor(connection = createWorkerConnection()) {
|
|
414
|
+
constructor(connection = createWorkerConnection(), reusable = true) {
|
|
377
415
|
this.connection = connection;
|
|
416
|
+
this.reusable = reusable;
|
|
378
417
|
connection.onMessage((message) => this.handleMessage(message));
|
|
379
418
|
connection.onFatal((error) => this.handleFatal(error));
|
|
380
419
|
}
|
|
@@ -497,10 +536,10 @@ export class LixWorkerClient {
|
|
|
497
536
|
}
|
|
498
537
|
break;
|
|
499
538
|
case "sync.headers":
|
|
500
|
-
void this.resolveSyncHeaders(message.requestId);
|
|
539
|
+
void this.resolveSyncHeaders(message.requestId, message.transportScope);
|
|
501
540
|
break;
|
|
502
541
|
case "sync.fetch":
|
|
503
|
-
void this.resolveSyncFetch(message.requestId, message.request);
|
|
542
|
+
void this.resolveSyncFetch(message.requestId, message.request, message.transportScope);
|
|
504
543
|
break;
|
|
505
544
|
case "sync.fetch.stream.pull":
|
|
506
545
|
void this.resolveSyncFetchStreamPull(message.requestId);
|
|
@@ -510,9 +549,11 @@ export class LixWorkerClient {
|
|
|
510
549
|
break;
|
|
511
550
|
}
|
|
512
551
|
}
|
|
513
|
-
async resolveSyncHeaders(requestId) {
|
|
552
|
+
async resolveSyncHeaders(requestId, transportScope) {
|
|
514
553
|
try {
|
|
515
|
-
|
|
554
|
+
if (transportScope !== undefined && !this.scopedServers.has(transportScope))
|
|
555
|
+
throw workerClosedError();
|
|
556
|
+
const source = (transportScope === undefined ? this.syncServer : this.scopedServers.get(transportScope))?.headers;
|
|
516
557
|
const headers = typeof source === "function" ? await source() : source;
|
|
517
558
|
this.notify({
|
|
518
559
|
kind: "sync.headers.result",
|
|
@@ -528,38 +569,33 @@ export class LixWorkerClient {
|
|
|
528
569
|
});
|
|
529
570
|
}
|
|
530
571
|
}
|
|
531
|
-
async resolveSyncFetch(requestId, request) {
|
|
532
|
-
const
|
|
533
|
-
if (!
|
|
534
|
-
this.notify({
|
|
535
|
-
kind: "sync.fetch.result",
|
|
536
|
-
requestId,
|
|
537
|
-
result: {
|
|
538
|
-
ok: false,
|
|
539
|
-
error: serializeWorkerError(new Error("Sync fetch bridge is unavailable")),
|
|
540
|
-
},
|
|
541
|
-
});
|
|
572
|
+
async resolveSyncFetch(requestId, request, transportScope) {
|
|
573
|
+
const server = transportScope === undefined ? this.syncServer : this.scopedServers.get(transportScope);
|
|
574
|
+
if (!server) {
|
|
575
|
+
this.notify({ kind: "sync.fetch.result", requestId, result: { ok: false, error: serializeWorkerError(workerClosedError()) } });
|
|
542
576
|
return;
|
|
543
577
|
}
|
|
578
|
+
const transport = server.transport ?? fetchTransport(server.fetch);
|
|
544
579
|
const controller = new AbortController();
|
|
545
580
|
this.syncFetchControllers.set(requestId, controller);
|
|
546
581
|
let retainedStream = false;
|
|
547
582
|
try {
|
|
548
|
-
const response = await
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
583
|
+
const response = await transport({ url: request.url, response: request.response, init: {
|
|
584
|
+
method: request.method,
|
|
585
|
+
headers: request.headers,
|
|
586
|
+
body: typeof request.body === "string"
|
|
587
|
+
? request.body
|
|
588
|
+
: request.body?.slice().buffer,
|
|
589
|
+
credentials: request.credentials,
|
|
590
|
+
signal: controller.signal,
|
|
591
|
+
cache: request.cache, redirect: request.redirect,
|
|
592
|
+
} });
|
|
557
593
|
if (this.syncFetchControllers.get(requestId) !== controller ||
|
|
558
594
|
controller.signal.aborted) {
|
|
559
595
|
await response.body?.cancel().catch(() => undefined);
|
|
560
596
|
return;
|
|
561
597
|
}
|
|
562
|
-
if (request.
|
|
598
|
+
if (request.response.mode === "streaming") {
|
|
563
599
|
this.syncFetchStreams.set(requestId, response.body?.getReader());
|
|
564
600
|
retainedStream = true;
|
|
565
601
|
this.notify({
|
|
@@ -577,7 +613,7 @@ export class LixWorkerClient {
|
|
|
577
613
|
});
|
|
578
614
|
return;
|
|
579
615
|
}
|
|
580
|
-
const body = await
|
|
616
|
+
const body = new Uint8Array(await response.arrayBuffer());
|
|
581
617
|
this.notify({
|
|
582
618
|
kind: "sync.fetch.result",
|
|
583
619
|
requestId,
|
|
@@ -593,6 +629,8 @@ export class LixWorkerClient {
|
|
|
593
629
|
});
|
|
594
630
|
}
|
|
595
631
|
catch (error) {
|
|
632
|
+
if (isSyncResponseTooLarge(error))
|
|
633
|
+
controller.abort(error);
|
|
596
634
|
if (!controller.signal.aborted || isSyncResponseTooLarge(error)) {
|
|
597
635
|
this.notify({
|
|
598
636
|
kind: "sync.fetch.result",
|
|
@@ -674,58 +712,10 @@ export class LixWorkerClient {
|
|
|
674
712
|
this.connection.unref();
|
|
675
713
|
}
|
|
676
714
|
}
|
|
677
|
-
async function readSyncResponseBody(response, limit, controller) {
|
|
678
|
-
if (!Number.isSafeInteger(limit) || limit <= 0) {
|
|
679
|
-
throw new TypeError("Browser sync fetch has no valid response limit");
|
|
680
|
-
}
|
|
681
|
-
const declaredLength = Number(response.headers.get("content-length"));
|
|
682
|
-
if (Number.isFinite(declaredLength) && declaredLength > limit) {
|
|
683
|
-
const error = syncResponseTooLarge(limit);
|
|
684
|
-
controller.abort(error);
|
|
685
|
-
throw error;
|
|
686
|
-
}
|
|
687
|
-
const stream = response.body;
|
|
688
|
-
if (!stream)
|
|
689
|
-
return new Uint8Array();
|
|
690
|
-
const reader = stream.getReader();
|
|
691
|
-
const chunks = [];
|
|
692
|
-
let total = 0;
|
|
693
|
-
try {
|
|
694
|
-
while (true) {
|
|
695
|
-
const { done, value } = await reader.read();
|
|
696
|
-
if (done)
|
|
697
|
-
break;
|
|
698
|
-
total += value.byteLength;
|
|
699
|
-
if (total > limit) {
|
|
700
|
-
const error = syncResponseTooLarge(limit);
|
|
701
|
-
controller.abort(error);
|
|
702
|
-
await reader.cancel(error).catch(() => undefined);
|
|
703
|
-
throw error;
|
|
704
|
-
}
|
|
705
|
-
chunks.push(value);
|
|
706
|
-
}
|
|
707
|
-
}
|
|
708
|
-
finally {
|
|
709
|
-
reader.releaseLock();
|
|
710
|
-
}
|
|
711
|
-
const body = new Uint8Array(total);
|
|
712
|
-
let offset = 0;
|
|
713
|
-
for (const chunk of chunks) {
|
|
714
|
-
body.set(chunk, offset);
|
|
715
|
-
offset += chunk.byteLength;
|
|
716
|
-
}
|
|
717
|
-
return body;
|
|
718
|
-
}
|
|
719
|
-
function syncResponseTooLarge(limit) {
|
|
720
|
-
const error = new Error(`sync fetch response exceeds ${limit} bytes`);
|
|
721
|
-
error.name = "LixError";
|
|
722
|
-
error.code = "LIX_ERROR_SYNC_RESPONSE_TOO_LARGE";
|
|
723
|
-
return error;
|
|
724
|
-
}
|
|
725
715
|
function isSyncResponseTooLarge(error) {
|
|
726
716
|
return (error instanceof Error &&
|
|
727
717
|
error.code ===
|
|
728
|
-
"
|
|
718
|
+
"LIX_TRANSPORT_RESPONSE_LIMIT");
|
|
729
719
|
}
|
|
730
720
|
function workerClosedError() {
|
|
731
721
|
const error = new Error("Lix worker is closed");
|
|
@@ -742,7 +732,6 @@ function serializeSyncServer(server) {
|
|
|
742
732
|
? undefined
|
|
743
733
|
: headerEntries(server.headers),
|
|
744
734
|
dynamicHeaders: typeof server.headers === "function",
|
|
745
|
-
customFetch: server.fetch !== undefined,
|
|
746
735
|
};
|
|
747
736
|
}
|
|
748
737
|
function headerEntries(headers) {
|
|
@@ -758,7 +747,7 @@ async function resolveDirectSyncServer(server) {
|
|
|
758
747
|
return {
|
|
759
748
|
url: new URL(server.url).toString(),
|
|
760
749
|
headers: headerEntries(headers),
|
|
761
|
-
|
|
750
|
+
transport: server.transport ?? (server.fetch ? fetchTransport(server.fetch) : undefined),
|
|
762
751
|
};
|
|
763
752
|
}
|
|
764
753
|
export async function hostedLixWorkerOperation(operation) {
|
|
@@ -771,3 +760,28 @@ export async function hostedLixWorkerOperation(operation) {
|
|
|
771
760
|
await client.terminate();
|
|
772
761
|
}
|
|
773
762
|
}
|
|
763
|
+
export async function convertReplicaWorkerOperation(storage, server, branchId) {
|
|
764
|
+
const providerOptions = storage.kind === "jsStorage" ? storage.options : undefined;
|
|
765
|
+
const sharedKey = providerOptions && typeof providerOptions === "object"
|
|
766
|
+
&& "sharedEngineKey" in providerOptions && typeof providerOptions.sharedEngineKey === "string"
|
|
767
|
+
&& providerOptions.sharedEngineKey.startsWith("lix:opfs:") ? providerOptions.sharedEngineKey : undefined;
|
|
768
|
+
const connection = sharedKey ? createSharedWorkerConnection(`${sharedKey}:protocol-${ADMISSION_PROTOCOL_EPOCH}:storage-${ADMISSION_STORAGE_EPOCH}`) : undefined;
|
|
769
|
+
const client = connection ? new LixWorkerClient(connection, false) : new LixWorkerClient();
|
|
770
|
+
client.beginLease(undefined, undefined, server);
|
|
771
|
+
try {
|
|
772
|
+
await client.request({ kind: "replica.convert", storage, server: serializeSyncServer(server), branchId }, 0);
|
|
773
|
+
}
|
|
774
|
+
finally {
|
|
775
|
+
await client.terminate();
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
export async function retryReplicaMigrationCleanupWorkerOperation(storage, server) {
|
|
779
|
+
const client = new LixWorkerClient();
|
|
780
|
+
client.beginLease(undefined, undefined, server);
|
|
781
|
+
try {
|
|
782
|
+
return await client.request({ kind: "replica.cleanup", storage, server: serializeSyncServer(server) }, 0);
|
|
783
|
+
}
|
|
784
|
+
finally {
|
|
785
|
+
await client.terminate();
|
|
786
|
+
}
|
|
787
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type AdmissionIdentity } from "./shared-admission.js";
|
|
2
|
+
export interface DurableLocalAdmissionStore {
|
|
3
|
+
get(key: string): Promise<unknown>;
|
|
4
|
+
put(key: string, value: unknown): Promise<void>;
|
|
5
|
+
delete(key: string): Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
/** Durable local routing evidence only. Never grants or restores a remote lease. */
|
|
8
|
+
export declare class DurableLocalAdmission {
|
|
9
|
+
private readonly scope;
|
|
10
|
+
private readonly store;
|
|
11
|
+
private readonly url;
|
|
12
|
+
private readonly repositoryId;
|
|
13
|
+
constructor(scope: string, url: string, store?: DurableLocalAdmissionStore);
|
|
14
|
+
private key;
|
|
15
|
+
private validIdentity;
|
|
16
|
+
read(headers: [string, string][]): Promise<AdmissionIdentity | undefined>;
|
|
17
|
+
/** Call only after actual storage/account validation, never after metadata admission alone. */
|
|
18
|
+
record(headers: [string, string][], identity: AdmissionIdentity): Promise<void>;
|
|
19
|
+
remove(headers: [string, string][]): Promise<void>;
|
|
20
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { ADMISSION_PROTOCOL_EPOCH, ADMISSION_STORAGE_EPOCH } from "./shared-admission.js";
|
|
2
|
+
const anonymous = "00000000-0000-7000-8000-000000000002";
|
|
3
|
+
const databaseName = "lix-local-admission-v1";
|
|
4
|
+
const storeName = "proofs";
|
|
5
|
+
/** Each operation closes its connection and resolves only after transaction commit. */
|
|
6
|
+
class IndexedDbProofStore {
|
|
7
|
+
async operation(key, mode, value) {
|
|
8
|
+
const db = await new Promise((resolve, reject) => {
|
|
9
|
+
const request = indexedDB.open(databaseName, 1);
|
|
10
|
+
request.onupgradeneeded = () => request.result.createObjectStore(storeName);
|
|
11
|
+
request.onsuccess = () => resolve(request.result);
|
|
12
|
+
request.onerror = () => reject(request.error);
|
|
13
|
+
});
|
|
14
|
+
try {
|
|
15
|
+
return await new Promise((resolve, reject) => {
|
|
16
|
+
const transaction = db.transaction(storeName, mode === "get" ? "readonly" : "readwrite");
|
|
17
|
+
const store = transaction.objectStore(storeName);
|
|
18
|
+
const request = mode === "get" ? store.get(key) : mode === "put" ? store.put(value, key) : store.delete(key);
|
|
19
|
+
transaction.oncomplete = () => resolve(request.result);
|
|
20
|
+
transaction.onabort = () => reject(transaction.error ?? new Error("Local admission transaction aborted"));
|
|
21
|
+
transaction.onerror = () => reject(transaction.error ?? new Error("Local admission transaction failed"));
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
db.close();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
get(key) { return this.operation(key, "get"); }
|
|
29
|
+
async put(key, value) { await this.operation(key, "put", value); }
|
|
30
|
+
async delete(key) { await this.operation(key, "delete"); }
|
|
31
|
+
}
|
|
32
|
+
/** Durable local routing evidence only. Never grants or restores a remote lease. */
|
|
33
|
+
export class DurableLocalAdmission {
|
|
34
|
+
scope;
|
|
35
|
+
store;
|
|
36
|
+
url;
|
|
37
|
+
repositoryId;
|
|
38
|
+
constructor(scope, url, store = new IndexedDbProofStore()) {
|
|
39
|
+
this.scope = scope;
|
|
40
|
+
this.store = store;
|
|
41
|
+
const parsed = new URL(url);
|
|
42
|
+
const id = parsed.pathname.match(/^\/lix\/([0-9a-f-]{36})\/?$/i)?.[1];
|
|
43
|
+
if (!scope || scope.length > 4096 || !id || !["https:", "http:"].includes(parsed.protocol) || parsed.search || parsed.hash || parsed.username || parsed.password) {
|
|
44
|
+
throw new Error("Invalid durable local admission scope");
|
|
45
|
+
}
|
|
46
|
+
parsed.pathname = `/lix/${id}`;
|
|
47
|
+
this.url = parsed.toString();
|
|
48
|
+
this.repositoryId = id;
|
|
49
|
+
}
|
|
50
|
+
async key(headers) {
|
|
51
|
+
const entries = [];
|
|
52
|
+
new Headers(headers).forEach((value, name) => entries.push([name, value]));
|
|
53
|
+
const bytes = new TextEncoder().encode(JSON.stringify([1, this.scope, this.url, entries, ADMISSION_PROTOCOL_EPOCH, ADMISSION_STORAGE_EPOCH]));
|
|
54
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
55
|
+
return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, "0")).join("");
|
|
56
|
+
}
|
|
57
|
+
validIdentity(value) {
|
|
58
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
59
|
+
return false;
|
|
60
|
+
const v = value;
|
|
61
|
+
return Object.keys(v).sort().join(",") === "principalId,protocolEpoch,repositoryId,storageEpoch" &&
|
|
62
|
+
v.repositoryId === this.repositoryId && typeof v.principalId === "string" && /^[\x21-\x7e]{1,255}$/.test(v.principalId) &&
|
|
63
|
+
v.protocolEpoch === ADMISSION_PROTOCOL_EPOCH && v.storageEpoch === ADMISSION_STORAGE_EPOCH;
|
|
64
|
+
}
|
|
65
|
+
async read(headers) {
|
|
66
|
+
const key = await this.key(headers);
|
|
67
|
+
const value = await this.store.get(key);
|
|
68
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
69
|
+
return undefined;
|
|
70
|
+
const record = value;
|
|
71
|
+
if (Object.keys(record).sort().join(",") !== "identity,key,schemaVersion" || record.schemaVersion !== 1 || record.key !== key || !this.validIdentity(record.identity))
|
|
72
|
+
return undefined;
|
|
73
|
+
if (!new Headers(headers).get("authorization") && record.identity.principalId !== anonymous)
|
|
74
|
+
return undefined;
|
|
75
|
+
return { ...record.identity };
|
|
76
|
+
}
|
|
77
|
+
/** Call only after actual storage/account validation, never after metadata admission alone. */
|
|
78
|
+
async record(headers, identity) {
|
|
79
|
+
if (!this.validIdentity(identity))
|
|
80
|
+
throw new Error("Invalid durable local admission identity");
|
|
81
|
+
if (!new Headers(headers).get("authorization") && identity.principalId !== anonymous)
|
|
82
|
+
return;
|
|
83
|
+
const key = await this.key(headers);
|
|
84
|
+
await this.store.put(key, { schemaVersion: 1, key, identity: { ...identity } });
|
|
85
|
+
}
|
|
86
|
+
async remove(headers) { await this.store.delete(await this.key(headers)); }
|
|
87
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/// <reference lib="webworker" />
|
|
2
|
+
import { openLixBinding, convertReplicaBinding } from "#binding";
|
|
3
|
+
import { fetchTransport, HttpTransportError } from "../http-transport.js";
|
|
4
|
+
import { SharedAdmissionCache, requestAdmission, sharedCredentialKey, sameAdmission, } from "./shared-admission.js";
|
|
5
|
+
import { DurableLocalAdmission } from "./durable-local-admission.js";
|
|
6
|
+
import { startWorkerHost } from "./host.js";
|
|
7
|
+
import { SharedEngineOwner } from "./shared-engine.js";
|
|
8
|
+
import { serializeWorkerError } from "./protocol.js";
|
|
9
|
+
const scope = globalThis;
|
|
10
|
+
let owner;
|
|
11
|
+
let configuration;
|
|
12
|
+
// Same-origin callers own the local store. Durable routing proofs can reopen
|
|
13
|
+
// its cached data after worker shutdown, but never authorize remote requests.
|
|
14
|
+
// Raw credentials remain in memory only.
|
|
15
|
+
const admitted = new SharedAdmissionCache();
|
|
16
|
+
let rootIdentity;
|
|
17
|
+
scope.onconnect = (event) => {
|
|
18
|
+
const port = event.ports[0];
|
|
19
|
+
let client;
|
|
20
|
+
let disconnected = false;
|
|
21
|
+
let input;
|
|
22
|
+
const prepareClient = async (...args) => {
|
|
23
|
+
const [storage, telemetry, parent, server, progress, snapshot] = args;
|
|
24
|
+
if (!server || snapshot)
|
|
25
|
+
throw new Error("Shared partial engines require a server and existing storage");
|
|
26
|
+
const config = JSON.stringify([storage, server.url]);
|
|
27
|
+
if (configuration !== undefined && config !== configuration)
|
|
28
|
+
throw new Error("Shared engine configuration mismatch");
|
|
29
|
+
configuration = config;
|
|
30
|
+
const raw = server;
|
|
31
|
+
const readHeaders = async () => raw.headerProvider ? await raw.headerProvider() : raw.headers;
|
|
32
|
+
let verifiedKey;
|
|
33
|
+
let verifiedGeneration;
|
|
34
|
+
let candidateIdentity;
|
|
35
|
+
let candidateHeaders;
|
|
36
|
+
let candidateOnline = false;
|
|
37
|
+
let candidateGeneration = 0;
|
|
38
|
+
let candidateCredentialGeneration = 0;
|
|
39
|
+
const providerOptions = storage.kind === "jsStorage" ? storage.options : undefined;
|
|
40
|
+
const physicalScope = providerOptions && typeof providerOptions === "object" &&
|
|
41
|
+
"sharedEngineKey" in providerOptions && typeof providerOptions.sharedEngineKey === "string"
|
|
42
|
+
? providerOptions.sharedEngineKey : undefined;
|
|
43
|
+
if (!physicalScope?.startsWith("lix:opfs:"))
|
|
44
|
+
throw new Error("Missing physical shared storage identity");
|
|
45
|
+
const localAdmission = new DurableLocalAdmission(physicalScope, raw.url);
|
|
46
|
+
const transport = raw.transport ?? fetchTransport();
|
|
47
|
+
const authenticate = async (headers, allowOffline) => {
|
|
48
|
+
const credentialGeneration = admitted.generation(raw.url, headers);
|
|
49
|
+
let result;
|
|
50
|
+
try {
|
|
51
|
+
result = await admitted.verify(raw.url, headers, rootIdentity, () => requestAdmission(raw.url, headers, transport), allowOffline);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
const code = error?.code;
|
|
55
|
+
if (code === "LIX_ADMISSION_AUTH_REJECTED") {
|
|
56
|
+
if (candidateHeaders && sharedCredentialKey(raw.url, candidateHeaders) === sharedCredentialKey(raw.url, headers)) {
|
|
57
|
+
candidateOnline = false;
|
|
58
|
+
candidateGeneration++;
|
|
59
|
+
}
|
|
60
|
+
admitted.remove(raw.url, headers);
|
|
61
|
+
await localAdmission.remove(headers).catch(() => undefined);
|
|
62
|
+
}
|
|
63
|
+
if (!allowOffline || code !== "LIX_IDENTITY_UNVERIFIED_OFFLINE")
|
|
64
|
+
throw error;
|
|
65
|
+
const local = await localAdmission.read(headers).catch(() => undefined);
|
|
66
|
+
if (!local)
|
|
67
|
+
throw error;
|
|
68
|
+
if (rootIdentity && !sameAdmission(local, rootIdentity)) {
|
|
69
|
+
throw new HttpTransportError("LIX_SHARED_ENGINE_IDENTITY_MISMATCH", "Cached local repository/account does not match this owner");
|
|
70
|
+
}
|
|
71
|
+
result = { identity: local, online: false };
|
|
72
|
+
}
|
|
73
|
+
if (admitted.generation(raw.url, headers) !== credentialGeneration) {
|
|
74
|
+
throw new HttpTransportError("LIX_ADMISSION_AUTH_REJECTED", "Credentials were rejected while local admission was in flight");
|
|
75
|
+
}
|
|
76
|
+
if (result.online) {
|
|
77
|
+
verifiedKey = sharedCredentialKey(raw.url, headers);
|
|
78
|
+
verifiedGeneration = credentialGeneration;
|
|
79
|
+
}
|
|
80
|
+
candidateCredentialGeneration = credentialGeneration;
|
|
81
|
+
candidateGeneration++;
|
|
82
|
+
candidateIdentity = result.identity;
|
|
83
|
+
candidateHeaders = headers.map(([name, value]) => [name, value]);
|
|
84
|
+
candidateOnline = result.online;
|
|
85
|
+
return result;
|
|
86
|
+
};
|
|
87
|
+
const routed = {
|
|
88
|
+
...raw,
|
|
89
|
+
transport: async (request) => {
|
|
90
|
+
try {
|
|
91
|
+
const response = await transport(request);
|
|
92
|
+
return response;
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
verifiedKey = undefined;
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
headerProvider: async () => {
|
|
100
|
+
const headers = await readHeaders();
|
|
101
|
+
if (sharedCredentialKey(raw.url, headers) !== verifiedKey || admitted.generation(raw.url, headers) !== verifiedGeneration) {
|
|
102
|
+
// Failure only suspends this remote lease; local sessions survive.
|
|
103
|
+
verifiedKey = undefined;
|
|
104
|
+
await authenticate(headers, false);
|
|
105
|
+
}
|
|
106
|
+
return headers;
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
if (!owner) {
|
|
110
|
+
owner = new SharedEngineOwner(async (transport, backgroundTelemetry, opener) => {
|
|
111
|
+
return openLixBinding(storage, backgroundTelemetry, opener.parent, transport, opener.progress);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
client = {
|
|
115
|
+
server: routed,
|
|
116
|
+
isDisconnected: () => disconnected,
|
|
117
|
+
telemetry,
|
|
118
|
+
parent,
|
|
119
|
+
progress,
|
|
120
|
+
rejectCredentials: async (headers) => {
|
|
121
|
+
const rejectedKey = sharedCredentialKey(raw.url, headers);
|
|
122
|
+
if (verifiedKey === rejectedKey)
|
|
123
|
+
verifiedKey = undefined;
|
|
124
|
+
if (candidateHeaders && sharedCredentialKey(raw.url, candidateHeaders) === rejectedKey) {
|
|
125
|
+
candidateOnline = false;
|
|
126
|
+
candidateGeneration++;
|
|
127
|
+
}
|
|
128
|
+
admitted.remove(raw.url, headers);
|
|
129
|
+
await localAdmission.remove(headers).catch(() => undefined);
|
|
130
|
+
},
|
|
131
|
+
commitIdentity: async () => {
|
|
132
|
+
if (!candidateIdentity)
|
|
133
|
+
throw new Error("Missing verified owner identity");
|
|
134
|
+
rootIdentity ??= candidateIdentity;
|
|
135
|
+
if (candidateOnline && candidateHeaders) {
|
|
136
|
+
// The owner has checked the actual stored account before this call.
|
|
137
|
+
// Failure to cache only disables later offline reopening.
|
|
138
|
+
const generation = candidateGeneration;
|
|
139
|
+
const headers = candidateHeaders;
|
|
140
|
+
const identity = candidateIdentity;
|
|
141
|
+
await admitted.persistLocal(raw.url, headers, candidateCredentialGeneration, () => localAdmission.record(headers, identity), () => localAdmission.remove(headers)).catch(() => undefined);
|
|
142
|
+
// A rejection during the asynchronous write must not resurrect its proof.
|
|
143
|
+
if (generation !== candidateGeneration)
|
|
144
|
+
await localAdmission.remove(headers).catch(() => undefined);
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
verifyIdentity: async () => {
|
|
148
|
+
const headers = await readHeaders();
|
|
149
|
+
const { identity, online } = await authenticate(headers, true);
|
|
150
|
+
return { authorityUrl: raw.url, accountId: identity.principalId, headers, online };
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
return { owner, client };
|
|
154
|
+
};
|
|
155
|
+
const controller = startWorkerHost({
|
|
156
|
+
postMessage: (message) => port.postMessage(message),
|
|
157
|
+
onMessage: (listener) => { input = listener; },
|
|
158
|
+
}, async (...args) => {
|
|
159
|
+
const prepared = await prepareClient(...args);
|
|
160
|
+
const binding = await prepared.owner.attach(prepared.client);
|
|
161
|
+
if (disconnected) {
|
|
162
|
+
await binding.close();
|
|
163
|
+
throw new Error("Shared engine client disconnected during open");
|
|
164
|
+
}
|
|
165
|
+
return binding;
|
|
166
|
+
}, async (storage, server, branchId) => {
|
|
167
|
+
const prepared = await prepareClient(storage, undefined, undefined, server);
|
|
168
|
+
await prepared.owner.convert(prepared.client, transport => convertReplicaBinding(storage, transport, branchId), branchId);
|
|
169
|
+
});
|
|
170
|
+
const disconnect = async () => {
|
|
171
|
+
if (disconnected)
|
|
172
|
+
return;
|
|
173
|
+
disconnected = true;
|
|
174
|
+
if (client)
|
|
175
|
+
owner?.deactivate(client);
|
|
176
|
+
let failure;
|
|
177
|
+
try {
|
|
178
|
+
await controller.close();
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
failure = error;
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
if (client)
|
|
185
|
+
await owner?.detach(client);
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
failure ??= error;
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
port.postMessage({ kind: "shared.disconnected", error: failure === undefined ? undefined : serializeWorkerError(failure) });
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
port.close();
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
port.onmessage = (event) => {
|
|
198
|
+
const message = event.data;
|
|
199
|
+
if (message?.kind === "shared.disconnect") {
|
|
200
|
+
void disconnect();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (message?.kind === "shared.clientLease") {
|
|
204
|
+
// The page holds this lock. Acquisition means it closed or crashed.
|
|
205
|
+
void navigator.locks.request(message.name, () => disconnect());
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
input?.(message);
|
|
209
|
+
};
|
|
210
|
+
port.start();
|
|
211
|
+
};
|
|
@@ -2,3 +2,5 @@ import type { LixBinding, LixStorageConfig, TelemetryDispatch, TelemetryParentCo
|
|
|
2
2
|
import type { WorkerConnection } from "./protocol.js";
|
|
3
3
|
export declare const openDirectLixBinding: undefined | ((storage: LixStorageConfig, telemetry?: TelemetryDispatch, telemetryParent?: TelemetryParentContext, server?: SyncServerBindingOptions, openProgress?: OpenProgressDispatch, snapshot?: ReadableStream<Uint8Array>) => Promise<LixBinding>);
|
|
4
4
|
export declare function createWorkerConnection(): WorkerConnection;
|
|
5
|
+
/** Package-private provider identity selects one engine, not one engine per tab. */
|
|
6
|
+
export declare function createSharedWorkerConnection(key: string): WorkerConnection;
|