wasm_rails 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/MIT-LICENSE +22 -0
- data/README.md +157 -0
- data/lib/generators/wasm_rails/install/install_generator.rb +109 -0
- data/lib/generators/wasm_rails/install/templates/boot.js +31 -0
- data/lib/generators/wasm_rails/install/templates/build_app_bundle.mjs +201 -0
- data/lib/generators/wasm_rails/install/templates/esbuild_wasm.mjs +58 -0
- data/lib/generators/wasm_rails/install/templates/serve_wasm.mjs +86 -0
- data/lib/generators/wasm_rails/install/templates/service_worker.js +413 -0
- data/lib/generators/wasm_rails/install/templates/wasm_shell.html +97 -0
- data/lib/generators/wasm_rails/install/templates/wasm_sqlite3_adapter.rb +160 -0
- data/lib/wasm_rails/railtie.rb +23 -0
- data/lib/wasm_rails/version.rb +3 -0
- data/lib/wasm_rails.rb +8 -0
- data/wasm_stubs/io/console/size.rb +8 -0
- data/wasm_stubs/io/wait.rb +6 -0
- data/wasm_stubs/loofah.rb +39 -0
- data/wasm_stubs/nokogiri.rb +67 -0
- data/wasm_stubs/openssl.rb +122 -0
- data/wasm_stubs/rails-html-sanitizer.rb +89 -0
- data/wasm_stubs/resolv.rb +19 -0
- data/wasm_stubs/socket.rb +47 -0
- data/wasm_stubs/sqlite3.rb +102 -0
- data/wasm_stubs/thread.rb +10 -0
- metadata +81 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
// Service Worker — Rails runs entirely here.
|
|
2
|
+
// SQLite via @sqlite.org/sqlite-wasm (synchronous OO1 API, OPFS-backed).
|
|
3
|
+
// Ruby+Rails via @ruby/wasm-wasi. No Web Worker relay, no SharedArrayBuffer.
|
|
4
|
+
|
|
5
|
+
import { DefaultRubyVM } from '@ruby/wasm-wasi/dist/browser';
|
|
6
|
+
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
|
|
7
|
+
|
|
8
|
+
// Injected at build time by esbuild define — changes on every build.
|
|
9
|
+
// Used to detect when a new SW version has been deployed and force clients to reload.
|
|
10
|
+
const BUILD_ID = typeof __BUILD_ID__ !== 'undefined' ? __BUILD_ID__ : 'dev';
|
|
11
|
+
const CACHE_VERSION = `wasm-rails-sw-${BUILD_ID}`;
|
|
12
|
+
|
|
13
|
+
const BYPASS_PREFIXES = ['/wasm/', '/assets/', '/packs/', '/icon', '/favicon', '/robots.txt', '/wasm_shell', '/auth.html'];
|
|
14
|
+
|
|
15
|
+
function isAsset(pathname) {
|
|
16
|
+
return BYPASS_PREFIXES.some(p => pathname.startsWith(p));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function broadcast(msg) {
|
|
20
|
+
self.clients.matchAll({ includeUncontrolled: true }).then(cs => cs.forEach(c => c.postMessage(msg)));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ── IndexedDB persistence (fallback when OPFS sync handles unavailable) ───────
|
|
24
|
+
|
|
25
|
+
const IDB_NAME = 'wasm-rails-sqlite';
|
|
26
|
+
const IDB_STORE = 'db';
|
|
27
|
+
const IDB_KEY = 'main';
|
|
28
|
+
const IDB_IMPORT_KEY = 'pending_import';
|
|
29
|
+
|
|
30
|
+
function idbOpen() {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const req = indexedDB.open(IDB_NAME, 1);
|
|
33
|
+
req.onupgradeneeded = e => e.target.result.createObjectStore(IDB_STORE);
|
|
34
|
+
req.onsuccess = e => resolve(e.target.result);
|
|
35
|
+
req.onerror = () => reject(req.error);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function idbGet(key) {
|
|
40
|
+
try {
|
|
41
|
+
const idb = await idbOpen();
|
|
42
|
+
return new Promise(resolve => {
|
|
43
|
+
const tx = idb.transaction(IDB_STORE, 'readonly');
|
|
44
|
+
const req = tx.objectStore(IDB_STORE).get(key);
|
|
45
|
+
req.onsuccess = () => { idb.close(); resolve(req.result || null); };
|
|
46
|
+
req.onerror = () => { idb.close(); resolve(null); };
|
|
47
|
+
});
|
|
48
|
+
} catch { return null; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function idbSet(key, bytes) {
|
|
52
|
+
try {
|
|
53
|
+
const idb = await idbOpen();
|
|
54
|
+
await new Promise((resolve, reject) => {
|
|
55
|
+
const tx = idb.transaction(IDB_STORE, 'readwrite');
|
|
56
|
+
tx.objectStore(IDB_STORE).put(bytes, key);
|
|
57
|
+
tx.oncomplete = resolve;
|
|
58
|
+
tx.onerror = () => reject(tx.error);
|
|
59
|
+
});
|
|
60
|
+
idb.close();
|
|
61
|
+
} catch (e) {
|
|
62
|
+
console.warn('[sw] IDB set failed:', e.message);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function idbDel(key) {
|
|
67
|
+
try {
|
|
68
|
+
const idb = await idbOpen();
|
|
69
|
+
await new Promise((resolve, reject) => {
|
|
70
|
+
const tx = idb.transaction(IDB_STORE, 'readwrite');
|
|
71
|
+
tx.objectStore(IDB_STORE).delete(key);
|
|
72
|
+
tx.oncomplete = resolve;
|
|
73
|
+
tx.onerror = () => reject(tx.error);
|
|
74
|
+
});
|
|
75
|
+
idb.close();
|
|
76
|
+
} catch {}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Keep old names as aliases so existing call-sites don't change.
|
|
80
|
+
const idbLoad = () => idbGet(IDB_KEY);
|
|
81
|
+
const idbSaveBytes = (b) => idbSet(IDB_KEY, b);
|
|
82
|
+
|
|
83
|
+
// ── SQLite ────────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
// Set in initSQLite when using IDB-backed in-memory DB; null when OPFS handles it.
|
|
86
|
+
let persistDb = null;
|
|
87
|
+
|
|
88
|
+
async function initSQLite() {
|
|
89
|
+
broadcast({ type: 'progress', step: 'Initializing SQLite…' });
|
|
90
|
+
|
|
91
|
+
const sqlite3 = await sqlite3InitModule({
|
|
92
|
+
locateFile: (path) => `/wasm/${path}`,
|
|
93
|
+
print: () => {},
|
|
94
|
+
printErr: (msg) => { if (!msg.includes('pragma')) console.warn('[sqlite3]', msg); },
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
let db;
|
|
98
|
+
try {
|
|
99
|
+
// OPFS SAH Pool VFS — synchronous, truly persistent.
|
|
100
|
+
const pool = await sqlite3.installOpfsSAHPoolVfs({ directory: '.wasm-rails', initialCapacity: 6 });
|
|
101
|
+
|
|
102
|
+
// Apply pending import before opening — pool.importDb requires the db to be closed.
|
|
103
|
+
const importBytes = await idbGet(IDB_IMPORT_KEY);
|
|
104
|
+
if (importBytes) {
|
|
105
|
+
try {
|
|
106
|
+
await pool.importDb('/wasm-rails.db', importBytes);
|
|
107
|
+
await idbDel(IDB_IMPORT_KEY);
|
|
108
|
+
console.log('[sw] Applied pending import to OPFS');
|
|
109
|
+
} catch (ie) {
|
|
110
|
+
console.warn('[sw] Pending import failed:', ie.message);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
db = new pool.OpfsSAHPoolDb('/wasm-rails.db');
|
|
115
|
+
console.log('[sw] SQLite OPFS SAH Pool opened (persistent)');
|
|
116
|
+
} catch (e) {
|
|
117
|
+
// OPFS sync handles unavailable — use in-memory DB with IndexedDB serialization.
|
|
118
|
+
console.warn('[sw] OPFS unavailable, using IndexedDB-backed in-memory DB');
|
|
119
|
+
db = new sqlite3.oo1.DB();
|
|
120
|
+
|
|
121
|
+
// Pending import takes priority over the regular IDB backup.
|
|
122
|
+
const importBytes = await idbGet(IDB_IMPORT_KEY);
|
|
123
|
+
if (importBytes) await idbDel(IDB_IMPORT_KEY);
|
|
124
|
+
const saved = importBytes || await idbLoad();
|
|
125
|
+
if (saved) {
|
|
126
|
+
try {
|
|
127
|
+
const pData = sqlite3.wasm.allocFromTypedArray(saved);
|
|
128
|
+
const rc = sqlite3.capi.sqlite3_deserialize(
|
|
129
|
+
db.pointer, 'main', pData, saved.byteLength, saved.byteLength, 1 | 2
|
|
130
|
+
);
|
|
131
|
+
if (rc !== 0) {
|
|
132
|
+
sqlite3.wasm.dealloc(pData);
|
|
133
|
+
console.warn(`[sw] SQLite restore failed (rc=${rc}), starting fresh`);
|
|
134
|
+
} else {
|
|
135
|
+
console.log(`[sw] SQLite restored from IndexedDB (${saved.byteLength} bytes)`);
|
|
136
|
+
}
|
|
137
|
+
} catch (de) {
|
|
138
|
+
console.warn('[sw] SQLite restore error:', de.message);
|
|
139
|
+
}
|
|
140
|
+
} else {
|
|
141
|
+
console.log('[sw] SQLite fresh in-memory DB');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
persistDb = async () => {
|
|
145
|
+
try {
|
|
146
|
+
const bytes = sqlite3.capi.sqlite3_js_db_export(db.pointer);
|
|
147
|
+
await idbSaveBytes(bytes);
|
|
148
|
+
} catch (e) {
|
|
149
|
+
console.warn('[sw] SQLite export failed:', e.message);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
exportDb = () => sqlite3.capi.sqlite3_js_db_export(db.pointer);
|
|
155
|
+
|
|
156
|
+
// Expose a synchronous interface Ruby calls via JS.global[:sqlite4rails]
|
|
157
|
+
self.sqlite4rails = {
|
|
158
|
+
exec: (sql) => {
|
|
159
|
+
const result = { cols: [], rows: [] };
|
|
160
|
+
db.exec({
|
|
161
|
+
sql: sql.toString(),
|
|
162
|
+
columnNames: result.cols,
|
|
163
|
+
resultRows: result.rows,
|
|
164
|
+
rowMode: 'array',
|
|
165
|
+
});
|
|
166
|
+
if (persistDb && db.changes() > 0) persistDb().catch(() => {});
|
|
167
|
+
return result;
|
|
168
|
+
},
|
|
169
|
+
changes: () => db.changes(),
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
console.log('[sw] SQLite ready');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── DB export ─────────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
let exportDb = null;
|
|
178
|
+
|
|
179
|
+
async function handleExport() {
|
|
180
|
+
if (!exportDb) return new Response('Database not ready', { status: 503 });
|
|
181
|
+
try {
|
|
182
|
+
const bytes = exportDb();
|
|
183
|
+
return new Response(bytes, {
|
|
184
|
+
status: 200,
|
|
185
|
+
headers: {
|
|
186
|
+
'Content-Type': 'application/x-sqlite3',
|
|
187
|
+
'Content-Disposition': 'attachment; filename="wasm-rails.sqlite3"',
|
|
188
|
+
'Content-Length': String(bytes.byteLength),
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
} catch (e) {
|
|
192
|
+
return new Response('Export failed: ' + e.message, { status: 500 });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function handleImport(req) {
|
|
197
|
+
try {
|
|
198
|
+
const bytes = new Uint8Array(await req.arrayBuffer());
|
|
199
|
+
const magic = new TextDecoder().decode(bytes.slice(0, 15));
|
|
200
|
+
if (!magic.startsWith('SQLite format 3')) {
|
|
201
|
+
return new Response(JSON.stringify({ error: 'Not a valid SQLite file' }), {
|
|
202
|
+
status: 400, headers: { 'Content-Type': 'application/json' }
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
await idbSet(IDB_IMPORT_KEY, bytes);
|
|
206
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
207
|
+
status: 200, headers: { 'Content-Type': 'application/json' }
|
|
208
|
+
});
|
|
209
|
+
} catch (e) {
|
|
210
|
+
return new Response(JSON.stringify({ error: e.message }), {
|
|
211
|
+
status: 500, headers: { 'Content-Type': 'application/json' }
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── Ruby / Rails ──────────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
let vm = null;
|
|
219
|
+
|
|
220
|
+
function buildMountScript(bundle) {
|
|
221
|
+
const entries = Object.entries(bundle).map(([path, b64]) => {
|
|
222
|
+
const escaped = b64.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
223
|
+
return `require 'base64'; File.write("${path}", Base64.decode64("${escaped}"))`;
|
|
224
|
+
}).join("\n");
|
|
225
|
+
|
|
226
|
+
const dirs = [...new Set(
|
|
227
|
+
Object.keys(bundle).map(p => p.split('/').slice(0, -1).join('/'))
|
|
228
|
+
)].sort().map(d => `FileUtils.mkdir_p("${d}")`).join("\n");
|
|
229
|
+
|
|
230
|
+
return `require 'fileutils'\n${dirs}\n${entries}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function escRuby(str) {
|
|
234
|
+
return String(str)
|
|
235
|
+
.replace(/\\/g, '\\\\')
|
|
236
|
+
.replace(/'/g, "\\'")
|
|
237
|
+
.replace(/\n/g, '\\n')
|
|
238
|
+
.replace(/\r/g, '\\r');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function initRails() {
|
|
242
|
+
broadcast({ type: 'progress', step: 'Loading Ruby+Rails (this takes ~20s on first load)…' });
|
|
243
|
+
|
|
244
|
+
const rubyWasmUrl = typeof __RUBY_WASM_URL__ !== 'undefined' ? __RUBY_WASM_URL__ : '/wasm/ruby+stdlib.wasm';
|
|
245
|
+
const wasmModule = await WebAssembly.compileStreaming(fetch(rubyWasmUrl));
|
|
246
|
+
const { vm: rubyVM } = await DefaultRubyVM(wasmModule);
|
|
247
|
+
|
|
248
|
+
broadcast({ type: 'progress', step: 'Mounting app bundle…' });
|
|
249
|
+
const appBundleUrl = typeof __APP_BUNDLE_URL__ !== 'undefined' ? __APP_BUNDLE_URL__ : '/wasm/app_bundle.json';
|
|
250
|
+
const bundle = await (await fetch(appBundleUrl)).json();
|
|
251
|
+
rubyVM.eval(buildMountScript(bundle));
|
|
252
|
+
|
|
253
|
+
broadcast({ type: 'progress', step: 'Booting Rails…' });
|
|
254
|
+
rubyVM.eval(`
|
|
255
|
+
load '/wasm_setup.rb'
|
|
256
|
+
|
|
257
|
+
ENV['RAILS_ENV'] = 'production'
|
|
258
|
+
ENV['SECRET_KEY_BASE'] = 'wasm-local-secret-not-used-for-encryption'
|
|
259
|
+
|
|
260
|
+
Dir.chdir('/app')
|
|
261
|
+
require_relative '/app/config/environment'
|
|
262
|
+
RAILS_APP = Rails.application
|
|
263
|
+
RAILS_APP.initialize! unless RAILS_APP.initialized?
|
|
264
|
+
puts '[sw] Rails booted'
|
|
265
|
+
|
|
266
|
+
begin
|
|
267
|
+
unless ActiveRecord::Base.connection.table_exists?('users')
|
|
268
|
+
ActiveRecord::Schema.verbose = false
|
|
269
|
+
load('/app/db/schema.rb')
|
|
270
|
+
puts '[sw] Schema loaded'
|
|
271
|
+
else
|
|
272
|
+
ActiveRecord::Migration.verbose = false
|
|
273
|
+
ActiveRecord::MigrationContext.new(
|
|
274
|
+
Rails.root.join('db/migrate'),
|
|
275
|
+
ActiveRecord::SchemaMigration.new(ActiveRecord::Base.connection_pool)
|
|
276
|
+
).migrate
|
|
277
|
+
puts '[sw] Migrations applied'
|
|
278
|
+
end
|
|
279
|
+
rescue => e
|
|
280
|
+
puts "[sw] Schema warning: \#{e.message}"
|
|
281
|
+
end
|
|
282
|
+
`);
|
|
283
|
+
|
|
284
|
+
// Persist schema to IDB — DDL (CREATE TABLE) doesn't bump db.changes()
|
|
285
|
+
// so the exec-level auto-save won't fire for the initial schema load.
|
|
286
|
+
if (persistDb) await persistDb();
|
|
287
|
+
|
|
288
|
+
vm = rubyVM;
|
|
289
|
+
console.log('[sw] Rails ready');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── Boot ──────────────────────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
let bootPromise = null;
|
|
295
|
+
|
|
296
|
+
async function boot() {
|
|
297
|
+
if (bootPromise) return bootPromise;
|
|
298
|
+
bootPromise = (async () => {
|
|
299
|
+
try {
|
|
300
|
+
await initSQLite();
|
|
301
|
+
await initRails();
|
|
302
|
+
broadcast({ type: 'ready' });
|
|
303
|
+
} catch (e) {
|
|
304
|
+
console.error('[sw] Boot failed:', e);
|
|
305
|
+
broadcast({ type: 'error', message: e.message });
|
|
306
|
+
} finally {
|
|
307
|
+
bootPromise = null;
|
|
308
|
+
}
|
|
309
|
+
})();
|
|
310
|
+
return bootPromise;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
self.addEventListener('install', (event) => {
|
|
314
|
+
event.waitUntil(
|
|
315
|
+
boot()
|
|
316
|
+
.then(() => caches.open(CACHE_VERSION))
|
|
317
|
+
.then(() => self.skipWaiting())
|
|
318
|
+
);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
self.addEventListener('activate', (event) => {
|
|
322
|
+
event.waitUntil((async () => {
|
|
323
|
+
const allCaches = await caches.keys();
|
|
324
|
+
const staleCaches = allCaches.filter(k => k.startsWith('wasm-rails-sw-') && k !== CACHE_VERSION);
|
|
325
|
+
|
|
326
|
+
const isUpdate = staleCaches.length > 0;
|
|
327
|
+
await Promise.all(staleCaches.map(k => caches.delete(k)));
|
|
328
|
+
|
|
329
|
+
await self.clients.claim();
|
|
330
|
+
|
|
331
|
+
if (isUpdate) {
|
|
332
|
+
console.log(`[sw] Updated to ${BUILD_ID} — reloading all clients`);
|
|
333
|
+
const clients = await self.clients.matchAll({ type: 'window' });
|
|
334
|
+
await Promise.all(clients.map(c => c.navigate(c.url).catch(() => {})));
|
|
335
|
+
}
|
|
336
|
+
})());
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// ── Request handling ──────────────────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
self.addEventListener('fetch', (event) => {
|
|
342
|
+
const url = new URL(event.request.url);
|
|
343
|
+
if (url.origin !== self.location.origin || isAsset(url.pathname)) return;
|
|
344
|
+
if (url.pathname === '/data/export.sqlite3') {
|
|
345
|
+
event.respondWith(handleExport());
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (url.pathname === '/data/import' && event.request.method === 'POST') {
|
|
349
|
+
event.respondWith(handleImport(event.request));
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
event.respondWith(handleRequest(event.request));
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
async function handleRequest(req) {
|
|
356
|
+
if (!vm) {
|
|
357
|
+
boot();
|
|
358
|
+
return new Response(`<!DOCTYPE html>
|
|
359
|
+
<html><head><meta charset="utf-8"><title>Budget Clear</title>
|
|
360
|
+
<style>body{font-family:monospace;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}p{font-size:1rem}</style>
|
|
361
|
+
<script>navigator.serviceWorker.addEventListener('message',e=>{if(e.data?.type==='ready')location.reload()});<\/script>
|
|
362
|
+
</head><body><p>Restarting Budget Clear…</p></body></html>`,
|
|
363
|
+
{ status: 503, headers: { 'Content-Type': 'text/html' } }
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const body = ['GET', 'HEAD'].includes(req.method) ? '' : await req.text();
|
|
368
|
+
const url = new URL(req.url);
|
|
369
|
+
const headers = {};
|
|
370
|
+
req.headers.forEach((v, k) => { headers[k] = v; });
|
|
371
|
+
|
|
372
|
+
const httpHeaders = Object.entries(headers)
|
|
373
|
+
.filter(([k]) => !['host', 'content-type', 'content-length'].includes(k.toLowerCase()))
|
|
374
|
+
.map(([k, v]) => ` 'HTTP_${k.toUpperCase().replace(/-/g, '_')}' => '${escRuby(v)}'`)
|
|
375
|
+
.join(',\n');
|
|
376
|
+
|
|
377
|
+
const result = vm.eval(`
|
|
378
|
+
begin
|
|
379
|
+
env = {
|
|
380
|
+
'REQUEST_METHOD' => '${escRuby(req.method)}',
|
|
381
|
+
'PATH_INFO' => '${escRuby(url.pathname)}',
|
|
382
|
+
'QUERY_STRING' => '${escRuby(url.search.slice(1))}',
|
|
383
|
+
'HTTP_HOST' => '${escRuby(url.host)}',
|
|
384
|
+
${headers['content-type'] ? `'CONTENT_TYPE' => '${escRuby(headers['content-type'])}',` : ''}
|
|
385
|
+
'CONTENT_LENGTH' => '${escRuby(String(body.length))}',
|
|
386
|
+
'rack.input' => StringIO.new('${escRuby(body)}'),
|
|
387
|
+
'rack.errors' => $stderr,
|
|
388
|
+
'rack.url_scheme' => '${escRuby(url.protocol.slice(0, -1))}',
|
|
389
|
+
'rack.multithread' => false,
|
|
390
|
+
'rack.multiprocess'=> false,
|
|
391
|
+
'rack.run_once' => false,
|
|
392
|
+
${httpHeaders}
|
|
393
|
+
}
|
|
394
|
+
status, headers, body_iter = RAILS_APP.call(env)
|
|
395
|
+
body_str = ''.dup; body_iter.each { |p| body_str << p.to_s }
|
|
396
|
+
body_iter.close if body_iter.respond_to?(:close)
|
|
397
|
+
JSON.generate({ status: status.to_i, headers: headers, body: body_str })
|
|
398
|
+
rescue => e
|
|
399
|
+
JSON.generate({ status: 500, headers: {}, body: e.message + "\\n" + e.backtrace.first(20).join("\\n") + (e.cause ? "\\nCaused by: \#{e.cause.message}\\n" + e.cause.backtrace.first(10).join("\\n") : "") })
|
|
400
|
+
end
|
|
401
|
+
`);
|
|
402
|
+
|
|
403
|
+
const response = JSON.parse(result.toString());
|
|
404
|
+
|
|
405
|
+
if (response.status !== 200) {
|
|
406
|
+
console.warn('[sw] non-200', response.status, response.body?.substring(0, 300));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const respHeaders = new Headers(response.headers || {});
|
|
410
|
+
if (!respHeaders.has('Content-Type')) respHeaders.set('Content-Type', 'text/html');
|
|
411
|
+
|
|
412
|
+
return new Response(response.body, { status: response.status, headers: respHeaders });
|
|
413
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
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>Loading…</title>
|
|
7
|
+
<style>
|
|
8
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
9
|
+
body {
|
|
10
|
+
font-family: monospace;
|
|
11
|
+
background: #fff;
|
|
12
|
+
color: #000;
|
|
13
|
+
display: flex;
|
|
14
|
+
flex-direction: column;
|
|
15
|
+
align-items: center;
|
|
16
|
+
justify-content: center;
|
|
17
|
+
min-height: 100vh;
|
|
18
|
+
padding: 2rem;
|
|
19
|
+
}
|
|
20
|
+
.box { border: 2px solid #000; padding: 2rem; width: 100%; max-width: 480px; }
|
|
21
|
+
.label { font-size: 0.65rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; color: #666; margin-bottom: 0.5rem; }
|
|
22
|
+
.status { font-size: 0.9rem; min-height: 1.4em; }
|
|
23
|
+
.bar-wrap { border: 2px solid #000; height: 10px; margin-top: 1.5rem; }
|
|
24
|
+
.bar { height: 100%; background: #000; width: 0%; transition: width 0.4s ease; }
|
|
25
|
+
.error { background: #f00; color: #fff; border: 2px solid #000; padding: 1rem; margin-top: 1rem;
|
|
26
|
+
font-size: 0.85rem; white-space: pre-wrap; display: none; }
|
|
27
|
+
</style>
|
|
28
|
+
</head>
|
|
29
|
+
<body>
|
|
30
|
+
<div class="box">
|
|
31
|
+
<div class="label">Status</div>
|
|
32
|
+
<div class="status" id="status">Initializing…</div>
|
|
33
|
+
<div class="bar-wrap"><div class="bar" id="bar"></div></div>
|
|
34
|
+
</div>
|
|
35
|
+
<div class="error" id="error"></div>
|
|
36
|
+
|
|
37
|
+
<script type="module">
|
|
38
|
+
const statusEl = document.getElementById('status');
|
|
39
|
+
const barEl = document.getElementById('bar');
|
|
40
|
+
const errorEl = document.getElementById('error');
|
|
41
|
+
|
|
42
|
+
function setStatus(msg, pct) {
|
|
43
|
+
statusEl.textContent = msg;
|
|
44
|
+
if (pct !== undefined) barEl.style.width = pct + '%';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function showError(msg) {
|
|
48
|
+
errorEl.style.display = 'block';
|
|
49
|
+
errorEl.textContent = 'Boot error:\n' + msg;
|
|
50
|
+
setStatus('Failed — see error below', 100);
|
|
51
|
+
barEl.style.background = '#f00';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function boot() {
|
|
55
|
+
if (!window.crossOriginIsolated) {
|
|
56
|
+
showError(
|
|
57
|
+
'Page is not cross-origin isolated.\n' +
|
|
58
|
+
'Server must send:\n' +
|
|
59
|
+
' Cross-Origin-Opener-Policy: same-origin\n' +
|
|
60
|
+
' Cross-Origin-Embedder-Policy: credentialless'
|
|
61
|
+
);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
setStatus('Loading boot module…', 10);
|
|
66
|
+
const { bootWasm } = await import('/wasm/boot.js');
|
|
67
|
+
|
|
68
|
+
window.addEventListener('wasm-progress', ({ detail }) => {
|
|
69
|
+
setStatus(detail.step);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
setStatus('Registering service worker…', 20);
|
|
73
|
+
|
|
74
|
+
let pct = 20;
|
|
75
|
+
const ticker = setInterval(() => {
|
|
76
|
+
if (pct < 90) { pct += 0.3; barEl.style.width = pct + '%'; }
|
|
77
|
+
}, 500);
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
await bootWasm();
|
|
81
|
+
clearInterval(ticker);
|
|
82
|
+
setStatus('Rails booted. Entering app…', 100);
|
|
83
|
+
await new Promise(r => setTimeout(r, 200));
|
|
84
|
+
// Redirect to your app's root after boot.
|
|
85
|
+
// Override this to redirect to an auth page if needed.
|
|
86
|
+
window.location.href = '/';
|
|
87
|
+
} catch (e) {
|
|
88
|
+
clearInterval(ticker);
|
|
89
|
+
showError(e.message);
|
|
90
|
+
console.error('[wasm_shell] Boot failed:', e);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
boot();
|
|
95
|
+
</script>
|
|
96
|
+
</body>
|
|
97
|
+
</html>
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
require 'js'
|
|
2
|
+
|
|
3
|
+
# wasm_stubs/sqlite3.rb (loaded via /stubs on $LOAD_PATH) provides the SQLite3
|
|
4
|
+
# module stubs. sqlite3_adapter.rb's `gem 'sqlite3'` and `require 'sqlite3'`
|
|
5
|
+
# both resolve there without the native C extension.
|
|
6
|
+
#
|
|
7
|
+
# We still need the fake gem spec so the `gem 'sqlite3', '>= 2.1'` version
|
|
8
|
+
# check in sqlite3_adapter.rb passes.
|
|
9
|
+
unless Gem.loaded_specs['sqlite3']
|
|
10
|
+
fake = Gem::Specification.new { |s| s.name = 'sqlite3'; s.version = Gem::Version.new('2.5.0') }
|
|
11
|
+
Gem.loaded_specs['sqlite3'] = fake
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
require 'active_record/connection_adapters/sqlite3_adapter'
|
|
15
|
+
|
|
16
|
+
module ActiveRecord
|
|
17
|
+
module ConnectionAdapters
|
|
18
|
+
# Thin JS-bridge wrapper around the real SQLite3Adapter.
|
|
19
|
+
# All query pipeline (perform_query, internal_exec_query, write_query?,
|
|
20
|
+
# column reflection, type mapping) is inherited from SQLite3Adapter.
|
|
21
|
+
# Only the raw connection object is replaced with a JS proxy.
|
|
22
|
+
class WasmSqlite3Adapter < SQLite3Adapter
|
|
23
|
+
class ExternalInterface
|
|
24
|
+
def initialize
|
|
25
|
+
@js = JS.global[:sqlite4rails]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Called by SQLite3Adapter to execute SQL. Returns a Statement-like object.
|
|
29
|
+
def prepare(sql)
|
|
30
|
+
Statement.new(@js, sql)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def execute(sql)
|
|
34
|
+
stmt = prepare(sql)
|
|
35
|
+
stmt.result
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Compatibility shims SQLite3Adapter calls on the connection object.
|
|
39
|
+
def transaction(mode = nil)
|
|
40
|
+
mode = nil if mode == :deferred
|
|
41
|
+
execute("begin #{mode} transaction".strip)
|
|
42
|
+
if block_given?
|
|
43
|
+
begin
|
|
44
|
+
yield self
|
|
45
|
+
commit
|
|
46
|
+
rescue
|
|
47
|
+
rollback
|
|
48
|
+
raise
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def commit = execute('commit transaction')
|
|
54
|
+
def rollback = execute('rollback transaction')
|
|
55
|
+
|
|
56
|
+
def changes = @js.call(:changes).to_i
|
|
57
|
+
def total_changes = @js.call(:changes).to_i
|
|
58
|
+
|
|
59
|
+
def busy_timeout(_t) = nil
|
|
60
|
+
def busy_handler_timeout=(_t); end
|
|
61
|
+
def closed? = false
|
|
62
|
+
def results_as_hash = true
|
|
63
|
+
def results_as_hash=(_v); end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
class Statement
|
|
67
|
+
attr_reader :columns, :rows
|
|
68
|
+
|
|
69
|
+
def initialize(js_interface, sql)
|
|
70
|
+
@js = js_interface
|
|
71
|
+
@base_sql = sql.to_s
|
|
72
|
+
@sql = @base_sql
|
|
73
|
+
@executed = false
|
|
74
|
+
@columns = []
|
|
75
|
+
@rows = []
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Substitute bound parameters into the SQL (? placeholders).
|
|
79
|
+
def bind_params(*params)
|
|
80
|
+
params = params.flatten(1)
|
|
81
|
+
return if params.empty?
|
|
82
|
+
i = -1
|
|
83
|
+
@sql = @base_sql.gsub('?') do
|
|
84
|
+
i += 1
|
|
85
|
+
quote_value(params[i])
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def step
|
|
90
|
+
execute
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def execute
|
|
95
|
+
return if @executed
|
|
96
|
+
@executed = true
|
|
97
|
+
|
|
98
|
+
res = @js.call(:exec, @sql)
|
|
99
|
+
@columns = res[:cols].to_a.map(&:to_s)
|
|
100
|
+
@rows = res[:rows].to_a.map do |row|
|
|
101
|
+
row.to_a.map do |val|
|
|
102
|
+
str = val.to_s
|
|
103
|
+
case val.typeof
|
|
104
|
+
when 'string' then str
|
|
105
|
+
when 'boolean' then str == 'true'
|
|
106
|
+
when 'number' then str.include?('.') ? val.to_f : val.to_i
|
|
107
|
+
else str == 'null' ? nil : str
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def column_count = (execute; @columns.size)
|
|
114
|
+
def types = (execute; Array.new(@columns.size)) # nil → default type
|
|
115
|
+
def to_a = (execute; @rows)
|
|
116
|
+
def close; end
|
|
117
|
+
def reset!; @executed = false; @sql = @base_sql; end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def quote_value(v)
|
|
122
|
+
case v
|
|
123
|
+
when NilClass then 'NULL'
|
|
124
|
+
when TrueClass then '1'
|
|
125
|
+
when FalseClass then '0'
|
|
126
|
+
when Numeric then v.to_s
|
|
127
|
+
else "'#{v.to_s.gsub("'", "''")}'"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
class << self
|
|
133
|
+
def database_exists?(_config) = true
|
|
134
|
+
def new_client(_config) = ExternalInterface.new
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def initialize(...)
|
|
138
|
+
# Bypass SQLite3Adapter's native-gem constructor; use AbstractAdapter's.
|
|
139
|
+
AbstractAdapter.instance_method(:initialize).bind_call(self, ...)
|
|
140
|
+
@prepared_statements = false
|
|
141
|
+
@memory_database = false
|
|
142
|
+
@connection_parameters = @config.merge(
|
|
143
|
+
database: @config[:database].to_s,
|
|
144
|
+
results_as_hash: true
|
|
145
|
+
)
|
|
146
|
+
@use_insert_returning = @config.key?(:insert_returning) \
|
|
147
|
+
? self.class.type_cast_config_to_boolean(@config[:insert_returning]) \
|
|
148
|
+
: true
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def database_exists? = true
|
|
152
|
+
def database_version = SQLite3Adapter::Version.new('3.45.1')
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
ActiveRecord::ConnectionAdapters.register(
|
|
158
|
+
'wasm_sqlite3',
|
|
159
|
+
'ActiveRecord::ConnectionAdapters::WasmSqlite3Adapter'
|
|
160
|
+
) { ActiveRecord::ConnectionAdapters::WasmSqlite3Adapter }
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
require "rails/railtie"
|
|
2
|
+
|
|
3
|
+
module WasmRails
|
|
4
|
+
class Railtie < Rails::Railtie
|
|
5
|
+
# In WASM mode, pre-require turbo-rails app-dir constants before Rails
|
|
6
|
+
# eager-loads ActionController::Base and fires the on_load hooks.
|
|
7
|
+
# Zeitwerk can't autoload from gem app/ dirs in WASM so we do it manually.
|
|
8
|
+
initializer "wasm_rails.turbo_namespaces", before: :load_config_initializers do
|
|
9
|
+
if WasmRails.wasm?
|
|
10
|
+
module Turbo
|
|
11
|
+
module Streams; end
|
|
12
|
+
module Frames; end
|
|
13
|
+
module Native; end
|
|
14
|
+
end
|
|
15
|
+
require "turbo/streams/action_helper"
|
|
16
|
+
require "turbo/streams/turbo_streams_tag_builder"
|
|
17
|
+
require "turbo/frames/frame_request"
|
|
18
|
+
require "turbo/native/navigation"
|
|
19
|
+
require "turbo/request_id_tracking"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|