@spooky-sync/core 0.0.1-canary.38 → 0.0.1-canary.381
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/AGENTS.md +91 -0
- package/README.md +59 -16
- package/dist/index.d.ts +2238 -144
- package/dist/index.js +18149 -2475
- package/dist/live.d.ts +149 -0
- package/dist/live.js +739 -0
- package/dist/otel/index.d.ts +1 -1
- package/dist/pure.d.ts +401 -0
- package/dist/pure.js +24 -0
- package/dist/sqlite-open.js +392 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +439 -0
- package/dist/sw.d.ts +207 -0
- package/dist/sw.js +393 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +477 -0
- package/dist/types.d.ts +2082 -39
- package/dist/worker-edges.js +498 -0
- package/dist/worker-impersonation.js +60 -0
- package/dist/worker-ref-tables.d.ts +48 -0
- package/dist/worker-types.d.ts +112 -0
- package/dist/worker-types.js +73 -0
- package/package.json +28 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/scripts/check-worker-entries.mjs +173 -0
- package/skills/sp00ky-core/SKILL.md +42 -8
- package/skills/sp00ky-core/references/config.md +8 -2
- package/src/boot/auth-flip.saga.test.ts +174 -0
- package/src/boot/auth-flip.saga.ts +77 -0
- package/src/boot/boot.saga.test.ts +203 -0
- package/src/boot/boot.saga.ts +146 -0
- package/src/boot/bucket-switch.saga.test.ts +191 -0
- package/src/boot/bucket-switch.saga.ts +151 -0
- package/src/boot/preload.saga.test.ts +96 -0
- package/src/boot/preload.saga.ts +45 -0
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/client/bucket-handle.test.ts +195 -0
- package/src/client/bucket-handle.ts +352 -0
- package/src/client/router.test.ts +59 -0
- package/src/client/router.ts +104 -0
- package/src/client/runtime.test.ts +319 -0
- package/src/client/runtime.ts +307 -0
- package/src/client/services.test.ts +74 -0
- package/src/client/services.ts +437 -0
- package/src/client/sp00ky-client.test.ts +428 -0
- package/src/client/sp00ky-client.ts +737 -0
- package/src/events/index.ts +3 -0
- package/src/index.ts +86 -1
- package/src/kernel/constants.test.ts +19 -0
- package/src/kernel/constants.ts +94 -0
- package/src/kernel/effects.test.ts +40 -0
- package/src/kernel/effects.ts +171 -0
- package/src/kernel/events.ts +60 -0
- package/src/kernel/interpreter.test.ts +116 -0
- package/src/kernel/interpreter.ts +148 -0
- package/src/kernel/saga.test.ts +89 -0
- package/src/kernel/saga.ts +73 -0
- package/src/live/index.test.ts +512 -0
- package/src/live/index.ts +1059 -0
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +209 -0
- package/src/modules/auth/auth.local-first.test.ts +228 -0
- package/src/modules/auth/auth.session-recheck.test.ts +140 -0
- package/src/modules/auth/events/index.ts +7 -1
- package/src/modules/auth/impersonation-banner.ts +443 -0
- package/src/modules/auth/impersonation.integration.test.ts +209 -0
- package/src/modules/auth/impersonation.test.ts +377 -0
- package/src/modules/auth/impersonation.ts +179 -0
- package/src/modules/auth/index.ts +538 -14
- package/src/modules/channel/channel.test.ts +752 -0
- package/src/modules/channel/index.ts +1153 -0
- package/src/modules/channel/webrtc.ts +472 -0
- package/src/modules/crdt/acceptance.ts +75 -0
- package/src/modules/crdt/bytes.ts +50 -0
- package/src/modules/crdt/crdt-field.ts +125 -0
- package/src/modules/crdt/crdt.test.ts +546 -0
- package/src/modules/crdt/hash.ts +103 -0
- package/src/modules/crdt/index.ts +821 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/crdt/mirror.ts +37 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +746 -93
- package/src/modules/devtools/mutations.test.ts +92 -0
- package/src/modules/devtools/mutations.ts +160 -0
- package/src/modules/devtools/notify-throttle.test.ts +166 -0
- package/src/modules/devtools/state-shape.test.ts +263 -0
- package/src/modules/devtools/storage-info.test.ts +79 -0
- package/src/modules/devtools/storage-info.ts +168 -0
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +110 -0
- package/src/modules/feature-flag/index.test.ts +298 -0
- package/src/modules/feature-flag/index.ts +316 -0
- package/src/modules/ref-tables.test.ts +93 -0
- package/src/modules/ref-tables.ts +91 -0
- package/src/modules/web-push/index.test.ts +824 -0
- package/src/modules/web-push/index.ts +1027 -0
- package/src/mutation/ingest-outage.integration.test.ts +400 -0
- package/src/mutation/jobs.test.ts +25 -0
- package/src/mutation/jobs.ts +41 -0
- package/src/mutation/mutation-id.test.ts +37 -0
- package/src/mutation/mutation-id.ts +35 -0
- package/src/mutation/pending-writes.integration.test.ts +175 -0
- package/src/mutation/push.saga.test.ts +361 -0
- package/src/mutation/push.saga.ts +245 -0
- package/src/mutation/rows.test.ts +278 -0
- package/src/mutation/rows.ts +515 -0
- package/src/mutation/tray.saga.test.ts +63 -0
- package/src/mutation/tray.saga.ts +48 -0
- package/src/mutation/write.saga.test.ts +436 -0
- package/src/mutation/write.saga.ts +384 -0
- package/src/pure.ts +66 -0
- package/src/push/types.ts +174 -0
- package/src/query/anon-query-isolation.integration.test.ts +531 -0
- package/src/query/env.test.ts +23 -0
- package/src/query/env.ts +65 -0
- package/src/query/fetch.saga.test.ts +199 -0
- package/src/query/fetch.saga.ts +120 -0
- package/src/query/hash.test.ts +14 -0
- package/src/query/hash.ts +18 -0
- package/src/query/id-set-plan.test.ts +132 -0
- package/src/query/lifecycle.saga.test.ts +274 -0
- package/src/query/lifecycle.saga.ts +186 -0
- package/src/query/list-ref-live.integration.test.ts +203 -0
- package/src/query/materialize.saga.test.ts +200 -0
- package/src/query/materialize.saga.ts +102 -0
- package/src/query/materialize.test.ts +58 -0
- package/src/query/materialize.ts +57 -0
- package/src/query/membership.saga.test.ts +514 -0
- package/src/query/membership.saga.ts +339 -0
- package/src/query/membership.test.ts +151 -0
- package/src/query/membership.ts +234 -0
- package/src/query/register.saga.test.ts +369 -0
- package/src/query/register.saga.ts +293 -0
- package/src/query/render-set.test.ts +47 -0
- package/src/query/render-set.ts +71 -0
- package/src/query/session-guard.integration.test.ts +304 -0
- package/src/query/sql.test.ts +118 -0
- package/src/query/sql.ts +174 -0
- package/src/query/window-query.test.ts +70 -0
- package/src/query/window-query.ts +208 -0
- package/src/services/blobs/blob-cache.test.ts +359 -0
- package/src/services/blobs/blob-cache.ts +603 -0
- package/src/services/blobs/blob-manifest.ts +227 -0
- package/src/services/blobs/blob-store.test.ts +77 -0
- package/src/services/blobs/blob-store.ts +359 -0
- package/src/services/blobs/blob.fixture.ts +90 -0
- package/src/services/blobs/index.ts +70 -0
- package/src/services/database/cache-engine.ts +209 -0
- package/src/services/database/connection-supervisor.test.ts +318 -0
- package/src/services/database/connection-supervisor.ts +422 -0
- package/src/services/database/database.query-timeout.test.ts +140 -0
- package/src/services/database/database.responses.test.ts +43 -0
- package/src/services/database/database.ts +151 -56
- package/src/services/database/engine-factory.ts +33 -0
- package/src/services/database/errors.ts +34 -0
- package/src/services/database/index.ts +7 -0
- package/src/services/database/landed-body-clear.integration.test.ts +236 -0
- package/src/services/database/local-migrator.integration.test.ts +110 -0
- package/src/services/database/local-migrator.ts +54 -9
- package/src/services/database/local.test.ts +150 -0
- package/src/services/database/local.ts +503 -59
- package/src/services/database/plan-render.test.ts +159 -0
- package/src/services/database/plan-render.ts +108 -0
- package/src/services/database/relation-resolver.test.ts +484 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +283 -4
- package/src/services/database/sqlite-cache-engine.test.ts +628 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1401 -0
- package/src/services/database/sqlite-column-reset.integration.test.ts +100 -0
- package/src/services/database/sqlite-column-reset.ts +91 -0
- package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
- package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
- package/src/services/database/sqlite-lock-verify.test.ts +33 -0
- package/src/services/database/sqlite-lock-verify.ts +45 -0
- package/src/services/database/sqlite-open.test.ts +150 -0
- package/src/services/database/sqlite-open.ts +164 -0
- package/src/services/database/sqlite-plan-sql.test.ts +164 -0
- package/src/services/database/sqlite-plan-sql.ts +196 -0
- package/src/services/database/sqlite-projection.test.ts +121 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +261 -0
- package/src/services/database/sqlite-select.ts +131 -0
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +224 -0
- package/src/services/database/sqlite-worker.ts +437 -0
- package/src/services/database/surql-translate.ts +417 -0
- package/src/services/database/surreal-cache-engine.ts +163 -0
- package/src/services/logger/index.test.ts +132 -0
- package/src/services/logger/index.ts +187 -6
- package/src/services/persistence/resilient.ts +8 -1
- package/src/services/persistence/surrealdb.ts +2 -2
- package/src/services/stream-processor/index.ts +801 -63
- package/src/services/stream-processor/permissions.test.ts +47 -0
- package/src/services/stream-processor/permissions.ts +53 -0
- package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
- package/src/services/stream-processor/stream-processor.prime.test.ts +234 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
- package/src/services/stream-processor/wasm-types.ts +57 -1
- package/src/services/tabs/broker-client.ts +284 -0
- package/src/services/tabs/broker.test.ts +381 -0
- package/src/services/tabs/coordinator.test.ts +386 -0
- package/src/services/tabs/coordinator.ts +646 -0
- package/src/services/tabs/fake-ports.fixture.ts +112 -0
- package/src/services/tabs/leader-locks.ts +75 -0
- package/src/services/tabs/protocol.ts +262 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +652 -0
- package/src/sp00ky.ts +5 -394
- package/src/state/client-state.ts +231 -0
- package/src/state/lifecycle.test.ts +184 -0
- package/src/state/lifecycle.ts +127 -0
- package/src/state/reducers.test.ts +337 -0
- package/src/state/reducers.ts +571 -0
- package/src/state/selectors.test.ts +203 -0
- package/src/state/selectors.ts +264 -0
- package/src/sw/index.test.ts +582 -0
- package/src/sw/index.ts +607 -0
- package/src/sw/store.ts +96 -0
- package/src/sync/connection.saga.test.ts +97 -0
- package/src/sync/connection.saga.ts +89 -0
- package/src/sync/live.saga.test.ts +229 -0
- package/src/sync/live.saga.ts +116 -0
- package/src/sync/policy.health.test.ts +56 -0
- package/src/sync/policy.test.ts +663 -0
- package/src/sync/policy.ts +515 -0
- package/src/sync/poll.saga.test.ts +51 -0
- package/src/sync/poll.saga.ts +36 -0
- package/src/sync/tabs.saga.test.ts +78 -0
- package/src/sync/tabs.saga.ts +96 -0
- package/src/testing/adapters.ts +137 -0
- package/src/testing/build.ts +63 -0
- package/src/testing/fake-services.ts +63 -0
- package/src/testing/run-pure.test.ts +92 -0
- package/src/testing/run-pure.ts +128 -0
- package/src/testing/services.ts +17 -0
- package/src/types.ts +694 -7
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/edges.ts +57 -0
- package/src/utils/error-classification.test.ts +116 -0
- package/src/utils/error-classification.ts +74 -0
- package/src/utils/index.ts +83 -6
- package/src/utils/parser.test.ts +67 -102
- package/src/utils/parser.ts +63 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/sha256.ts +15 -0
- package/src/utils/surql.ts +22 -11
- package/src/utils/utils.test.ts +3 -2
- package/tsdown.config.ts +117 -3
- package/vitest.config.ts +48 -0
- package/src/modules/cache/index.ts +0 -241
- package/src/modules/cache/types.ts +0 -19
- package/src/modules/data/data.test.ts +0 -58
- package/src/modules/data/index.ts +0 -882
- package/src/modules/sync/engine.ts +0 -173
- package/src/modules/sync/events/index.ts +0 -78
- package/src/modules/sync/index.ts +0 -3
- package/src/modules/sync/queue/index.ts +0 -2
- package/src/modules/sync/queue/queue-down.ts +0 -90
- package/src/modules/sync/queue/queue-up.ts +0 -224
- package/src/modules/sync/scheduler.ts +0 -84
- package/src/modules/sync/sync.ts +0 -422
- package/src/modules/sync/utils.test.ts +0 -311
- package/src/modules/sync/utils.ts +0 -169
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import type { Surreal, Uuid } from 'surrealdb';
|
|
2
|
+
import type { ConnectionState } from '../../types';
|
|
3
|
+
import type { Logger } from '../../services/logger/index';
|
|
4
|
+
import { parseRecordIdString } from '../../utils/index';
|
|
5
|
+
import type { ChannelMessage, ChannelTransport, PeerRef, TransportHooks } from './index';
|
|
6
|
+
import { idKey, peerKey } from './index';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The default channel transport: one WebRTC data channel per pair of tabs,
|
|
10
|
+
* set up through signaling rows in the database.
|
|
11
|
+
*
|
|
12
|
+
* Signaling (all rows of `_00_channel_signal`, read through one raw LIVE):
|
|
13
|
+
*
|
|
14
|
+
* 1. A tab that wants user U writes `hello` to U. Every tab of U hears it.
|
|
15
|
+
* 2. Of each pair of tabs, the one with the lower `user#peer` key offers.
|
|
16
|
+
* A tab that hears a hello from a higher key offers at once; one that
|
|
17
|
+
* hears it from a lower key answers with a `hello` addressed back to that
|
|
18
|
+
* tab, which then offers. So both orders of arrival connect, and nobody
|
|
19
|
+
* loops.
|
|
20
|
+
* 3. `offer` / `answer` carry the SDPs, `ice` batches of candidates
|
|
21
|
+
* (trickled, so nothing waits for gathering to finish).
|
|
22
|
+
*
|
|
23
|
+
* A tab only ever reacts to signals from users it wants itself, so a
|
|
24
|
+
* connection needs both sides to list each other. The database stamps every
|
|
25
|
+
* row's `sender`, and the offer carries the DTLS fingerprint of the
|
|
26
|
+
* connection, so the user on the other end of a data channel is proven.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export type SignalKind = 'hello' | 'offer' | 'answer' | 'ice';
|
|
30
|
+
|
|
31
|
+
export interface Signal {
|
|
32
|
+
readonly from: string;
|
|
33
|
+
readonly fromPeer: string;
|
|
34
|
+
readonly toPeer: string | null;
|
|
35
|
+
readonly kind: SignalKind;
|
|
36
|
+
readonly payload: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface OutgoingSignal {
|
|
40
|
+
readonly to: string;
|
|
41
|
+
readonly toPeer: string | null;
|
|
42
|
+
readonly fromPeer: string;
|
|
43
|
+
readonly kind: SignalKind;
|
|
44
|
+
readonly payload: string | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Where signals go and come from. The database by default. */
|
|
48
|
+
export interface SignalPort {
|
|
49
|
+
send(signal: OutgoingSignal): Promise<void>;
|
|
50
|
+
listen(self: PeerRef, cb: (signal: Signal) => void): () => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface WebRtcOptions {
|
|
54
|
+
signal: SignalPort;
|
|
55
|
+
iceServers?: RTCIceServer[];
|
|
56
|
+
logger?: Pick<Logger, 'debug' | 'warn'>;
|
|
57
|
+
/** Injectable for tests and non-browser runtimes. */
|
|
58
|
+
RTCPeerConnection?: typeof RTCPeerConnection;
|
|
59
|
+
/** Give up on one attempt after this long. Default 15000. */
|
|
60
|
+
connectTimeoutMs?: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const DEFAULT_ICE: RTCIceServer[] = [{ urls: 'stun:stun.cloudflare.com:3478' }];
|
|
64
|
+
const ICE_FLUSH_MS = 20;
|
|
65
|
+
const RETRY_BASE_MS = 2000;
|
|
66
|
+
const RETRY_MAX_MS = 30_000;
|
|
67
|
+
|
|
68
|
+
interface Link {
|
|
69
|
+
readonly ref: PeerRef;
|
|
70
|
+
readonly key: string;
|
|
71
|
+
readonly offerer: boolean;
|
|
72
|
+
/** One negotiation: answers and candidates of another are ignored. */
|
|
73
|
+
readonly sid: string;
|
|
74
|
+
readonly pc: RTCPeerConnection;
|
|
75
|
+
dc: RTCDataChannel | null;
|
|
76
|
+
open: boolean;
|
|
77
|
+
remoteSet: boolean;
|
|
78
|
+
pendingIce: RTCIceCandidateInit[];
|
|
79
|
+
outIce: RTCIceCandidateInit[];
|
|
80
|
+
iceTimer: ReturnType<typeof setTimeout> | null;
|
|
81
|
+
connectTimer: ReturnType<typeof setTimeout> | null;
|
|
82
|
+
/** Signals of this link go out in order (offer before its candidates). */
|
|
83
|
+
queue: Promise<void>;
|
|
84
|
+
closed: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const randomSid = (): string => Math.random().toString(36).slice(2, 10);
|
|
88
|
+
|
|
89
|
+
export function webrtcTransport(hooks: TransportHooks, opts: WebRtcOptions): ChannelTransport {
|
|
90
|
+
const PC = opts.RTCPeerConnection ?? (globalThis as { RTCPeerConnection?: typeof RTCPeerConnection }).RTCPeerConnection;
|
|
91
|
+
const log = opts.logger;
|
|
92
|
+
const connectTimeoutMs = opts.connectTimeoutMs ?? 15_000;
|
|
93
|
+
const links = new Map<string, Link>();
|
|
94
|
+
const retries = new Map<string, { attempts: number; timer: ReturnType<typeof setTimeout> | null }>();
|
|
95
|
+
let wanted = new Set<string>();
|
|
96
|
+
let stopListen: (() => void) | null = null;
|
|
97
|
+
let listeningAs: string | null = null;
|
|
98
|
+
let closed = false;
|
|
99
|
+
|
|
100
|
+
const selfKey = (): string | null => {
|
|
101
|
+
const self = hooks.self();
|
|
102
|
+
return self ? peerKey(self) : null;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const send = (link: Link | null, signal: Omit<OutgoingSignal, 'fromPeer'>): void => {
|
|
106
|
+
const self = hooks.self();
|
|
107
|
+
if (!self) return;
|
|
108
|
+
const out: OutgoingSignal = { ...signal, fromPeer: self.peer };
|
|
109
|
+
const run = () =>
|
|
110
|
+
opts.signal.send(out).catch((error) => {
|
|
111
|
+
log?.debug({ error, kind: signal.kind, Category: 'sp00ky-client::channel' }, 'channel signal failed');
|
|
112
|
+
});
|
|
113
|
+
if (link) link.queue = link.queue.then(run);
|
|
114
|
+
else void run();
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const dropLink = (link: Link, failed: boolean): void => {
|
|
118
|
+
if (link.closed) return;
|
|
119
|
+
link.closed = true;
|
|
120
|
+
if (link.iceTimer) clearTimeout(link.iceTimer);
|
|
121
|
+
if (link.connectTimer) clearTimeout(link.connectTimer);
|
|
122
|
+
try {
|
|
123
|
+
link.dc?.close();
|
|
124
|
+
link.pc.close();
|
|
125
|
+
} catch {
|
|
126
|
+
// already gone
|
|
127
|
+
}
|
|
128
|
+
if (links.get(link.key) === link) links.delete(link.key);
|
|
129
|
+
if (link.open) {
|
|
130
|
+
link.open = false;
|
|
131
|
+
hooks.onPeer(link.ref, false);
|
|
132
|
+
}
|
|
133
|
+
if (failed && wanted.has(link.ref.user) && !closed) {
|
|
134
|
+
const anyOpen = [...links.values()].some((l) => l.ref.user === link.ref.user && l.open);
|
|
135
|
+
if (!anyOpen) hooks.onStatus(link.ref.user, 'failed');
|
|
136
|
+
scheduleRetry(link.ref);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const scheduleRetry = (ref: PeerRef): void => {
|
|
141
|
+
const key = peerKey(ref);
|
|
142
|
+
const r = retries.get(key) ?? { attempts: 0, timer: null };
|
|
143
|
+
if (r.timer) return;
|
|
144
|
+
const delay = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** r.attempts);
|
|
145
|
+
r.attempts++;
|
|
146
|
+
r.timer = setTimeout(() => {
|
|
147
|
+
r.timer = null;
|
|
148
|
+
if (closed || !wanted.has(ref.user) || links.has(key)) return;
|
|
149
|
+
const mine = selfKey();
|
|
150
|
+
if (!mine) return;
|
|
151
|
+
if (mine < key) startOffer(ref);
|
|
152
|
+
else send(null, { to: ref.user, toPeer: ref.peer, kind: 'hello', payload: null });
|
|
153
|
+
}, delay);
|
|
154
|
+
retries.set(key, r);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const attachChannel = (link: Link, dc: RTCDataChannel): void => {
|
|
158
|
+
link.dc = dc;
|
|
159
|
+
dc.onopen = () => {
|
|
160
|
+
if (link.closed) return;
|
|
161
|
+
link.open = true;
|
|
162
|
+
if (link.connectTimer) clearTimeout(link.connectTimer);
|
|
163
|
+
link.connectTimer = null;
|
|
164
|
+
retries.delete(link.key);
|
|
165
|
+
hooks.onPeer(link.ref, true);
|
|
166
|
+
hooks.onStatus(link.ref.user, 'connected');
|
|
167
|
+
};
|
|
168
|
+
dc.onclose = () => dropLink(link, true);
|
|
169
|
+
dc.onmessage = (event) => {
|
|
170
|
+
if (typeof event.data !== 'string') return;
|
|
171
|
+
let message: unknown;
|
|
172
|
+
try {
|
|
173
|
+
message = JSON.parse(event.data);
|
|
174
|
+
} catch {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
hooks.onMessage(link.ref, message);
|
|
178
|
+
};
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const newLink = (ref: PeerRef, offerer: boolean, sid: string): Link | null => {
|
|
182
|
+
if (!PC) return null;
|
|
183
|
+
const key = peerKey(ref);
|
|
184
|
+
const prev = links.get(key);
|
|
185
|
+
if (prev) dropLink(prev, false);
|
|
186
|
+
const pc = new PC({ iceServers: opts.iceServers ?? DEFAULT_ICE });
|
|
187
|
+
const link: Link = {
|
|
188
|
+
ref,
|
|
189
|
+
key,
|
|
190
|
+
offerer,
|
|
191
|
+
sid,
|
|
192
|
+
pc,
|
|
193
|
+
dc: null,
|
|
194
|
+
open: false,
|
|
195
|
+
remoteSet: false,
|
|
196
|
+
pendingIce: [],
|
|
197
|
+
outIce: [],
|
|
198
|
+
iceTimer: null,
|
|
199
|
+
connectTimer: null,
|
|
200
|
+
queue: Promise.resolve(),
|
|
201
|
+
closed: false,
|
|
202
|
+
};
|
|
203
|
+
links.set(key, link);
|
|
204
|
+
pc.onicecandidate = (event) => {
|
|
205
|
+
if (!event.candidate || link.closed) return;
|
|
206
|
+
link.outIce.push(event.candidate.toJSON());
|
|
207
|
+
link.iceTimer ??= setTimeout(() => {
|
|
208
|
+
link.iceTimer = null;
|
|
209
|
+
const candidates = link.outIce.splice(0);
|
|
210
|
+
if (candidates.length === 0 || link.closed) return;
|
|
211
|
+
send(link, { to: ref.user, toPeer: ref.peer, kind: 'ice', payload: JSON.stringify({ sid, candidates }) });
|
|
212
|
+
}, ICE_FLUSH_MS);
|
|
213
|
+
};
|
|
214
|
+
pc.onconnectionstatechange = () => {
|
|
215
|
+
if (pc.connectionState === 'failed' || pc.connectionState === 'closed') dropLink(link, true);
|
|
216
|
+
};
|
|
217
|
+
if (!offerer) pc.ondatachannel = (event) => attachChannel(link, event.channel);
|
|
218
|
+
link.connectTimer = setTimeout(() => {
|
|
219
|
+
if (!link.open) dropLink(link, true);
|
|
220
|
+
}, connectTimeoutMs);
|
|
221
|
+
hooks.onStatus(ref.user, 'connecting');
|
|
222
|
+
return link;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const startOffer = (ref: PeerRef): void => {
|
|
226
|
+
const link = newLink(ref, true, randomSid());
|
|
227
|
+
if (!link) return;
|
|
228
|
+
attachChannel(link, link.pc.createDataChannel('sp00ky', { ordered: true }));
|
|
229
|
+
void (async () => {
|
|
230
|
+
try {
|
|
231
|
+
const offer = await link.pc.createOffer();
|
|
232
|
+
await link.pc.setLocalDescription(offer);
|
|
233
|
+
send(link, {
|
|
234
|
+
to: ref.user,
|
|
235
|
+
toPeer: ref.peer,
|
|
236
|
+
kind: 'offer',
|
|
237
|
+
payload: JSON.stringify({ sid: link.sid, sdp: link.pc.localDescription?.sdp ?? offer.sdp }),
|
|
238
|
+
});
|
|
239
|
+
} catch (error) {
|
|
240
|
+
log?.debug({ error, Category: 'sp00ky-client::channel' }, 'channel offer failed');
|
|
241
|
+
dropLink(link, true);
|
|
242
|
+
}
|
|
243
|
+
})();
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const addIce = async (link: Link, candidates: RTCIceCandidateInit[]): Promise<void> => {
|
|
247
|
+
for (const c of candidates) {
|
|
248
|
+
try {
|
|
249
|
+
await link.pc.addIceCandidate(c);
|
|
250
|
+
} catch {
|
|
251
|
+
// a candidate of a torn-down negotiation, or one the browser rejects
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const onSignal = (s: Signal): void => {
|
|
257
|
+
if (closed) return;
|
|
258
|
+
const self = hooks.self();
|
|
259
|
+
if (!self) return;
|
|
260
|
+
const from = idKey(s.from);
|
|
261
|
+
if (!from || !wanted.has(from)) return;
|
|
262
|
+
if (s.toPeer !== null && s.toPeer !== self.peer) return;
|
|
263
|
+
const ref: PeerRef = { user: from, peer: s.fromPeer };
|
|
264
|
+
const key = peerKey(ref);
|
|
265
|
+
const mine = peerKey(self);
|
|
266
|
+
if (key === mine) return;
|
|
267
|
+
const payload = parsePayload(s.payload);
|
|
268
|
+
const link = links.get(key);
|
|
269
|
+
|
|
270
|
+
switch (s.kind) {
|
|
271
|
+
case 'hello': {
|
|
272
|
+
if (link && !link.closed) return;
|
|
273
|
+
if (mine < key) startOffer(ref);
|
|
274
|
+
else if (s.toPeer === null) send(null, { to: from, toPeer: ref.peer, kind: 'hello', payload: null });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
case 'offer': {
|
|
278
|
+
if (mine < key || typeof payload?.sdp !== 'string' || typeof payload.sid !== 'string') return;
|
|
279
|
+
const answerer = newLink(ref, false, payload.sid);
|
|
280
|
+
if (!answerer) return;
|
|
281
|
+
void (async () => {
|
|
282
|
+
try {
|
|
283
|
+
await answerer.pc.setRemoteDescription({ type: 'offer', sdp: payload.sdp as string });
|
|
284
|
+
answerer.remoteSet = true;
|
|
285
|
+
await addIce(answerer, answerer.pendingIce.splice(0));
|
|
286
|
+
const answer = await answerer.pc.createAnswer();
|
|
287
|
+
await answerer.pc.setLocalDescription(answer);
|
|
288
|
+
send(answerer, {
|
|
289
|
+
to: from,
|
|
290
|
+
toPeer: ref.peer,
|
|
291
|
+
kind: 'answer',
|
|
292
|
+
payload: JSON.stringify({ sid: answerer.sid, sdp: answerer.pc.localDescription?.sdp ?? answer.sdp }),
|
|
293
|
+
});
|
|
294
|
+
} catch (error) {
|
|
295
|
+
log?.debug({ error, Category: 'sp00ky-client::channel' }, 'channel answer failed');
|
|
296
|
+
dropLink(answerer, true);
|
|
297
|
+
}
|
|
298
|
+
})();
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
case 'answer': {
|
|
302
|
+
if (!link || !link.offerer || link.remoteSet || payload?.sid !== link.sid || typeof payload.sdp !== 'string') return;
|
|
303
|
+
link.remoteSet = true;
|
|
304
|
+
void link.pc
|
|
305
|
+
.setRemoteDescription({ type: 'answer', sdp: payload.sdp })
|
|
306
|
+
.then(() => addIce(link, link.pendingIce.splice(0)))
|
|
307
|
+
.catch(() => dropLink(link, true));
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
case 'ice': {
|
|
311
|
+
if (!link || payload?.sid !== link.sid || !Array.isArray(payload.candidates)) return;
|
|
312
|
+
const candidates = payload.candidates as RTCIceCandidateInit[];
|
|
313
|
+
if (link.remoteSet) void addIce(link, candidates);
|
|
314
|
+
else link.pendingIce.push(...candidates);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const ensureListening = (): void => {
|
|
321
|
+
const self = hooks.self();
|
|
322
|
+
const as = self ? peerKey(self) : null;
|
|
323
|
+
if (as === listeningAs) return;
|
|
324
|
+
stopListen?.();
|
|
325
|
+
stopListen = null;
|
|
326
|
+
listeningAs = as;
|
|
327
|
+
if (self) stopListen = opts.signal.listen(self, onSignal);
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
supported: PC !== undefined,
|
|
332
|
+
want(users) {
|
|
333
|
+
if (closed || !PC) {
|
|
334
|
+
wanted = new Set(users);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const before = wanted;
|
|
338
|
+
wanted = new Set(users);
|
|
339
|
+
if (wanted.size > 0) ensureListening();
|
|
340
|
+
for (const link of [...links.values()]) if (!wanted.has(link.ref.user)) dropLink(link, false);
|
|
341
|
+
for (const [key, r] of retries) {
|
|
342
|
+
if (!wanted.has(key.slice(0, key.lastIndexOf('#'))) && r.timer) clearTimeout(r.timer);
|
|
343
|
+
}
|
|
344
|
+
for (const user of wanted) {
|
|
345
|
+
if (before.has(user)) continue;
|
|
346
|
+
send(null, { to: user, toPeer: null, kind: 'hello', payload: null });
|
|
347
|
+
hooks.onStatus(user, 'connecting');
|
|
348
|
+
}
|
|
349
|
+
},
|
|
350
|
+
send(to, message: ChannelMessage) {
|
|
351
|
+
const link = links.get(peerKey(to));
|
|
352
|
+
if (!link?.open || !link.dc || link.dc.readyState !== 'open') return;
|
|
353
|
+
try {
|
|
354
|
+
link.dc.send(JSON.stringify(message));
|
|
355
|
+
} catch (error) {
|
|
356
|
+
log?.debug({ error, Category: 'sp00ky-client::channel' }, 'channel send failed');
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
close() {
|
|
360
|
+
closed = true;
|
|
361
|
+
for (const r of retries.values()) if (r.timer) clearTimeout(r.timer);
|
|
362
|
+
retries.clear();
|
|
363
|
+
for (const link of [...links.values()]) dropLink(link, false);
|
|
364
|
+
stopListen?.();
|
|
365
|
+
stopListen = null;
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function parsePayload(payload: string | null): Record<string, unknown> | null {
|
|
371
|
+
if (!payload) return null;
|
|
372
|
+
try {
|
|
373
|
+
const v = JSON.parse(payload);
|
|
374
|
+
return v !== null && typeof v === 'object' ? (v as Record<string, unknown>) : null;
|
|
375
|
+
} catch {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ---- signaling over SurrealDB ----------------------------------------------------
|
|
381
|
+
|
|
382
|
+
/** The bits of the remote service signaling needs. */
|
|
383
|
+
export interface SignalRemote {
|
|
384
|
+
query<T extends unknown[]>(sql: string, vars?: Record<string, unknown>): Promise<T>;
|
|
385
|
+
getClient(): Pick<Surreal, 'liveOf'>;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export interface SignalConnection {
|
|
389
|
+
subscribe(cb: (state: ConnectionState) => void): () => void;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const SIGNAL_TABLE = '_00_channel_signal';
|
|
393
|
+
const LISTEN_RETRY_MS = 3000;
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Signals as rows of `_00_channel_signal` (generated for schemas with a
|
|
397
|
+
* `@channel` table): a CREATE to send, one raw `LIVE SELECT` on rows
|
|
398
|
+
* addressed to this user to receive. Not the outbox and not a synced query:
|
|
399
|
+
* each signal costs one round trip, which is what keeps connection setup
|
|
400
|
+
* short. The LIVE is re-armed whenever the socket reconnects.
|
|
401
|
+
*/
|
|
402
|
+
export function surrealSignal(
|
|
403
|
+
remote: SignalRemote,
|
|
404
|
+
connection: SignalConnection,
|
|
405
|
+
logger?: Pick<Logger, 'debug' | 'warn'>
|
|
406
|
+
): SignalPort {
|
|
407
|
+
return {
|
|
408
|
+
async send(s) {
|
|
409
|
+
const content: Record<string, unknown> = {
|
|
410
|
+
recipient: parseRecordIdString(s.to),
|
|
411
|
+
sender_peer: s.fromPeer,
|
|
412
|
+
kind: s.kind,
|
|
413
|
+
};
|
|
414
|
+
if (s.toPeer !== null) content.recipient_peer = s.toPeer;
|
|
415
|
+
if (s.payload !== null) content.payload = s.payload;
|
|
416
|
+
await remote.query(`CREATE ${SIGNAL_TABLE} CONTENT $c RETURN NONE`, { c: content });
|
|
417
|
+
},
|
|
418
|
+
listen(self, cb) {
|
|
419
|
+
let stopped = false;
|
|
420
|
+
let uuid: Uuid | null = null;
|
|
421
|
+
let retry: ReturnType<typeof setTimeout> | null = null;
|
|
422
|
+
let state: ConnectionState | null = null;
|
|
423
|
+
const arm = async () => {
|
|
424
|
+
if (stopped) return;
|
|
425
|
+
if (retry) clearTimeout(retry);
|
|
426
|
+
retry = null;
|
|
427
|
+
const old = uuid;
|
|
428
|
+
uuid = null;
|
|
429
|
+
if (old) void remote.query('KILL $u', { u: old }).catch(() => {});
|
|
430
|
+
try {
|
|
431
|
+
const [id] = await remote.query<[Uuid]>(`LIVE SELECT * FROM ${SIGNAL_TABLE} WHERE recipient = $me`, {
|
|
432
|
+
me: parseRecordIdString(self.user),
|
|
433
|
+
});
|
|
434
|
+
if (stopped) {
|
|
435
|
+
void remote.query('KILL $u', { u: id }).catch(() => {});
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
uuid = id;
|
|
439
|
+
const live = await remote.getClient().liveOf(id);
|
|
440
|
+
live.subscribe((message) => {
|
|
441
|
+
if (stopped || message.action !== 'CREATE') return;
|
|
442
|
+
const v = message.value as Record<string, unknown>;
|
|
443
|
+
const from = idKey(v.sender);
|
|
444
|
+
if (!from || typeof v.sender_peer !== 'string' || typeof v.kind !== 'string') return;
|
|
445
|
+
cb({
|
|
446
|
+
from,
|
|
447
|
+
fromPeer: v.sender_peer,
|
|
448
|
+
toPeer: typeof v.recipient_peer === 'string' ? v.recipient_peer : null,
|
|
449
|
+
kind: v.kind as SignalKind,
|
|
450
|
+
payload: typeof v.payload === 'string' ? v.payload : null,
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
} catch (error) {
|
|
454
|
+
logger?.debug({ error, Category: 'sp00ky-client::channel' }, 'channel signal listen failed; retrying');
|
|
455
|
+
if (!stopped) retry = setTimeout(() => void arm(), LISTEN_RETRY_MS);
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
const unsubscribe = connection.subscribe((next) => {
|
|
459
|
+
const was = state;
|
|
460
|
+
state = next;
|
|
461
|
+
if (next === 'connected' && was !== null && was !== 'connected') void arm();
|
|
462
|
+
});
|
|
463
|
+
void arm();
|
|
464
|
+
return () => {
|
|
465
|
+
stopped = true;
|
|
466
|
+
unsubscribe();
|
|
467
|
+
if (retry) clearTimeout(retry);
|
|
468
|
+
if (uuid) void remote.query('KILL $u', { u: uuid }).catch(() => {});
|
|
469
|
+
};
|
|
470
|
+
},
|
|
471
|
+
};
|
|
472
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type * as Loro from 'loro-crdt';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which edit-log rows may enter a document.
|
|
5
|
+
*
|
|
6
|
+
* Every client and the SSP fold (`packages/ssp-node/src/crdt/acceptance.rs`)
|
|
7
|
+
* apply this exact rule before importing a row, so they import the same rows
|
|
8
|
+
* and converge on the same document: a row one side accepted and another
|
|
9
|
+
* refused would leave them different forever (a CRDT never forgets an op).
|
|
10
|
+
*
|
|
11
|
+
* - the blob decodes as a Loro **update** (a snapshot could smuggle in
|
|
12
|
+
* arbitrary history from any peer);
|
|
13
|
+
* - it is no larger than the column allows;
|
|
14
|
+
* - every op in it was made by the row's `peer`, which the database already
|
|
15
|
+
* checked belongs to the row's author (`_00_crdt_peer`).
|
|
16
|
+
*
|
|
17
|
+
* Loro's decoder can trap on crafted input with a valid checksum, so a raw
|
|
18
|
+
* header check rejects non-update blobs before decoding, and decoding runs
|
|
19
|
+
* under `try`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export type Rejection =
|
|
23
|
+
| 'empty'
|
|
24
|
+
| 'too_large'
|
|
25
|
+
| 'bad_peer'
|
|
26
|
+
| 'header'
|
|
27
|
+
| 'undecodable'
|
|
28
|
+
| 'not_an_update'
|
|
29
|
+
| 'no_ops'
|
|
30
|
+
| 'foreign_peer';
|
|
31
|
+
|
|
32
|
+
export interface Accepted {
|
|
33
|
+
peer: `${number}`;
|
|
34
|
+
/** Exclusive end counter of the row's ops for `peer`. */
|
|
35
|
+
end: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type Verdict = { ok: true; accepted: Accepted } | { ok: false; reason: Rejection };
|
|
39
|
+
|
|
40
|
+
/** The largest blob a row may carry (the generated column's cap). */
|
|
41
|
+
export const MAX_DELTA_BYTES = 256 * 1024;
|
|
42
|
+
|
|
43
|
+
const PEER = /^\d{1,20}$/;
|
|
44
|
+
/** `"loro"`: every Loro blob starts with it. */
|
|
45
|
+
const MAGIC = [0x6c, 0x6f, 0x72, 0x6f];
|
|
46
|
+
/** Header bytes 20..22 (big-endian) carry the encoding mode; 4 is an update. */
|
|
47
|
+
const MODE_UPDATES = 4;
|
|
48
|
+
|
|
49
|
+
export function check(
|
|
50
|
+
loro: Pick<typeof Loro, 'decodeImportBlobMeta'>,
|
|
51
|
+
blob: Uint8Array | undefined,
|
|
52
|
+
peer: unknown,
|
|
53
|
+
maxBytes = MAX_DELTA_BYTES
|
|
54
|
+
): Verdict {
|
|
55
|
+
if (!blob || blob.length === 0) return { ok: false, reason: 'empty' };
|
|
56
|
+
if (blob.length > maxBytes) return { ok: false, reason: 'too_large' };
|
|
57
|
+
if (typeof peer !== 'string' || !PEER.test(peer)) return { ok: false, reason: 'bad_peer' };
|
|
58
|
+
if (blob.length < 22 || MAGIC.some((b, i) => blob[i] !== b)) return { ok: false, reason: 'header' };
|
|
59
|
+
if (((blob[20] << 8) | blob[21]) !== MODE_UPDATES) return { ok: false, reason: 'not_an_update' };
|
|
60
|
+
let meta: ReturnType<typeof Loro.decodeImportBlobMeta>;
|
|
61
|
+
try {
|
|
62
|
+
meta = loro.decodeImportBlobMeta(blob, true);
|
|
63
|
+
} catch {
|
|
64
|
+
return { ok: false, reason: 'undecodable' };
|
|
65
|
+
}
|
|
66
|
+
if (meta.mode !== 'update') return { ok: false, reason: 'not_an_update' };
|
|
67
|
+
const start = meta.partialStartVersionVector.toJSON();
|
|
68
|
+
const authors: Array<[string, number]> = [];
|
|
69
|
+
for (const [p, end] of meta.partialEndVersionVector.toJSON()) {
|
|
70
|
+
if (end > (start.get(p) ?? 0)) authors.push([String(p), end]);
|
|
71
|
+
}
|
|
72
|
+
if (authors.length === 0) return { ok: false, reason: 'no_ops' };
|
|
73
|
+
if (authors.length > 1 || BigInt(authors[0][0]) !== BigInt(peer)) return { ok: false, reason: 'foreign_peer' };
|
|
74
|
+
return { ok: true, accepted: { peer: peer as `${number}`, end: authors[0][1] } };
|
|
75
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Byte columns reach the client in several shapes depending on the engine and
|
|
3
|
+
* the path: `Uint8Array` (SQLite, a fresh local write), `ArrayBuffer` (the
|
|
4
|
+
* surrealdb SDK's CBOR decode), a typed-array view, or a `number[]` (bytes
|
|
5
|
+
* nested in a FLEXIBLE object on the SurrealDB-WASM engine). Normalize all of
|
|
6
|
+
* them; anything else is not bytes.
|
|
7
|
+
*/
|
|
8
|
+
export function asBytes(value: unknown): Uint8Array | undefined {
|
|
9
|
+
if (value instanceof Uint8Array) return value;
|
|
10
|
+
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
|
11
|
+
if (ArrayBuffer.isView(value)) {
|
|
12
|
+
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
13
|
+
}
|
|
14
|
+
if (Array.isArray(value) && value.length > 0 && value.every((n) => typeof n === 'number')) {
|
|
15
|
+
return Uint8Array.from(value as number[]);
|
|
16
|
+
}
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function decodeBase64(b64: string): Uint8Array {
|
|
21
|
+
if (typeof atob === 'function') {
|
|
22
|
+
const binary = atob(b64);
|
|
23
|
+
const bytes = new Uint8Array(binary.length);
|
|
24
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
25
|
+
return bytes;
|
|
26
|
+
}
|
|
27
|
+
return new Uint8Array(Buffer.from(b64, 'base64'));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function encodeBase64(bytes: Uint8Array): string {
|
|
31
|
+
if (typeof btoa === 'function') {
|
|
32
|
+
let binary = '';
|
|
33
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
34
|
+
return btoa(binary);
|
|
35
|
+
}
|
|
36
|
+
return Buffer.from(bytes).toString('base64');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A random Loro peer id below 2^53 (so it is exact as a JS number), as the
|
|
41
|
+
* decimal string Loro uses for `PeerID`. Each tab picks a new one per session
|
|
42
|
+
* and claims it in `_00_crdt_peer` before any of its edits are sent.
|
|
43
|
+
*/
|
|
44
|
+
export function randomPeer(): `${number}` {
|
|
45
|
+
const words = new Uint32Array(2);
|
|
46
|
+
crypto.getRandomValues(words);
|
|
47
|
+
const high = words[0] & 0x1fffff; // 21 bits
|
|
48
|
+
const id = high * 0x100000000 + words[1];
|
|
49
|
+
return String(id === 0 ? 1 : id) as `${number}`;
|
|
50
|
+
}
|