instant_record 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +24 -0
- data/LICENSE.txt +21 -0
- data/README.md +378 -0
- data/app/controllers/instant_record/bootstraps_controller.rb +34 -0
- data/app/controllers/instant_record/events_controller.rb +59 -0
- data/app/controllers/instant_record/mutations_controller.rb +21 -0
- data/app/controllers/instant_record/records_controller.rb +46 -0
- data/app/models/instant_record/applied_mutation.rb +9 -0
- data/app/models/instant_record/change.rb +30 -0
- data/app/models/instant_record/mutation_applier.rb +133 -0
- data/config/routes.rb +7 -0
- data/db/migrate/20260721000002_create_instant_record_outbox.rb +12 -0
- data/db/migrate/20260721000003_create_instant_record_sync_metadata.rb +9 -0
- data/db/migrate/20260721000101_create_instant_record_changes.rb +13 -0
- data/db/migrate/20260721000102_create_instant_record_applied_mutations.rb +10 -0
- data/lib/generators/instant_record/install/install_generator.rb +53 -0
- data/lib/generators/instant_record/install/templates/database.js +32 -0
- data/lib/generators/instant_record/install/templates/index.html +69 -0
- data/lib/generators/instant_record/install/templates/rails.sw.js +750 -0
- data/lib/instant_record/client/notifier.rb +23 -0
- data/lib/instant_record/client/transport.rb +90 -0
- data/lib/instant_record/client.rb +465 -0
- data/lib/instant_record/configuration.rb +19 -0
- data/lib/instant_record/engine.rb +52 -0
- data/lib/instant_record/local_schema.rb +75 -0
- data/lib/instant_record/outbox_mutation.rb +24 -0
- data/lib/instant_record/pglite_compat.rb +40 -0
- data/lib/instant_record/runtime_scoped.rb +21 -0
- data/lib/instant_record/sync_metadata.rb +16 -0
- data/lib/instant_record/sync_window.rb +82 -0
- data/lib/instant_record/syncable.rb +106 -0
- data/lib/instant_record/version.rb +3 -0
- data/lib/instant_record.rb +356 -0
- data/lib/tasks/instant_record.rake +53 -0
- metadata +108 -0
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
import {
|
|
2
|
+
initRailsVM,
|
|
3
|
+
Progress,
|
|
4
|
+
registerPGliteWasmInterface,
|
|
5
|
+
RackHandler,
|
|
6
|
+
} from "wasmify-rails";
|
|
7
|
+
|
|
8
|
+
import { setupPGliteDatabase } from "./database.js";
|
|
9
|
+
|
|
10
|
+
let db = null;
|
|
11
|
+
|
|
12
|
+
const initDB = async (progress) => {
|
|
13
|
+
if (db) return db;
|
|
14
|
+
|
|
15
|
+
bootStarted();
|
|
16
|
+
const startedAt = performance.now();
|
|
17
|
+
|
|
18
|
+
progress?.updateStep("Initializing PGlite database...");
|
|
19
|
+
db = await setupPGliteDatabase();
|
|
20
|
+
progress?.updateStep("PGlite database created.");
|
|
21
|
+
|
|
22
|
+
bootPhase("pglite", { ms: since(startedAt) });
|
|
23
|
+
|
|
24
|
+
return db;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
let vm = null;
|
|
28
|
+
|
|
29
|
+
// --- Inspection and simulation ----------------------------------------------
|
|
30
|
+
// Ruby's transport reaches the network through globalThis.fetch (see
|
|
31
|
+
// Client::Transport::JsFetch), so wrapping it here is the one place that sees
|
|
32
|
+
// every request the sync loop makes to the server — and the one place that can
|
|
33
|
+
// delay or fail those requests without the sync code knowing. A page turns this
|
|
34
|
+
// on by posting instant_record.simulate; until then it is inert.
|
|
35
|
+
|
|
36
|
+
const sim = { report: false, offline: false, latencyMs: 0 };
|
|
37
|
+
|
|
38
|
+
// Kept here, not in the page: in a plain-Rails app every interaction is a
|
|
39
|
+
// navigation, which wipes any page-side buffer and races the message announcing
|
|
40
|
+
// the navigation itself. The worker outlives all of that, so a page that just
|
|
41
|
+
// loaded can ask for what it missed.
|
|
42
|
+
const REPORT_HISTORY = 150;
|
|
43
|
+
const history = [];
|
|
44
|
+
|
|
45
|
+
const report = (entry) => {
|
|
46
|
+
const stamped = { ...entry, at: Date.now() };
|
|
47
|
+
history.push(stamped);
|
|
48
|
+
if (history.length > REPORT_HISTORY) history.shift();
|
|
49
|
+
|
|
50
|
+
if (!sim.report) return;
|
|
51
|
+
|
|
52
|
+
self.clients.matchAll({ type: "window" }).then((clients) => {
|
|
53
|
+
clients.forEach((client) =>
|
|
54
|
+
client.postMessage({ type: "instant_record.debug", entry: stamped }),
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Assets arrive as a burst after every navigation. Worth knowing that the
|
|
60
|
+
// bundle serves its own asset pipeline — but once per page, as a total, not as
|
|
61
|
+
// a dozen lines that bury the sync flow. (They are re-requested each load
|
|
62
|
+
// rather than cached, which is why the burst repeats.)
|
|
63
|
+
const ASSET_BATCH_MS = 250;
|
|
64
|
+
|
|
65
|
+
let assetBatch = null;
|
|
66
|
+
|
|
67
|
+
const reportAssets = (ms) => {
|
|
68
|
+
if (!sim.report) return;
|
|
69
|
+
|
|
70
|
+
assetBatch ||= { count: 0, ms: 0, timer: null };
|
|
71
|
+
assetBatch.count += 1;
|
|
72
|
+
assetBatch.ms += ms;
|
|
73
|
+
|
|
74
|
+
clearTimeout(assetBatch.timer);
|
|
75
|
+
assetBatch.timer = setTimeout(() => {
|
|
76
|
+
const { count, ms: total } = assetBatch;
|
|
77
|
+
assetBatch = null;
|
|
78
|
+
report({
|
|
79
|
+
kind: "assets",
|
|
80
|
+
path: `${count} served from the bundle`,
|
|
81
|
+
ms: total,
|
|
82
|
+
});
|
|
83
|
+
}, ASSET_BATCH_MS);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const nativeFetch = globalThis.fetch.bind(globalThis);
|
|
87
|
+
|
|
88
|
+
globalThis.fetch = async (input, init) => {
|
|
89
|
+
const href = typeof input === "string" ? input : input.url;
|
|
90
|
+
const url = new URL(href, self.location.origin);
|
|
91
|
+
|
|
92
|
+
// Only requests to the sync server are simulated. The app's own bundle and
|
|
93
|
+
// assets are same-origin and must keep loading even while "offline" — going
|
|
94
|
+
// offline means losing the server, not the local runtime.
|
|
95
|
+
if (url.origin === self.location.origin) return nativeFetch(input, init);
|
|
96
|
+
|
|
97
|
+
const method = (
|
|
98
|
+
init?.method || (typeof input === "object" && input.method) || "GET"
|
|
99
|
+
).toUpperCase();
|
|
100
|
+
const startedAt = performance.now();
|
|
101
|
+
const elapsed = () => Math.round(performance.now() - startedAt);
|
|
102
|
+
|
|
103
|
+
if (sim.latencyMs) {
|
|
104
|
+
await new Promise((resolve) => setTimeout(resolve, sim.latencyMs));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (sim.offline) {
|
|
108
|
+
report({ kind: "sync", method, path: url.pathname, status: "offline" });
|
|
109
|
+
throw new TypeError("Failed to fetch (simulated offline)");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const response = await nativeFetch(input, init);
|
|
114
|
+
report({ kind: "sync", method, path: url.pathname, status: response.status, ms: elapsed() });
|
|
115
|
+
return response;
|
|
116
|
+
} catch (error) {
|
|
117
|
+
report({ kind: "sync", method, path: url.pathname, status: "failed", ms: elapsed() });
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// --- Carrying writes up ------------------------------------------------------
|
|
123
|
+
// No heartbeat. A sync pass runs when there is a reason for one: a local write
|
|
124
|
+
// just happened, the app just booted, or the network came back. The only reason
|
|
125
|
+
// to come back unprompted is an outbox that still has something in it, so the
|
|
126
|
+
// pass reports what is left and we retry with backoff until it is empty. An idle
|
|
127
|
+
// client makes no requests at all beyond holding the change stream open.
|
|
128
|
+
|
|
129
|
+
const DRAIN_BACKOFF_CAP_MS = 30000;
|
|
130
|
+
// How long a burst is given to finish before one pass carries all of it. Short
|
|
131
|
+
// enough to be imperceptible — the write itself already rendered locally.
|
|
132
|
+
const SYNC_QUIET_MS = 50;
|
|
133
|
+
|
|
134
|
+
let drainTimer = null;
|
|
135
|
+
let drainBaseMs = 3000; // replaced with config.sync_interval once the VM is up
|
|
136
|
+
let drainDelayMs = drainBaseMs;
|
|
137
|
+
|
|
138
|
+
const syncNow = async () => {
|
|
139
|
+
if (!vm) return;
|
|
140
|
+
|
|
141
|
+
clearTimeout(drainTimer);
|
|
142
|
+
drainTimer = null;
|
|
143
|
+
|
|
144
|
+
const startedAt = performance.now();
|
|
145
|
+
const reply = await enterVM("InstantRecord.tick_json");
|
|
146
|
+
const ms = Math.round(performance.now() - startedAt);
|
|
147
|
+
|
|
148
|
+
if (reply.error) console.warn("[instant-record] sync pass failed", reply.error);
|
|
149
|
+
|
|
150
|
+
// Nothing queued: stop. This is what makes an idle tab silent.
|
|
151
|
+
const pending = reply.pending ?? 0;
|
|
152
|
+
if (reply.ok && pending === 0) {
|
|
153
|
+
drainDelayMs = drainBaseMs;
|
|
154
|
+
report({ kind: "sync-pass", path: "outbox empty, idle", ms });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
report({
|
|
159
|
+
kind: "sync-pass",
|
|
160
|
+
path: `${pending} queued, retrying in ${Math.round(drainDelayMs / 1000)}s`,
|
|
161
|
+
status: reply.ok ? undefined : "failed",
|
|
162
|
+
ms,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
drainTimer = setTimeout(syncNow, drainDelayMs);
|
|
166
|
+
drainDelayMs = Math.min(drainDelayMs * 2, DRAIN_BACKOFF_CAP_MS);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// A fresh reason to sync resets the backoff — the previous delay was chosen for
|
|
170
|
+
// a failure that a new write or a reconnect has no bearing on.
|
|
171
|
+
//
|
|
172
|
+
// The short wait is this shim's only job here: Ruby coalesces requests that
|
|
173
|
+
// arrive while a pass is running (see InstantRecord::COALESCE_PASSES), but it
|
|
174
|
+
// cannot sleep to wait for ones that arrive *between* passes, because sleeping
|
|
175
|
+
// would block the VM. So a burst of writes settles into one pass rather than one
|
|
176
|
+
// pass each, and the batched drain then carries the whole burst in one request.
|
|
177
|
+
const syncSoon = () => {
|
|
178
|
+
drainDelayMs = drainBaseMs;
|
|
179
|
+
clearTimeout(drainTimer);
|
|
180
|
+
drainTimer = setTimeout(syncNow, SYNC_QUIET_MS);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const startSync = async () => {
|
|
184
|
+
// Boot-time window eviction runs only on cold boots. An idle-terminated
|
|
185
|
+
// worker restarting under already-open tabs (which it still controls) must
|
|
186
|
+
// not trim scrollback a reader is holding.
|
|
187
|
+
const controlled = await self.clients.matchAll({ type: "window" });
|
|
188
|
+
const coldBoot = controlled.length === 0;
|
|
189
|
+
await vm.evalAsync(`InstantRecord.start(cold_boot: ${coldBoot})`);
|
|
190
|
+
|
|
191
|
+
const seconds = parseInt(
|
|
192
|
+
(await vm.evalAsync("InstantRecord.config.sync_interval")).toString(),
|
|
193
|
+
10,
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
drainBaseMs = seconds * 1000;
|
|
197
|
+
drainDelayMs = drainBaseMs;
|
|
198
|
+
|
|
199
|
+
// The one unprompted pass: hydrates a fresh client, trims windows on a cold
|
|
200
|
+
// boot, and clears anything an earlier session left queued.
|
|
201
|
+
syncNow();
|
|
202
|
+
|
|
203
|
+
// Not awaited: holds a stream for as long as the VM lives.
|
|
204
|
+
streamChanges();
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// --- The change stream -------------------------------------------------------
|
|
208
|
+
// Ruby cannot hold a tailing stream: every chunk it awaited would suspend the
|
|
209
|
+
// single-flight guard, starving outbox drains for the whole window. That is why
|
|
210
|
+
// its own poll asks for window=0. The worker has no such problem — it is plain
|
|
211
|
+
// JavaScript — so it holds the stream here and hands batches to Ruby. Inbound
|
|
212
|
+
// delivery is push, and reopening with ?after=<cursor> replays anything missed,
|
|
213
|
+
// which is why nothing needs to poll for changes any more.
|
|
214
|
+
|
|
215
|
+
const STREAM_RETRY_MS = 2000;
|
|
216
|
+
|
|
217
|
+
let streaming = false;
|
|
218
|
+
// Going offline has to sever the connection that is already open, not just fail
|
|
219
|
+
// the next one — a held stream would otherwise keep delivering for the rest of
|
|
220
|
+
// its window and make the simulation look broken.
|
|
221
|
+
let streamAbort = null;
|
|
222
|
+
|
|
223
|
+
// Enter the VM for one guarded, JSON-returning call, retrying while a sync pass
|
|
224
|
+
// holds the single-flight guard. Every entry point the worker uses answers
|
|
225
|
+
// {busy: true} rather than re-entering.
|
|
226
|
+
const enterVM = async (expression, attempts = 8) => {
|
|
227
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
228
|
+
let reply;
|
|
229
|
+
try {
|
|
230
|
+
reply = JSON.parse((await vm.evalAsync(expression)).toString());
|
|
231
|
+
} catch (e) {
|
|
232
|
+
return { ok: false, error: String(e) };
|
|
233
|
+
}
|
|
234
|
+
if (!reply.busy) return reply;
|
|
235
|
+
await new Promise((resolve) => setTimeout(resolve, 150 * (attempt + 1)));
|
|
236
|
+
}
|
|
237
|
+
return { ok: false, busy: true };
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const runStream = async () => {
|
|
241
|
+
const endpoint = (await vm.evalAsync("InstantRecord.endpoint")).toString();
|
|
242
|
+
const cursor = (await vm.evalAsync("InstantRecord.cursor")).toString();
|
|
243
|
+
// Deliberately the wrapped fetch: the stream is a sync-server request, so
|
|
244
|
+
// "offline" must break it like any other and let the poll take over.
|
|
245
|
+
streamAbort = new AbortController();
|
|
246
|
+
const response = await fetch(`${endpoint}/events?after=${cursor}`, {
|
|
247
|
+
signal: streamAbort.signal,
|
|
248
|
+
});
|
|
249
|
+
if (!response.ok) throw new Error(`stream -> ${response.status}`);
|
|
250
|
+
|
|
251
|
+
// Also discards any half-received frame from the connection that just died.
|
|
252
|
+
await enterVM("InstantRecord.stream_opened_json");
|
|
253
|
+
streaming = true;
|
|
254
|
+
report({ kind: "stream", path: `connected, tailing from ${cursor}`, streamOpen: true });
|
|
255
|
+
|
|
256
|
+
// Reaching the server proves the network is back, which is the other reason a
|
|
257
|
+
// stalled outbox deserves an immediate retry rather than waiting out a backoff.
|
|
258
|
+
syncSoon();
|
|
259
|
+
|
|
260
|
+
const reader = response.body.getReader();
|
|
261
|
+
const decoder = new TextDecoder();
|
|
262
|
+
|
|
263
|
+
// Read bytes, hand over text. Framing the SSE wire format is Ruby's job — it
|
|
264
|
+
// already has a parser for it (Transport::SseParser), and a second one here
|
|
265
|
+
// would be an untested copy free to drift. A frame split across chunks is
|
|
266
|
+
// buffered on that side until it is whole.
|
|
267
|
+
for (;;) {
|
|
268
|
+
const { value, done } = await reader.read();
|
|
269
|
+
if (done) break;
|
|
270
|
+
|
|
271
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
272
|
+
if (!chunk) continue;
|
|
273
|
+
|
|
274
|
+
const encoded = btoa(unescape(encodeURIComponent(chunk)));
|
|
275
|
+
const consumed = await enterVM(`InstantRecord.consume_stream_b64("${encoded}")`);
|
|
276
|
+
if (!consumed.applied) continue;
|
|
277
|
+
|
|
278
|
+
report({
|
|
279
|
+
kind: "stream",
|
|
280
|
+
path: `${consumed.applied} pushed → applied, cursor ${consumed.cursor}`,
|
|
281
|
+
status: consumed.ok ? 200 : "failed",
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
// One stream at a time, reopened for as long as the VM is alive. A failure just
|
|
287
|
+
// means the tick's poll is the delivery path until the next attempt succeeds.
|
|
288
|
+
const streamChanges = async () => {
|
|
289
|
+
for (;;) {
|
|
290
|
+
if (!vm) return;
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
await runStream();
|
|
294
|
+
report({ kind: "stream", path: "window closed, reopening", streamOpen: false });
|
|
295
|
+
} catch (error) {
|
|
296
|
+
report({
|
|
297
|
+
kind: "stream",
|
|
298
|
+
path: "unavailable — polling instead",
|
|
299
|
+
status: "failed",
|
|
300
|
+
streamOpen: false,
|
|
301
|
+
});
|
|
302
|
+
await new Promise((resolve) => setTimeout(resolve, STREAM_RETRY_MS));
|
|
303
|
+
} finally {
|
|
304
|
+
if (streaming) {
|
|
305
|
+
streaming = false;
|
|
306
|
+
await enterVM("InstantRecord.stream_closed_json");
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
// History fetches enter the VM here — via a page message, never from inside a
|
|
313
|
+
// Rack request — because a nested evalAsync at an asyncify suspension point
|
|
314
|
+
// is the known crash class. Ruby's single-flight guard answers busy while a
|
|
315
|
+
// sync tick is in flight; retry briefly instead of interleaving.
|
|
316
|
+
const fetchHistory = async (request, attempts = 8) => {
|
|
317
|
+
if (!vm) return { ok: false, error: "vm not ready" };
|
|
318
|
+
|
|
319
|
+
// Base64 the request before it enters Ruby source: the base64 alphabet
|
|
320
|
+
// has none of Ruby's string-literal metacharacters, so an untrusted page
|
|
321
|
+
// message can't break out of the string or trigger #{} interpolation.
|
|
322
|
+
// Passing raw JSON here would be a code-injection sink.
|
|
323
|
+
const encoded = btoa(
|
|
324
|
+
unescape(encodeURIComponent(JSON.stringify(request))),
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
const startedAt = performance.now();
|
|
328
|
+
|
|
329
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
330
|
+
let reply;
|
|
331
|
+
try {
|
|
332
|
+
const raw = await vm.evalAsync(
|
|
333
|
+
`InstantRecord.fetch_history_b64("${encoded}")`,
|
|
334
|
+
);
|
|
335
|
+
reply = JSON.parse(raw.toString());
|
|
336
|
+
} catch (e) {
|
|
337
|
+
report({ kind: "history", path: "fetch_history", status: "failed" });
|
|
338
|
+
return { ok: false, error: String(e) };
|
|
339
|
+
}
|
|
340
|
+
if (!reply.busy) {
|
|
341
|
+
report({
|
|
342
|
+
kind: "history",
|
|
343
|
+
path: `fetch_history → ${reply.applied ?? 0} applied, more: ${!!reply.has_more}`,
|
|
344
|
+
status: reply.ok ? 200 : "failed",
|
|
345
|
+
ms: Math.round(performance.now() - startedAt),
|
|
346
|
+
});
|
|
347
|
+
return reply;
|
|
348
|
+
}
|
|
349
|
+
report({ kind: "history", path: "fetch_history → busy, retrying", status: "busy" });
|
|
350
|
+
await new Promise((resolve) => setTimeout(resolve, 150 * (attempt + 1)));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Retries exhausted while a sync pass held the VM: transient, not offline.
|
|
354
|
+
return { ok: false, busy: true, error: "sync busy" };
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
// --- The boot ledger ---------------------------------------------------------
|
|
358
|
+
// A first boot downloads ~60MB of Ruby, compiles it, opens a Postgres, boots
|
|
359
|
+
// Rails and prepares a schema. Behind one line of splash copy that wait reads as
|
|
360
|
+
// broken; itemised, it reads as expensive and understood. So each phase is
|
|
361
|
+
// timed here and announced as it lands.
|
|
362
|
+
//
|
|
363
|
+
// This is the browser's own instrumentation, not sync logic: the worker is the
|
|
364
|
+
// only place that sees the bundle arrive and the VM come up. It reports numbers
|
|
365
|
+
// only — the words belong to the page. Nothing is estimated: a figure this side
|
|
366
|
+
// cannot measure is sent as null, and the page leaves that line out.
|
|
367
|
+
|
|
368
|
+
// Only this build produces ledger messages, and it says so out loud. An edited
|
|
369
|
+
// worker can fail to reach the browser silently (the dev worker's URL never
|
|
370
|
+
// changes), so anything measuring the worker can check what it is talking to
|
|
371
|
+
// instead of assuming.
|
|
372
|
+
const LEDGER_VERSION = "boot-ledger-1";
|
|
373
|
+
|
|
374
|
+
const boot = { at: null, startedAt: null, entries: [], totalMs: null };
|
|
375
|
+
|
|
376
|
+
const announce = async (message) => {
|
|
377
|
+
// includeUncontrolled: during install this worker controls nothing yet, and
|
|
378
|
+
// the splash waiting on it is precisely the client that needs these.
|
|
379
|
+
const clients = await self.clients.matchAll({
|
|
380
|
+
includeUncontrolled: true,
|
|
381
|
+
type: "window",
|
|
382
|
+
});
|
|
383
|
+
clients.forEach((client) => client.postMessage(message));
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
// Whichever entry point gets there first owns the clock: install boots the VM,
|
|
387
|
+
// but so does the first Rack request after an idle worker was terminated.
|
|
388
|
+
const bootStarted = () => {
|
|
389
|
+
if (boot.startedAt !== null) return;
|
|
390
|
+
|
|
391
|
+
boot.at = Date.now();
|
|
392
|
+
boot.startedAt = performance.now();
|
|
393
|
+
boot.entries = [];
|
|
394
|
+
boot.totalMs = null;
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
const since = (mark) => Math.round(performance.now() - mark);
|
|
398
|
+
|
|
399
|
+
const bootPhase = (phase, detail) => {
|
|
400
|
+
const entry = { phase, ...detail };
|
|
401
|
+
boot.entries.push(entry);
|
|
402
|
+
announce({ type: "instant_record.boot_phase", entry, ledger: LEDGER_VERSION });
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const bootLedger = () => ({
|
|
406
|
+
ledger: LEDGER_VERSION,
|
|
407
|
+
build: BUILD_VERSION,
|
|
408
|
+
at: boot.at,
|
|
409
|
+
entries: boot.entries,
|
|
410
|
+
totalMs: boot.totalMs,
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
const initVM = async (progress, opts = {}) => {
|
|
414
|
+
if (vm) return vm;
|
|
415
|
+
|
|
416
|
+
bootStarted();
|
|
417
|
+
|
|
418
|
+
if (!db) {
|
|
419
|
+
await initDB(progress);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
registerPGliteWasmInterface(self, db);
|
|
423
|
+
|
|
424
|
+
let redirectConsole = true;
|
|
425
|
+
|
|
426
|
+
// Fetch with revalidation: the browser happily serves a cached app.wasm to
|
|
427
|
+
// a freshly installed service worker, pinning clients to a stale bundle
|
|
428
|
+
// after a rebuild. `no-cache` revalidates via ETag — a 304 when unchanged,
|
|
429
|
+
// fresh bytes after a deploy.
|
|
430
|
+
progress?.updateStep("Loading WebAssembly module...");
|
|
431
|
+
const wasmUrl = new URL("/app.wasm", self.location.origin).href;
|
|
432
|
+
const requestedAt = performance.now();
|
|
433
|
+
const response = await fetch(wasmUrl, { cache: "no-cache" });
|
|
434
|
+
|
|
435
|
+
// Count the bytes on their way into the compiler rather than trusting a size
|
|
436
|
+
// written down at build time — that number drifts, this one cannot. The
|
|
437
|
+
// stream still feeds compileStreaming, so compiling continues to overlap the
|
|
438
|
+
// download and the transform costs nothing measurable.
|
|
439
|
+
let bytes = 0;
|
|
440
|
+
let lastByteAt = null;
|
|
441
|
+
const counted = response.body.pipeThrough(
|
|
442
|
+
new TransformStream({
|
|
443
|
+
transform(chunk, controller) {
|
|
444
|
+
bytes += chunk.byteLength;
|
|
445
|
+
controller.enqueue(chunk);
|
|
446
|
+
},
|
|
447
|
+
flush() {
|
|
448
|
+
lastByteAt = performance.now();
|
|
449
|
+
},
|
|
450
|
+
}),
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
const wasmModule = await WebAssembly.compileStreaming(
|
|
454
|
+
new Response(counted, { headers: { "content-type": "application/wasm" } }),
|
|
455
|
+
);
|
|
456
|
+
const compiledAt = performance.now();
|
|
457
|
+
|
|
458
|
+
// Whether those bytes crossed the network is the browser's own accounting,
|
|
459
|
+
// not something to infer: `transferSize` counts what came over the wire, so
|
|
460
|
+
// 0 is a cache hit and header-sized is a revalidated one. `no-cache` above
|
|
461
|
+
// means even a warm boot re-asks, which is why the comparison is against
|
|
462
|
+
// encodedBodySize rather than zero. Resource timing is not guaranteed inside
|
|
463
|
+
// a worker — without it, cached stays null and the page says nothing.
|
|
464
|
+
const timing = performance.getEntriesByName(wasmUrl).pop();
|
|
465
|
+
const wireBytes = timing ? timing.encodedBodySize || null : null;
|
|
466
|
+
const cached = timing
|
|
467
|
+
? timing.transferSize === 0 ||
|
|
468
|
+
(timing.encodedBodySize > 0 && timing.transferSize < timing.encodedBodySize)
|
|
469
|
+
: null;
|
|
470
|
+
|
|
471
|
+
bootPhase("bundle", {
|
|
472
|
+
ms: Math.round((lastByteAt ?? compiledAt) - requestedAt),
|
|
473
|
+
bytes,
|
|
474
|
+
wireBytes,
|
|
475
|
+
cached,
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// Streaming compilation finishes shortly after the last byte; that tail is
|
|
479
|
+
// the only part of the compile that is not hidden behind the download.
|
|
480
|
+
bootPhase("compile", { ms: Math.round(compiledAt - (lastByteAt ?? requestedAt)) });
|
|
481
|
+
|
|
482
|
+
const vmStartedAt = performance.now();
|
|
483
|
+
|
|
484
|
+
vm = await initRailsVM(wasmModule, {
|
|
485
|
+
database: { adapter: "pglite" },
|
|
486
|
+
async: true,
|
|
487
|
+
progressCallback: (step) => {
|
|
488
|
+
progress?.updateStep(step);
|
|
489
|
+
},
|
|
490
|
+
outputCallback: (output) => {
|
|
491
|
+
if (!redirectConsole) return;
|
|
492
|
+
progress?.notify(output);
|
|
493
|
+
},
|
|
494
|
+
...opts,
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
bootPhase("vm", { ms: since(vmStartedAt) });
|
|
498
|
+
|
|
499
|
+
// Ensure schema is loaded (PGlite is async-only, so evalAsync). Not a bare
|
|
500
|
+
// DatabaseTasks.prepare_all: the app's migrations are invisible from this VM's
|
|
501
|
+
// working directory, so catching an existing local database up to a schema
|
|
502
|
+
// that has grown since is Ruby's job — InstantRecord::LocalSchema.
|
|
503
|
+
const schemaStartedAt = performance.now();
|
|
504
|
+
progress?.updateStep("Preparing database...");
|
|
505
|
+
await vm.evalAsync("InstantRecord.prepare_database!");
|
|
506
|
+
|
|
507
|
+
bootPhase("schema", { ms: since(schemaStartedAt) });
|
|
508
|
+
|
|
509
|
+
boot.totalMs = Math.round(performance.now() - boot.startedAt);
|
|
510
|
+
console.log(
|
|
511
|
+
`[instant-record] boot ${boot.totalMs}ms (build ${BUILD_VERSION}, ${LEDGER_VERSION})`,
|
|
512
|
+
);
|
|
513
|
+
progress?.notify(`Rails VM boot + db prepare: ${boot.totalMs}ms`);
|
|
514
|
+
announce({ type: "instant_record.boot_done", ledger: bootLedger() });
|
|
515
|
+
|
|
516
|
+
redirectConsole = false;
|
|
517
|
+
|
|
518
|
+
await startSync();
|
|
519
|
+
|
|
520
|
+
return vm;
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
const resetVM = () => {
|
|
524
|
+
clearTimeout(drainTimer);
|
|
525
|
+
drainTimer = null;
|
|
526
|
+
// The stream loop and any pending retry both bail once the VM is gone.
|
|
527
|
+
vm = null;
|
|
528
|
+
// The next boot is a boot of its own, and gets its own ledger.
|
|
529
|
+
boot.startedAt = null;
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
// Half of a cold boot on demand — measuring one used to mean the DevTools
|
|
533
|
+
// "clear site data" ritual, so it was never repeated.
|
|
534
|
+
//
|
|
535
|
+
// This side lets go of everything: the held change stream (a pending fetch keeps
|
|
536
|
+
// this worker alive, and a live worker is what blocks the database delete), the
|
|
537
|
+
// PGlite handle, the caches, and finally the registration, so the page that
|
|
538
|
+
// reloads next comes back uncontrolled and installs a worker from scratch.
|
|
539
|
+
//
|
|
540
|
+
// Deleting the database itself is deliberately not done here. IndexedDB will not
|
|
541
|
+
// delete a database while anything holds it open, and closing PGlite does not
|
|
542
|
+
// release the handle its wasm filesystem keeps — only this worker's death does.
|
|
543
|
+
// So the page deletes it on the next load, once this worker is gone; a page that
|
|
544
|
+
// does not bother still gets a fresh worker on the data that is already there.
|
|
545
|
+
// What nothing can clear is the browser's own HTTP cache, which may still hold
|
|
546
|
+
// app.wasm — hence the ledger measuring where the bundle came from instead of
|
|
547
|
+
// assuming a wipe implies a download.
|
|
548
|
+
const wipeLocalData = async () => {
|
|
549
|
+
streamAbort?.abort();
|
|
550
|
+
resetVM();
|
|
551
|
+
|
|
552
|
+
const open = db;
|
|
553
|
+
db = null;
|
|
554
|
+
await open?.close();
|
|
555
|
+
|
|
556
|
+
await Promise.all((await caches.keys()).map((key) => caches.delete(key)));
|
|
557
|
+
await self.registration.unregister();
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
const installApp = async () => {
|
|
561
|
+
const progress = new Progress();
|
|
562
|
+
await progress.attach(self);
|
|
563
|
+
|
|
564
|
+
await initDB(progress);
|
|
565
|
+
await initVM(progress);
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
// Stamped by instant_record:build with the app.wasm digest. A rebuild
|
|
569
|
+
// changes this constant, which changes the service worker's bytes, which
|
|
570
|
+
// makes the browser install the new worker on the next navigation — that is
|
|
571
|
+
// the whole update mechanism for already-installed clients.
|
|
572
|
+
const BUILD_VERSION = "__INSTANT_RECORD_BUILD_VERSION__";
|
|
573
|
+
|
|
574
|
+
self.addEventListener("activate", (event) => {
|
|
575
|
+
console.log(
|
|
576
|
+
`[rails-web] Activate Service Worker (build ${BUILD_VERSION}, ${LEDGER_VERSION})`,
|
|
577
|
+
);
|
|
578
|
+
// Take over open tabs immediately and reload them onto the new bundle;
|
|
579
|
+
// without this, old tabs keep the previous worker's VM forever.
|
|
580
|
+
event.waitUntil(
|
|
581
|
+
self.clients.claim().then(async () => {
|
|
582
|
+
const clients = await self.clients.matchAll({ type: "window" });
|
|
583
|
+
clients.forEach((client) => client.postMessage({ type: "sw_updated" }));
|
|
584
|
+
}),
|
|
585
|
+
);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
self.addEventListener("install", (event) => {
|
|
589
|
+
console.log(
|
|
590
|
+
`[rails-web] Install Service Worker (build ${BUILD_VERSION}, ${LEDGER_VERSION})`,
|
|
591
|
+
);
|
|
592
|
+
event.waitUntil(installApp().then(() => self.skipWaiting()));
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
const rackHandler = new RackHandler(initVM, { assumeSSL: true, async: true });
|
|
596
|
+
|
|
597
|
+
const BOOT_RESOURCES = ["/boot", "/boot.js", "/boot.html", "/rails.sw.js", "/favicon.ico"];
|
|
598
|
+
const VITE_RESOURCES = ["node_modules", "@vite"];
|
|
599
|
+
const VITE_ASSETS = ["/assets/pglite-", "/assets/boot-"];
|
|
600
|
+
|
|
601
|
+
self.addEventListener("fetch", (event) => {
|
|
602
|
+
const url = new URL(event.request.url);
|
|
603
|
+
|
|
604
|
+
// Cross-origin requests (e.g. the sync server) go straight to the network.
|
|
605
|
+
if (url.origin !== self.location.origin) {
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Vite's hashed output shares /assets/ with Rails' Propshaft assets, which
|
|
610
|
+
// Rack-in-Wasm serves from the bundle. Match Vite's files by name — a
|
|
611
|
+
// blanket /assets/ bypass 404s every stylesheet and Stimulus controller.
|
|
612
|
+
if (VITE_ASSETS.some((prefix) => url.pathname.startsWith(prefix))) {
|
|
613
|
+
console.log("[rails-web] Fetching Vite asset from network:", url.href);
|
|
614
|
+
event.respondWith(fetch(event.request));
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (BOOT_RESOURCES.some((r) => url.pathname.endsWith(r))) {
|
|
619
|
+
console.log("[rails-web] Fetching boot files from network:", url.href);
|
|
620
|
+
event.respondWith(fetch(event.request.url));
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (VITE_RESOURCES.some((r) => url.href.includes(r))) {
|
|
625
|
+
console.log("[rails-web] Fetching Vite files from network:", url.href);
|
|
626
|
+
event.respondWith(fetch(event.request.url));
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Requests marked ?instant_record_network=1 bypass the local runtime and go
|
|
631
|
+
// to the real server — for actions whose writes must land on the
|
|
632
|
+
// authoritative change log (e.g. a demo reset) even when served same-origin.
|
|
633
|
+
if (url.searchParams.has("instant_record_network")) {
|
|
634
|
+
console.log("[rails-web] Passing request to network:", url.href);
|
|
635
|
+
// fetch(event.request) preserves the method and body (POSTs included).
|
|
636
|
+
event.respondWith(fetch(event.request));
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const respond = rackHandler.handle(event.request);
|
|
641
|
+
|
|
642
|
+
// Reported from here rather than from the page, because a form submission and
|
|
643
|
+
// a navigation are not window.fetch calls — wrapping fetch in the page misses
|
|
644
|
+
// the entire plain-Rails interaction model, and the first page load with it.
|
|
645
|
+
//
|
|
646
|
+
if (sim.report) {
|
|
647
|
+
const startedAt = performance.now();
|
|
648
|
+
const isAsset = url.pathname.startsWith("/assets/");
|
|
649
|
+
|
|
650
|
+
event.waitUntil(
|
|
651
|
+
respond
|
|
652
|
+
.then((response) => {
|
|
653
|
+
const ms = Math.round(performance.now() - startedAt);
|
|
654
|
+
if (isAsset) return reportAssets(ms);
|
|
655
|
+
|
|
656
|
+
report({
|
|
657
|
+
kind: "local",
|
|
658
|
+
method: event.request.method,
|
|
659
|
+
path: url.pathname + url.search,
|
|
660
|
+
status: response.status,
|
|
661
|
+
ms,
|
|
662
|
+
});
|
|
663
|
+
})
|
|
664
|
+
.catch(() => {}),
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// A local write enqueues an outbox mutation, and that is the main reason a
|
|
669
|
+
// sync pass ever runs — there is no timer waiting to notice it.
|
|
670
|
+
if (event.request.method !== "GET") {
|
|
671
|
+
event.waitUntil(respond.then(() => syncSoon()));
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
return event.respondWith(respond);
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
self.addEventListener("message", async (event) => {
|
|
678
|
+
console.log("[rails-web] Received worker message:", event.data);
|
|
679
|
+
|
|
680
|
+
if (event.data.type === "instant_record.fetch_history") {
|
|
681
|
+
const reply = await fetchHistory(event.data.request);
|
|
682
|
+
event.ports[0]?.postMessage(reply);
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// A page that was not open for the boot it wants to report on asks for the
|
|
687
|
+
// ledger here — /receipts, or any warm visit where the splash never appeared.
|
|
688
|
+
// totalMs is null while a boot is still running.
|
|
689
|
+
if (event.data.type === "instant_record.boot_ledger") {
|
|
690
|
+
event.source?.postMessage({
|
|
691
|
+
type: "instant_record.boot_done",
|
|
692
|
+
ledger: bootLedger(),
|
|
693
|
+
});
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if (event.data.type === "instant_record.wipe_local_data") {
|
|
698
|
+
await wipeLocalData();
|
|
699
|
+
event.ports[0]?.postMessage({ ok: true });
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Clearing has to reach the buffer, not just the page's copy of it.
|
|
704
|
+
if (event.data.type === "instant_record.clear_log") {
|
|
705
|
+
history.length = 0;
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Inspector controls: report toggles the event stream, offline and latencyMs
|
|
710
|
+
// simulate a bad network on the sync server only.
|
|
711
|
+
if (event.data.type === "instant_record.simulate") {
|
|
712
|
+
const wasOffline = sim.offline;
|
|
713
|
+
const before = `${sim.offline}/${sim.latencyMs}`;
|
|
714
|
+
Object.assign(sim, event.data.settings || {});
|
|
715
|
+
|
|
716
|
+
// Cut a live stream the moment the network is "lost".
|
|
717
|
+
if (sim.offline && !wasOffline) streamAbort?.abort();
|
|
718
|
+
|
|
719
|
+
// Only worth a line when something actually changed: every page load
|
|
720
|
+
// re-states its settings, and logging that is pure noise.
|
|
721
|
+
if (`${sim.offline}/${sim.latencyMs}` !== before) {
|
|
722
|
+
report({
|
|
723
|
+
kind: "sim",
|
|
724
|
+
path: `offline: ${sim.offline}, latency: ${sim.latencyMs}ms`,
|
|
725
|
+
streamOpen: streaming,
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Only when asked. A page requests this once, on load: replaying it later
|
|
730
|
+
// would replace a log that has since accumulated entries of its own.
|
|
731
|
+
if (event.data.backlog) {
|
|
732
|
+
event.source?.postMessage({
|
|
733
|
+
type: "instant_record.debug_backlog",
|
|
734
|
+
entries: history,
|
|
735
|
+
streamOpen: streaming,
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
if (event.data.type === "reload-rails") {
|
|
742
|
+
const progress = new Progress();
|
|
743
|
+
await progress.attach(self);
|
|
744
|
+
|
|
745
|
+
progress.updateStep("Reloading Rails application...");
|
|
746
|
+
|
|
747
|
+
resetVM();
|
|
748
|
+
await initVM(progress, { debug: event.data.debug });
|
|
749
|
+
}
|
|
750
|
+
});
|