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.FS.unlink(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        wMsg('fiddle-ready');
300    });
301})();
302