@doxsync/core 0.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE-APACHE +202 -0
- package/LICENSE-MIT +21 -0
- package/README.md +193 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.js +9 -0
- package/dist/node.js +19 -0
- package/dist/runtime.js +85 -0
- package/dist/wasm/doxsync.d.ts +61 -0
- package/dist/wasm/doxsync.js +520 -0
- package/dist/wasm/doxsync_bg.wasm +0 -0
- package/dist/wasm/doxsync_bg.wasm.d.ts +18 -0
- package/examples/browser.css +110 -0
- package/examples/browser.html +94 -0
- package/examples/browser.js +347 -0
- package/examples/node.mjs +19 -0
- package/package.json +35 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>doxsync · Sync playground</title>
|
|
7
|
+
<link rel="stylesheet" href="./browser.css">
|
|
8
|
+
<script type="module" src="./browser.js"></script>
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<main>
|
|
12
|
+
<header class="page-header">
|
|
13
|
+
<div>
|
|
14
|
+
<p class="brand">doxsync <span>/ browser demo</span></p>
|
|
15
|
+
<h1>Watch every change sync.</h1>
|
|
16
|
+
<p class="intro">Change the producer, watch messages travel, and see the consumer catch up.</p>
|
|
17
|
+
</div>
|
|
18
|
+
<p id="sync-state" class="sync-state" data-state="loading" role="status">Loading WASM…</p>
|
|
19
|
+
</header>
|
|
20
|
+
|
|
21
|
+
<section class="controls" aria-label="Simulation controls">
|
|
22
|
+
<div class="buttons">
|
|
23
|
+
<button id="toggle" class="primary" disabled>Pause auto updates</button>
|
|
24
|
+
<button id="step" disabled>Change once</button>
|
|
25
|
+
<button id="reset" disabled>Reset</button>
|
|
26
|
+
</div>
|
|
27
|
+
<label>Update interval
|
|
28
|
+
<select id="interval" disabled>
|
|
29
|
+
<option value="500">0.5 s</option>
|
|
30
|
+
<option value="1000">1 s</option>
|
|
31
|
+
<option value="1500" selected>1.5 s</option>
|
|
32
|
+
<option value="3000">3 s</option>
|
|
33
|
+
<option value="10000">10 s</option>
|
|
34
|
+
<option value="30000">30 s</option>
|
|
35
|
+
</select>
|
|
36
|
+
</label>
|
|
37
|
+
<label>Delivery delay
|
|
38
|
+
<span class="delay-input"><input id="delay" type="number" min="0" max="10000" step="100" value="1000" disabled><span>ms</span></span>
|
|
39
|
+
</label>
|
|
40
|
+
</section>
|
|
41
|
+
<p class="control-note">Delay changes apply to new messages. Delivery stays ordered, and queued messages continue arriving while updates are paused.</p>
|
|
42
|
+
<div id="error" class="error" role="alert" hidden></div>
|
|
43
|
+
|
|
44
|
+
<section class="documents" aria-label="Producer and consumer documents">
|
|
45
|
+
<article class="document-panel producer-panel">
|
|
46
|
+
<header class="panel-header">
|
|
47
|
+
<div><span class="role-dot"></span><h2>Producer</h2><span class="subtle">Source document</span></div>
|
|
48
|
+
<span id="producer-version" class="version">—</span>
|
|
49
|
+
</header>
|
|
50
|
+
<pre class="document-code" tabindex="0" aria-label="Current producer document"><code id="producer-document">Waiting for initialization…</code></pre>
|
|
51
|
+
<footer id="producer-change" class="document-footer">Preparing the initial document</footer>
|
|
52
|
+
</article>
|
|
53
|
+
<article class="document-panel consumer-panel">
|
|
54
|
+
<header class="panel-header">
|
|
55
|
+
<div><span class="role-dot"></span><h2>Consumer</h2><span class="subtle">Received document</span></div>
|
|
56
|
+
<span id="consumer-version" class="version">—</span>
|
|
57
|
+
</header>
|
|
58
|
+
<pre class="document-code" tabindex="0" aria-label="Current consumer document"><code id="consumer-document">Waiting for the first snapshot…</code></pre>
|
|
59
|
+
<footer id="consumer-change" class="document-footer">Updates when a message arrives</footer>
|
|
60
|
+
</article>
|
|
61
|
+
</section>
|
|
62
|
+
|
|
63
|
+
<section class="transport" aria-label="Message delivery">
|
|
64
|
+
<header class="transport-header">
|
|
65
|
+
<div><h2>Message delivery</h2><span class="subtle">Producer <span aria-hidden="true">→</span> Consumer</span></div>
|
|
66
|
+
<p id="metrics">Sent 0 · 0 B · In flight 0</p>
|
|
67
|
+
</header>
|
|
68
|
+
<div class="message-workspace">
|
|
69
|
+
<div class="message-history">
|
|
70
|
+
<div class="history-heading"><span>Message / Change</span><span>Size / Status</span></div>
|
|
71
|
+
<ol id="message-list" aria-label="Message history; select a message to inspect its raw bytes"></ol>
|
|
72
|
+
<p class="history-note">Showing the latest 60 delivered messages and all messages in flight.</p>
|
|
73
|
+
</div>
|
|
74
|
+
<section class="message-detail" aria-label="Selected message details">
|
|
75
|
+
<div class="detail-heading">
|
|
76
|
+
<h3 id="message-title">Waiting for a message</h3>
|
|
77
|
+
<label class="follow"><input id="follow" type="checkbox" checked>Follow latest</label>
|
|
78
|
+
</div>
|
|
79
|
+
<p id="message-description" class="message-description">Select a message to inspect the actual bytes sent.</p>
|
|
80
|
+
<dl id="message-facts" class="message-facts">
|
|
81
|
+
<div><dt>Size</dt><dd id="detail-size">—</dd></div>
|
|
82
|
+
<div><dt>Status</dt><dd id="detail-status">—</dd></div>
|
|
83
|
+
<div><dt>Set delay</dt><dd id="detail-delay">—</dd></div>
|
|
84
|
+
<div><dt>Elapsed</dt><dd id="detail-elapsed">—</dd></div>
|
|
85
|
+
</dl>
|
|
86
|
+
<div class="bytes-heading"><span>RAW MESSAGE · HEX</span><span>Byte offsets on the left</span></div>
|
|
87
|
+
<pre id="message-bytes" class="message-bytes" tabindex="0" aria-label="Message bytes in hexadecimal">No messages yet</pre>
|
|
88
|
+
</section>
|
|
89
|
+
</div>
|
|
90
|
+
</section>
|
|
91
|
+
<footer class="page-footer"><span>Real Rust / WASM synchronization with simulated network delivery.</span><span>Highlighted lines show the latest local update. <code>n</code> marks a bigint.</span></footer>
|
|
92
|
+
</main>
|
|
93
|
+
</body>
|
|
94
|
+
</html>
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
const $ = (id) => document.getElementById(id);
|
|
2
|
+
const controls = ["toggle", "step", "reset", "interval", "delay"].map($);
|
|
3
|
+
const historyLimit = 60;
|
|
4
|
+
let bindings;
|
|
5
|
+
let state;
|
|
6
|
+
let mutationTimer;
|
|
7
|
+
let deliveryTimer;
|
|
8
|
+
let displayTimer;
|
|
9
|
+
let loading = false;
|
|
10
|
+
|
|
11
|
+
const initialDocument = () => ({
|
|
12
|
+
title: "doxsync playground",
|
|
13
|
+
visitors: 12n,
|
|
14
|
+
online: true,
|
|
15
|
+
settings: { theme: "light", volume: 60 },
|
|
16
|
+
tags: ["rust", "wasm"],
|
|
17
|
+
});
|
|
18
|
+
const pick = (items) => items[Math.floor(Math.random() * items.length)];
|
|
19
|
+
const number = (min, max) => min + Math.floor(Math.random() * (max - min + 1));
|
|
20
|
+
const messageId = (id) => `#${String(id).padStart(4, "0")}`;
|
|
21
|
+
const bytesLabel = (size) => size < 1024 ? `${size} B` : `${(size / 1024).toFixed(1)} KiB`;
|
|
22
|
+
const delayValue = () => {
|
|
23
|
+
const value = Number($("delay").value);
|
|
24
|
+
return Math.round(Math.min(10000, Math.max(0, Number.isFinite(value) ? value : 1000)));
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Canonical display order lets the two documents be compared directly. The n
|
|
28
|
+
// suffix preserves bigint's type instead of presenting it as a JSON string.
|
|
29
|
+
function format(value, depth = 0) {
|
|
30
|
+
if (typeof value === "bigint") return `${value}n`;
|
|
31
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
32
|
+
const array = Array.isArray(value);
|
|
33
|
+
const entries = array ? value : Object.keys(value).sort();
|
|
34
|
+
if (!entries.length) return array ? "[]" : "{}";
|
|
35
|
+
const indent = " ".repeat(depth + 1);
|
|
36
|
+
const lines = entries.map((item) => indent + (array
|
|
37
|
+
? format(item, depth + 1)
|
|
38
|
+
: `${JSON.stringify(item)}: ${format(value[item], depth + 1)}`));
|
|
39
|
+
return `${array ? "[" : "{"}\n${lines.join(",\n")}\n${" ".repeat(depth)}${array ? "]" : "}"}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function renderDocument(id, text, previous = "") {
|
|
43
|
+
const oldLines = new Set(previous.split("\n"));
|
|
44
|
+
const fragment = document.createDocumentFragment();
|
|
45
|
+
for (const [index, line] of text.split("\n").entries()) {
|
|
46
|
+
const row = document.createElement("span");
|
|
47
|
+
row.className = `code-line${previous && !oldLines.has(line) ? " changed" : ""}`;
|
|
48
|
+
row.dataset.line = index + 1;
|
|
49
|
+
const tokens = /"(?:\\.|[^"\\])*"|\b(?:true|false|null)\b|-?\b\d+(?:\.\d+)?n?\b/g;
|
|
50
|
+
let cursor = 0;
|
|
51
|
+
for (const token of line.matchAll(tokens)) {
|
|
52
|
+
row.append(document.createTextNode(line.slice(cursor, token.index)));
|
|
53
|
+
const span = document.createElement("span");
|
|
54
|
+
const quoted = token[0].startsWith('"');
|
|
55
|
+
const key = quoted && line.slice(token.index + token[0].length).trimStart().startsWith(":");
|
|
56
|
+
span.className = key ? "token-key" : quoted ? "token-string"
|
|
57
|
+
: /^(true|false|null)$/.test(token[0]) ? "token-literal" : "token-number";
|
|
58
|
+
span.textContent = token[0];
|
|
59
|
+
row.append(span);
|
|
60
|
+
cursor = token.index + token[0].length;
|
|
61
|
+
}
|
|
62
|
+
row.append(document.createTextNode(line.slice(cursor) + "\n"));
|
|
63
|
+
fragment.append(row);
|
|
64
|
+
}
|
|
65
|
+
$(id).replaceChildren(fragment);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function hex(bytes) {
|
|
69
|
+
const lines = [];
|
|
70
|
+
for (let offset = 0; offset < bytes.length; offset += 16) {
|
|
71
|
+
const chunk = bytes.subarray(offset, offset + 16);
|
|
72
|
+
const values = Array.from(chunk, (byte) => byte.toString(16).padStart(2, "0")).join(" ");
|
|
73
|
+
lines.push(`${offset.toString(16).padStart(4, "0")} ${values}`);
|
|
74
|
+
}
|
|
75
|
+
return lines.join("\n");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function statusText(message, now = performance.now()) {
|
|
79
|
+
if (message.status === "failed") return "Delivery failed";
|
|
80
|
+
if (message.status === "delivered") return "Delivered";
|
|
81
|
+
return `In flight · ${Math.max(0, message.dueAt - now).toFixed(0)} ms`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function selectMessage(message) {
|
|
85
|
+
state.selected = message;
|
|
86
|
+
for (const item of state.history) {
|
|
87
|
+
item.row.setAttribute("aria-pressed", String(item === message));
|
|
88
|
+
}
|
|
89
|
+
$("message-title").textContent = `MESSAGE ${messageId(message.id)}`;
|
|
90
|
+
$("message-description").textContent = message.summary;
|
|
91
|
+
$("detail-size").textContent = `${message.bytes.length} B`;
|
|
92
|
+
$("detail-delay").textContent = `${message.delay} ms`;
|
|
93
|
+
$("message-bytes").textContent = hex(message.bytes);
|
|
94
|
+
updateTransport();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function updateTransport() {
|
|
98
|
+
if (!state) return;
|
|
99
|
+
const now = performance.now();
|
|
100
|
+
for (const message of state.queue) message.statusNode.textContent = statusText(message, now);
|
|
101
|
+
$("metrics").textContent = `Sent ${state.sent} · ${bytesLabel(state.totalBytes)} · In flight ${state.queue.length} · Unchanged ${state.unchanged}`;
|
|
102
|
+
if (!state.failed) {
|
|
103
|
+
$("sync-state").dataset.state = state.queue.length ? "pending" : "synced";
|
|
104
|
+
$("sync-state").textContent = state.queue.length
|
|
105
|
+
? `Syncing · ${state.queue.length} in flight`
|
|
106
|
+
: "Documents are in sync";
|
|
107
|
+
}
|
|
108
|
+
if (state.selected) {
|
|
109
|
+
const message = state.selected;
|
|
110
|
+
$("detail-status").textContent = message.status === "queued" ? "In flight" : statusText(message, now);
|
|
111
|
+
$("detail-elapsed").textContent = `${Math.round((message.deliveredAt ?? now) - message.sentAt)} ms`;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function pruneHistory() {
|
|
116
|
+
const delivered = state.history.filter((message) => message.status === "delivered");
|
|
117
|
+
for (const message of delivered.slice(historyLimit)) {
|
|
118
|
+
message.row.closest("li").remove();
|
|
119
|
+
state.history.splice(state.history.indexOf(message), 1);
|
|
120
|
+
if (state.selected === message) selectMessage(state.history[0]);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function scheduleDelivery() {
|
|
125
|
+
clearTimeout(deliveryTimer);
|
|
126
|
+
if (!state.queue.length || state.failed) return;
|
|
127
|
+
deliveryTimer = setTimeout(deliver, Math.max(0, state.queue[0].dueAt - performance.now()));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function deliver() {
|
|
131
|
+
try {
|
|
132
|
+
while (state.queue.length && state.queue[0].dueAt <= performance.now()) {
|
|
133
|
+
const message = state.queue[0];
|
|
134
|
+
state.consumer.consumeDiff(message.bytes);
|
|
135
|
+
const received = format(state.consumer.document());
|
|
136
|
+
if (received !== message.expected) throw new Error(`Message ${messageId(message.id)} produced a document that differs from the document at send time`);
|
|
137
|
+
renderDocument("consumer-document", received, state.consumerText);
|
|
138
|
+
state.consumerText = received;
|
|
139
|
+
$("consumer-version").textContent = messageId(message.id);
|
|
140
|
+
$("consumer-change").textContent = message.summary;
|
|
141
|
+
message.deliveredAt = performance.now();
|
|
142
|
+
message.status = "delivered";
|
|
143
|
+
message.row.dataset.status = "delivered";
|
|
144
|
+
message.statusNode.textContent = "Delivered";
|
|
145
|
+
state.queue.shift();
|
|
146
|
+
}
|
|
147
|
+
pruneHistory();
|
|
148
|
+
updateTransport();
|
|
149
|
+
scheduleDelivery();
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (state.queue[0]) {
|
|
152
|
+
state.queue[0].status = "failed";
|
|
153
|
+
state.queue[0].row.dataset.status = "failed";
|
|
154
|
+
state.queue[0].statusNode.textContent = "Delivery failed";
|
|
155
|
+
}
|
|
156
|
+
fail(error);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function send(summary) {
|
|
161
|
+
const bytes = state.producer.produceDiff();
|
|
162
|
+
if (bytes === undefined) {
|
|
163
|
+
state.unchanged++;
|
|
164
|
+
$("producer-change").textContent = "Document unchanged; no message generated";
|
|
165
|
+
updateTransport();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const now = performance.now();
|
|
169
|
+
const delay = delayValue();
|
|
170
|
+
const message = {
|
|
171
|
+
id: ++state.sent, bytes, summary, delay, sentAt: now,
|
|
172
|
+
// A shorter delay must never let a later stateful message overtake an older
|
|
173
|
+
// one. A single FIFO timer applies each complete message exactly once.
|
|
174
|
+
dueAt: Math.max(now + delay, state.queue.at(-1)?.dueAt ?? now),
|
|
175
|
+
status: "queued", expected: state.producerText,
|
|
176
|
+
};
|
|
177
|
+
const item = document.createElement("li");
|
|
178
|
+
const row = document.createElement("button");
|
|
179
|
+
row.type = "button";
|
|
180
|
+
row.className = "message-row";
|
|
181
|
+
row.dataset.status = "queued";
|
|
182
|
+
row.dataset.messageId = message.id;
|
|
183
|
+
row.setAttribute("aria-pressed", "false");
|
|
184
|
+
const id = document.createElement("span");
|
|
185
|
+
id.className = "message-id";
|
|
186
|
+
id.textContent = messageId(message.id);
|
|
187
|
+
const description = document.createElement("span");
|
|
188
|
+
description.className = "message-summary";
|
|
189
|
+
description.textContent = summary;
|
|
190
|
+
description.title = summary;
|
|
191
|
+
const facts = document.createElement("span");
|
|
192
|
+
const size = document.createElement("span");
|
|
193
|
+
size.className = "message-size";
|
|
194
|
+
size.textContent = `${bytes.length} B`;
|
|
195
|
+
const status = document.createElement("span");
|
|
196
|
+
status.className = "message-state";
|
|
197
|
+
status.textContent = statusText(message, now);
|
|
198
|
+
facts.append(size, status);
|
|
199
|
+
row.append(id, description, facts);
|
|
200
|
+
row.addEventListener("click", () => {
|
|
201
|
+
$("follow").checked = false;
|
|
202
|
+
selectMessage(message);
|
|
203
|
+
});
|
|
204
|
+
item.append(row);
|
|
205
|
+
message.row = row;
|
|
206
|
+
message.statusNode = status;
|
|
207
|
+
state.queue.push(message);
|
|
208
|
+
state.history.unshift(message);
|
|
209
|
+
state.totalBytes += bytes.length;
|
|
210
|
+
$("message-list").prepend(item);
|
|
211
|
+
$("producer-version").textContent = messageId(message.id);
|
|
212
|
+
$("producer-change").textContent = summary;
|
|
213
|
+
if ($("follow").checked || !state.selected) {
|
|
214
|
+
selectMessage(message);
|
|
215
|
+
$("message-list").scrollTop = 0;
|
|
216
|
+
}
|
|
217
|
+
updateTransport();
|
|
218
|
+
scheduleDelivery();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const mutations = [
|
|
222
|
+
(doc) => { const delta = BigInt(number(1, 5)); doc.visitors += delta; return `visitors increased by ${delta}`; },
|
|
223
|
+
(doc) => { doc.online = !doc.online; return `online → ${doc.online}`; },
|
|
224
|
+
(doc) => { doc.settings.theme = pick(["light", "dark", "system"].filter((value) => value !== doc.settings.theme)); return `settings.theme → ${doc.settings.theme}`; },
|
|
225
|
+
(doc) => { doc.settings.volume = (doc.settings.volume + number(1, 4) * 10) % 110; return `settings.volume → ${doc.settings.volume}`; },
|
|
226
|
+
(doc) => { doc.title = pick(["doxsync playground", "Hello from Rust", "Hello, WebAssembly", "Small changes, in sync"].filter((value) => value !== doc.title)); return `title → ${doc.title}`; },
|
|
227
|
+
(doc) => {
|
|
228
|
+
if (doc.tags.length >= 5 || (doc.tags.length > 1 && Math.random() < .5)) {
|
|
229
|
+
const [removed] = doc.tags.splice(number(0, doc.tags.length - 1), 1);
|
|
230
|
+
return `Removed ${removed} from tags`;
|
|
231
|
+
}
|
|
232
|
+
const tag = pick(["rust", "wasm", "browser", "sync", "binary", "js"].filter((value) => !doc.tags.includes(value)));
|
|
233
|
+
doc.tags.push(tag);
|
|
234
|
+
return `Added ${tag} to tags`;
|
|
235
|
+
},
|
|
236
|
+
(doc) => {
|
|
237
|
+
if (Object.hasOwn(doc, "note")) { delete doc.note; return "Removed the note field"; }
|
|
238
|
+
doc.note = "This message is on its way";
|
|
239
|
+
return "Added the note field";
|
|
240
|
+
},
|
|
241
|
+
() => "Document unchanged",
|
|
242
|
+
];
|
|
243
|
+
|
|
244
|
+
function mutate() {
|
|
245
|
+
if (!state || state.failed) return;
|
|
246
|
+
try {
|
|
247
|
+
// Producer owns a copy. Keep this display model in step with successfully
|
|
248
|
+
// accepted replacements; no JS implementation of the diff algorithm.
|
|
249
|
+
const next = structuredClone(state.document);
|
|
250
|
+
const summary = pick(mutations)(next);
|
|
251
|
+
state.producer.replace(next);
|
|
252
|
+
state.document = next;
|
|
253
|
+
const text = format(next);
|
|
254
|
+
if (text !== state.producerText) renderDocument("producer-document", text, state.producerText);
|
|
255
|
+
state.producerText = text;
|
|
256
|
+
send(summary);
|
|
257
|
+
} catch (error) { fail(error); }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function scheduleMutation() {
|
|
261
|
+
clearTimeout(mutationTimer);
|
|
262
|
+
if (!state.running || state.failed) return;
|
|
263
|
+
mutationTimer = setTimeout(() => { mutate(); scheduleMutation(); }, Number($("interval").value));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function stop() {
|
|
267
|
+
clearTimeout(mutationTimer);
|
|
268
|
+
clearTimeout(deliveryTimer);
|
|
269
|
+
clearInterval(displayTimer);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function dispose() {
|
|
273
|
+
stop();
|
|
274
|
+
state?.producer.free();
|
|
275
|
+
state?.consumer.free();
|
|
276
|
+
state = undefined;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function fail(error) {
|
|
280
|
+
stop();
|
|
281
|
+
if (state) { state.failed = true; state.running = false; }
|
|
282
|
+
controls.forEach((control) => { control.disabled = control.id !== "reset"; });
|
|
283
|
+
$("toggle").textContent = "Auto updates stopped";
|
|
284
|
+
$("reset").textContent = bindings ? "Reset" : "Retry loading";
|
|
285
|
+
$("error").hidden = false;
|
|
286
|
+
$("error").textContent = `${error.kind ? `[${error.kind}] ` : ""}${error.message ?? error}\n${state ? "The simulation has stopped. Reset to try again." : "Run npm run build first, then open this page through an HTTP server."}`;
|
|
287
|
+
$("sync-state").dataset.state = "error";
|
|
288
|
+
$("sync-state").textContent = "Simulation stopped";
|
|
289
|
+
updateTransport();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function reset() {
|
|
293
|
+
dispose();
|
|
294
|
+
const document = initialDocument();
|
|
295
|
+
state = {
|
|
296
|
+
document, producer: new bindings.Producer(document, [1]), consumer: new bindings.Consumer(),
|
|
297
|
+
producerText: format(document), consumerText: "", queue: [], history: [],
|
|
298
|
+
selected: undefined, sent: 0, totalBytes: 0, unchanged: 0,
|
|
299
|
+
running: true, failed: false,
|
|
300
|
+
};
|
|
301
|
+
controls.forEach((control) => { control.disabled = false; });
|
|
302
|
+
$("error").hidden = true;
|
|
303
|
+
$("toggle").textContent = "Pause auto updates";
|
|
304
|
+
$("reset").textContent = "Reset";
|
|
305
|
+
$("follow").checked = true;
|
|
306
|
+
$("message-list").replaceChildren();
|
|
307
|
+
renderDocument("producer-document", state.producerText);
|
|
308
|
+
renderDocument("consumer-document", "Waiting for the first snapshot…");
|
|
309
|
+
$("consumer-version").textContent = "—";
|
|
310
|
+
$("consumer-change").textContent = "Updates when a message arrives";
|
|
311
|
+
send("Initial snapshot");
|
|
312
|
+
scheduleMutation();
|
|
313
|
+
displayTimer = setInterval(updateTransport, 100);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function boot() {
|
|
317
|
+
if (loading) return;
|
|
318
|
+
loading = true;
|
|
319
|
+
controls.forEach((control) => { control.disabled = true; });
|
|
320
|
+
try {
|
|
321
|
+
bindings = await import("../dist/index.js");
|
|
322
|
+
await bindings.init();
|
|
323
|
+
reset();
|
|
324
|
+
} catch (error) { fail(error); }
|
|
325
|
+
finally { loading = false; }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
$("toggle").addEventListener("click", () => {
|
|
329
|
+
state.running = !state.running;
|
|
330
|
+
$("toggle").textContent = state.running ? "Pause auto updates" : "Resume auto updates";
|
|
331
|
+
scheduleMutation();
|
|
332
|
+
});
|
|
333
|
+
$("step").addEventListener("click", mutate);
|
|
334
|
+
$("reset").addEventListener("click", boot);
|
|
335
|
+
$("interval").addEventListener("change", scheduleMutation);
|
|
336
|
+
$("delay").addEventListener("change", () => {
|
|
337
|
+
$("delay").value = delayValue();
|
|
338
|
+
});
|
|
339
|
+
$("follow").addEventListener("change", () => {
|
|
340
|
+
if ($("follow").checked && state?.history.length) {
|
|
341
|
+
selectMessage(state.history[0]);
|
|
342
|
+
$("message-list").scrollTop = 0;
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
window.addEventListener("pagehide", dispose);
|
|
346
|
+
window.addEventListener("pageshow", (event) => { if (event.persisted) boot(); });
|
|
347
|
+
boot();
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { init, Producer, Consumer } from "@doxsync/core";
|
|
2
|
+
|
|
3
|
+
await init();
|
|
4
|
+
const producer = new Producer({ count: 1n, data: new Uint8Array([1, 2, 3]) }, [1]);
|
|
5
|
+
const consumer = new Consumer();
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
consumer.consumeDiff(producer.produceDiff());
|
|
9
|
+
console.log("Snapshot:", consumer.document());
|
|
10
|
+
|
|
11
|
+
producer.replace({ count: 2n, data: new Uint8Array([1, 2, 3]) });
|
|
12
|
+
const message = producer.produceDiff();
|
|
13
|
+
if (message !== undefined) consumer.consumeDiff(message);
|
|
14
|
+
console.log("Updated:", consumer.document());
|
|
15
|
+
console.log("Unchanged:", producer.produceDiff());
|
|
16
|
+
} finally {
|
|
17
|
+
producer.free();
|
|
18
|
+
consumer.free();
|
|
19
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@doxsync/core",
|
|
3
|
+
"version": "0.1.0-alpha.1",
|
|
4
|
+
"description": "JavaScript bindings for the doxsync Rust synchronization engine",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22"
|
|
8
|
+
},
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"node": "./dist/node.js",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./doxsync.wasm": "./dist/wasm/doxsync_bg.wasm"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist/",
|
|
20
|
+
"examples/",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE-MIT",
|
|
23
|
+
"LICENSE-APACHE"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "node scripts/build.mjs",
|
|
27
|
+
"prepack": "npm run build"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT OR Apache-2.0",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/PeterlitsZo/doxsync.git",
|
|
33
|
+
"directory": "doxsync-js"
|
|
34
|
+
}
|
|
35
|
+
}
|