1/*
2  2022-05-20
3
4  The author disclaims copyright to this source code.  In place of a
5  legal notice, here is a blessing:
6
7  *   May you do good and not evil.
8  *   May you find forgiveness for yourself and forgive others.
9  *   May you share freely, never taking more than you give.
10
11  ***********************************************************************
12
13  This is the JS Worker file for the sqlite3 fiddle app. It loads the
14  sqlite3 wasm module and offers access to the db via the Worker
15  message-passing interface.
16
17  Forewarning: this API is still very much Under Construction and
18  subject to any number of changes as experience reveals what those
19  need to be.
20
21  Because we can have only a single message handler, as opposed to an
22  arbitrary number of discrete event listeners like with DOM elements,
23  we have to define a lower-level message API. Messages abstractly
24  look like:
25
26  { type: string, data: type-specific value }
27
28  Where 'type' is used for dispatching and 'data' is a
29  'type'-dependent value.
30
31  The 'type' values expected by each side of the main/worker
32  connection vary. The types are described below but subject to
33  change at any time as this experiment evolves.
34
35  Workers-to-Main types
36
37  - stdout, stderr: indicate stdout/stderr output from the wasm
38    layer. The data property is the string of the output, noting
39    that the emscripten binding emits these one line at a time. Thus,
40    if a C-side puts() emits multiple lines in a single call, the JS
41    side will see that as multiple calls. Example:
42
43    {type:'stdout', data: 'Hi, world.'}
44
45  - module: Status text. This is intended to alert the main thread
46    about module loading status so that, e.g., the main thread can
47    update a progress widget and DTRT when the module is finished
48    loading and available for work. Status messages come in the form
49
50    {type:'module', data:{
51        type:'status',
52        data: {text:string|null, step:1-based-integer}
53    }
54
55    with an incrementing step value for each subsequent message. When
56    the module loading is complete, a message with a text value of
57    null is posted.
58
59  - working: data='start'|'end'. Indicates that work is about to be
60    sent to the module or has just completed. This can be used, e.g.,
61    to disable UI elements which should not be activated while work
62    is pending. Example:
63
64    {type:'working', data:'start'}
65
66  Main-to-Worker types:
67
68  - shellExec: data=text to execute as if it had been entered in the
69    sqlite3 CLI shell app (as opposed to sqlite3_exec()). This event
70    causes the worker to emit a 'working' event (data='start') before
71    it starts and a 'working' event (data='end') when it finished. If
72    called while work is currently being executed it emits stderr
73    message instead of doing actual work, as the underlying db cannot
74    handle concurrent tasks. Example:
75
76    {type:'shellExec', data: 'select * from sqlite_master'}
77
78  - More TBD as the higher-level db layer develops.
79*/
80
81/*
82  Apparent browser(s) bug: console messages emitted may be duplicated
83  in the console, even though they're provably only run once. See:
84
85  https://stackoverflow.com/questions/49659464
86
87  Noting that it happens in Firefox as well as Chrome. Harmless but
88  annoying.
89*/
90"use strict";
91(function(){
92  /**
93     Posts a message in the form {type,data} unless passed more than 2
94     args, in which case it posts {type, data:[arg1...argN]}.
95  */
96  const wMsg = function(type,data){
97    postMessage({
98      type,
99      data: arguments.length<3
100        ? data
101        : Array.prototype.slice.call(arguments,1)
102    });
103  };
104
105  const stdout = function(){wMsg('stdout', Array.prototype.slice.call(arguments));};
106  const stderr = function(){wMsg('stderr', Array.prototype.slice.call(arguments));};
107
108  self.onerror = function(/*message, source, lineno, colno, error*/) {
109    const err = arguments[4];
110    if(err && 'ExitStatus'==err.name){
111      /* This is relevant for the sqlite3 shell binding but not the
112         lower-level binding. */
113      fiddleModule.isDead = true;
114      stderr("FATAL ERROR:", err.message);
115      stderr("Restarting the app requires reloading the page.");
116      wMsg('error', err);
117    }
118    console.error(err);
119    fiddleModule.setStatus('Exception thrown, see JavaScript console: '+err);
120  };
121
122  const Sqlite3Shell = {
123    /** Returns the name of the currently-opened db. */
124    dbFilename: function f(){
125      if(!f._) f._ = fiddleModule.cwrap('fiddle_db_filename', "string", ['string']);
126      return f._();
127    },
128    /**
129       Runs the given text through the shell as if it had been typed
130       in by a user. Fires a working/start event before it starts and
131       working/end event when it finishes.
132    */
133    exec: function f(sql){
134      if(!f._) f._ = fiddleModule.cwrap('fiddle_exec', null, ['string']);
135      if(fiddleModule.isDead){
136        stderr("shell module has exit()ed. Cannot run SQL.");
137        return;
138      }
139      wMsg('working','start');
140      try {
141        if(f._running){
142          stderr('Cannot run multiple commands concurrently.');
143        }else{
144          f._running = true;
145          f._(sql);
146        }
147      } finally {
148        delete f._running;
149        wMsg('working','end');
150      }
151    },
152    resetDb: function f(){
153      if(!f._) f._ = fiddleModule.cwrap('fiddle_reset_db', null);
154      stdout("Resetting database.");
155      f._();
156      stdout("Reset",this.dbFilename());
157    },
158    /* Interrupt can't work: this Worker is tied up working, so won't get the
159       interrupt event which would be needed to perform the interrupt. */
160    interrupt: function f(){
161      if(!f._) f._ = fiddleModule.cwrap('fiddle_interrupt', null);
162      stdout("Requesting interrupt.");
163      f._();
164    }
165  };
166
167  self.onmessage = function f(ev){
168    ev = ev.data;
169    if(!f.cache){
170      f.cache = {
171        prevFilename: null
172      };
173    }
174    //console.debug("worker: onmessage.data",ev);
175    switch(ev.type){
176        case 'shellExec': Sqlite3Shell.exec(ev.data); return;
177        case 'db-reset': Sqlite3Shell.resetDb(); return;
178        case 'interrupt': Sqlite3Shell.interrupt(); return;
179          /** Triggers the export of the current db. Fires an
180              event in the form:
181
182              {type:'db-export',
183                data:{
184                  filename: name of db,
185                  buffer: contents of the db file (Uint8Array),
186                  error: on error, a message string and no buffer property.
187                }
188              }
189          */
190        case 'db-export': {
191          const fn = Sqlite3Shell.dbFilename();
192          stdout("Exporting",fn+".");
193          const fn2 = fn ? fn.split(/[/\\]/).pop() : null;
194          try{
195            if(!fn2) throw new Error("DB appears to be closed.");
196            wMsg('db-export',{
197              filename: fn2,
198              buffer: fiddleModule.FS.readFile(fn, {encoding:"binary"})
199            });
200          }catch(e){
201            /* Post a failure message so that UI elements disabled
202               during the export can be re-enabled. */
203            wMsg('db-export',{
204              filename: fn,
205              error: e.message
206            });
207          }
208          return;
209        }
210        case 'open': {
211          /* Expects: {
212             buffer: ArrayBuffer | Uint8Array,
213             filename: for logging/informational purposes only
214             } */
215          const opt = ev.data;
216          let buffer = opt.buffer;
217          if(buffer instanceof Uint8Array){
218          }else if(buffer instanceof ArrayBuffer){
219            buffer = new Uint8Array(buffer);
220          }else{
221            stderr("'open' expects {buffer:Uint8Array} containing an uploaded db.");
222            return;
223          }
224          const fn = (
225            opt.filename
226              ? opt.filename.split(/[/\\]/).pop().replace('"','_')
227              : ("db-"+((Math.random() * 10000000) | 0)+
228                 "-"+((Math.random() * 10000000) | 0)+".sqlite3")
229          );
230          /* We cannot delete the existing db file until the new one
231             is installed, which means that we risk overflowing our
232             quota (if any) by having both the previous and current
233             db briefly installed in the virtual filesystem. */
234          fiddleModule.FS.createDataFile("/", fn, buffer, true, true);
235          const oldName = Sqlite3Shell.dbFilename();
236          Sqlite3Shell.exec('.open "/'+fn+'"');
237          if(oldName && oldName !== fn){
238            try{fiddleModule.fsUnlink(oldName);}
239            catch(e){/*ignored*/}
240          }
241          stdout("Replaced DB with",fn+".");
242          return;
243        }
244    };
245    console.warn("Unknown fiddle-worker message type:",ev);
246  };
247
248  /**
249     emscripten module for use with build mode -sMODULARIZE.
250  */
251  const fiddleModule = {
252    print: stdout,
253    printErr: stderr,
254    /**
255       Intercepts status updates from the emscripting module init
256       and fires worker events with a type of 'status' and a
257       payload of:
258
259       {
260       text: string | null, // null at end of load process
261       step: integer // starts at 1, increments 1 per call
262       }
263
264       We have no way of knowing in advance how many steps will
265       be processed/posted, so creating a "percentage done" view is
266       not really practical. One can be approximated by giving it a
267       current value of message.step and max value of message.step+1,
268       though.
269
270       When work is finished, a message with a text value of null is
271       submitted.
272
273       After a message with text==null is posted, the module may later
274       post messages about fatal problems, e.g. an exit() being
275       triggered, so it is recommended that UI elements for posting
276       status messages not be outright removed from the DOM when
277       text==null, and that they instead be hidden until/unless
278       text!=null.
279    */
280    setStatus: function f(text){
281      if(!f.last) f.last = { step: 0, text: '' };
282      else if(text === f.last.text) return;
283      f.last.text = text;
284      wMsg('module',{
285        type:'status',
286        data:{step: ++f.last.step, text: text||null}
287      });
288    }
289  };
290
291  importScripts('fiddle-module.js');
292  /**
293     initFiddleModule() is installed via fiddle-module.js due to
294     building with:
295
296     emcc ... -sMODULARIZE=1 -sEXPORT_NAME=initFiddleModule
297  */
298  initFiddleModule(fiddleModule).then(function(thisModule){
299    fiddleModule.fsUnlink = fiddleModule.cwrap('sqlite3_wasm_vfs_unlink','number',['string']);
300    (function initOpfs(){
301      if(!self.FileSystemHandle || !self.FileSystemDirectoryHandle
302         || !self.FileSystemFileHandle){
303        stdout("OPFS unavailable. All DB state is transient.");
304        return;
305      }
306      try {
307        if(0===fiddleModule.ccall('sqlite3_wasm_init_opfs', undefined)){
308          stdout("Initialized OPFS WASMFS backend.");
309        }else{
310          stderr("Initialization of OPFS WASMFS backend failed.");
311        }
312      }catch(e){
313        stderr("Apparently missing WASMFS:",e.message);
314      }
315    })();
316    wMsg('fiddle-ready');
317  });
318})();
319