janela 0.1.1 → 0.2.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.
package/runtime/janela.ts CHANGED
@@ -2,12 +2,13 @@
2
2
  //
3
3
  // The Tauri-shaped surface: one `__invoke` binding carries every command as a
4
4
  // (name, argsJson) envelope, dispatched to handlers registered on the app
5
- // object. Backend→frontend events ride wv_eval into the injected bootstrap.
5
+ // object. Handlers see decoded values — the runtime owns JSON at the boundary.
6
+ // Backend→frontend events ride wv_eval into the injected bootstrap.
6
7
  //
7
8
  // NOTE ON STYLE: every FFI call whose result initializes a variable is written
8
- // `f(...) + 0`. scriptc 0.0.32 miscompiles a bare FFI call used as a complete
9
- // initializer/assignment RHS (see FINDINGS.md); any enclosing expression is
10
- // the workaround.
9
+ // `f(...) + 0`. scriptc miscompiles a bare FFI call used as a complete
10
+ // initializer/assignment RHS — still true in 0.0.35, and reported upstream as
11
+ // vercel-labs/scriptc#21. Any enclosing expression is the workaround.
11
12
 
12
13
  declare function wvCreate(debug: number): number;
13
14
  declare function wvSetTitle(h: number, title: string): number;
@@ -16,12 +17,23 @@ declare function wvSetHtml(h: number, html: string): number;
16
17
  declare function wvInit(h: number, js: string): number;
17
18
  declare function wvEval(h: number, js: string): number;
18
19
  declare function wvBind(h: number, name: string): number;
19
- declare function wvReqLen(h: number): number;
20
- declare function wvReqByte(h: number, i: number): number;
21
- declare function wvReplyReset(h: number): number;
22
- declare function wvReplyPush(h: number, b: number): number;
23
- declare function wvRun(h: number, cb: (bindIndex: number, seq: number) => number): number;
20
+ declare function wvReply(h: number, body: string): number;
21
+ declare function wvOnInvoke(h: number, cb: (req: string) => number): number;
22
+ declare function wvOnTick(h: number, cb: () => void): number;
23
+ declare function wvRun(h: number): number;
24
24
  declare function wvTerminate(h: number): number;
25
+ declare function wvDefer(h: number): number;
26
+ declare function wvResolve(h: number, id: number, status: number): number;
27
+ declare function wvTickStart(h: number, intervalMs: number): number;
28
+ declare function wvTickStop(h: number): number;
29
+ declare function wvFsRead(h: number, path: string): number;
30
+ declare function wvFsWrite(h: number, path: string, data: string): number;
31
+ declare function wvFsStatus(h: number, id: number): number;
32
+ declare function wvFsTake(h: number, id: number, sink: (text: string) => void): number;
33
+ declare function wvFsFree(h: number, id: number): number;
34
+
35
+ const FS_PENDING = 0;
36
+ const FS_OK = 1;
25
37
 
26
38
  // Injected into every page before it loads (webview_init).
27
39
  const BOOTSTRAP =
@@ -40,10 +52,34 @@ const BOOTSTRAP =
40
52
  " for (var i = 0; i < cbs.length; i++) cbs[i](payload);" +
41
53
  "};";
42
54
 
43
- // Handlers receive the invoke args as JSON text and must return JSON text
44
- // (what the frontend promise resolves with). Throwing is not supported by
45
- // scriptc across the FFI boundary — return an error envelope instead.
46
- export type CommandHandler = (argsJson: string) => string;
55
+ // Handlers take the invoked arguments as a value and return a value; the
56
+ // runtime owns JSON at the boundary. `args` is whatever the page passed to
57
+ // janela.invoke(name, args) — cast it to the shape you expect. The return
58
+ // value is what the page's promise resolves with.
59
+ //
60
+ // Throwing is not supported by scriptc across the FFI boundary. Use
61
+ // commandAsync's `reject` to fail a call, or return an error value.
62
+ export type CommandHandler = (args: unknown) => unknown;
63
+
64
+ /**
65
+ * An async command: return immediately, answer later. `resolve`/`reject` take
66
+ * a value and settle the page's `await janela.invoke(...)` promise whenever
67
+ * they are called — from a later defer()/sleep() turn, or from another
68
+ * command. The window stays responsive for as long as the call is pending.
69
+ */
70
+ export type AsyncCommandHandler = (
71
+ args: unknown,
72
+ resolve: (value: unknown) => void,
73
+ reject: (reason: unknown) => void,
74
+ ) => void;
75
+
76
+ /**
77
+ * Completion of an async file operation. `err` is null on success; on failure
78
+ * it carries a Node-shaped message ("ENOENT: no such file or directory, open
79
+ * '/x'") and `text` is empty. Errors arrive as values, never as throws —
80
+ * scriptc cannot propagate an exception across the FFI boundary.
81
+ */
82
+ export type FsCallback = (err: string | null, text: string) => void;
47
83
 
48
84
  export interface WindowConfig {
49
85
  title: string;
@@ -57,85 +93,39 @@ export interface JanelaApp {
57
93
  handlers: CommandHandler[];
58
94
  /** Register a named command, callable from the page as janela.invoke(name, args). */
59
95
  command: (name: string, h: CommandHandler) => void;
60
- /** Fire an event into the page; payloadJson must be valid JSON text. */
61
- emit: (event: string, payloadJson: string) => void;
96
+ /** Register a command that answers later; see AsyncCommandHandler. */
97
+ commandAsync: (name: string, h: AsyncCommandHandler) => void;
98
+ /** Run fn on the next turn of the host loop — the way to slice long work. */
99
+ defer: (fn: () => void) => void;
100
+ /** Run fn after at least ms. The host loop's timer; scriptc's setTimeout
101
+ * cannot fire while the window is open (its loop is parked inside run()). */
102
+ sleep: (ms: number, fn: () => void) => void;
103
+ /**
104
+ * Read a file without blocking the window. The syscall runs on a shim
105
+ * worker thread; the callback lands on the UI thread on a later turn.
106
+ * Prefer this over node:fs readFileSync inside a command — that one blocks
107
+ * the loop, and with it the whole window.
108
+ */
109
+ readFileAsync: (path: string, cb: FsCallback) => void;
110
+ /** Write a file without blocking the window; cb(null) on success. */
111
+ writeFileAsync: (
112
+ path: string,
113
+ data: string,
114
+ cb: (err: string | null) => void,
115
+ ) => void;
116
+ /** Fire an event into the page; the payload is delivered as a value. */
117
+ emit: (event: string, payload: unknown) => void;
62
118
  /** Close the window and make run() return. */
63
119
  quit: () => void;
64
120
  /** Show the page and block until the window closes. Returns the run status. */
65
121
  run: (html: string) => number;
66
122
  }
67
123
 
68
- const HEX = "0123456789abcdef";
69
-
70
- function uEscape(unit: number): string {
71
- return (
72
- "\\u" +
73
- HEX.charAt((unit >> 12) & 0xf) +
74
- HEX.charAt((unit >> 8) & 0xf) +
75
- HEX.charAt((unit >> 4) & 0xf) +
76
- HEX.charAt(unit & 0xf)
77
- );
78
- }
79
-
80
- // The request arrives as UTF-8 JSON bytes. scriptc strings cannot hold lone
81
- // surrogates (String.fromCharCode(0xd83d) yields a replacement char), so we
82
- // never build non-ASCII chars directly: every code point >= 0x80 is re-emitted
83
- // as a JSON \uXXXX escape (a surrogate PAIR of escapes for astral planes),
84
- // which JSON.parse reconstructs correctly. Legal only because the payload is
85
- // always JSON, where non-ASCII can only occur inside strings.
86
- function readRequest(h: number): string {
87
- let out = "";
88
- const n = wvReqLen(h) + 0;
89
- let i = 0;
90
- while (i < n) {
91
- const b0 = wvReqByte(h, i) + 0;
92
- i = i + 1;
93
- let cp = b0;
94
- if ((b0 & 0xe0) === 0xc0 && i < n) {
95
- cp = ((b0 & 0x1f) << 6) | (wvReqByte(h, i) & 0x3f);
96
- i = i + 1;
97
- } else if ((b0 & 0xf0) === 0xe0 && i + 1 < n) {
98
- cp = ((b0 & 0x0f) << 12) | ((wvReqByte(h, i) & 0x3f) << 6) | (wvReqByte(h, i + 1) & 0x3f);
99
- i = i + 2;
100
- } else if ((b0 & 0xf8) === 0xf0 && i + 2 < n) {
101
- cp =
102
- ((b0 & 0x07) << 18) |
103
- ((wvReqByte(h, i) & 0x3f) << 12) |
104
- ((wvReqByte(h, i + 1) & 0x3f) << 6) |
105
- (wvReqByte(h, i + 2) & 0x3f);
106
- i = i + 3;
107
- }
108
- if (cp < 0x80) {
109
- out = out + String.fromCharCode(cp);
110
- } else if (cp < 0x10000) {
111
- out = out + uEscape(cp);
112
- } else {
113
- const v = cp - 0x10000;
114
- out = out + uEscape(0xd800 + (v >> 10)) + uEscape(0xdc00 + (v & 0x3ff));
115
- }
116
- }
117
- return out;
118
- }
119
-
120
- // The reply must be valid JSON when it reaches the page. Non-ASCII chars can
121
- // only legally occur inside JSON strings, where a \uXXXX escape is always
122
- // equivalent — so escaping every char >127 keeps the byte channel ASCII-clean
123
- // (surrogate halves escape individually, which JSON also permits).
124
- function writeReply(h: number, body: string): void {
125
- wvReplyReset(h);
126
- for (let i = 0; i < body.length; i++) {
127
- const c = body.charCodeAt(i);
128
- if (c < 0x80) {
129
- wvReplyPush(h, c);
130
- } else {
131
- wvReplyPush(h, 92); // backslash
132
- wvReplyPush(h, 117); // 'u'
133
- wvReplyPush(h, HEX.charCodeAt((c >> 12) & 0xf));
134
- wvReplyPush(h, HEX.charCodeAt((c >> 8) & 0xf));
135
- wvReplyPush(h, HEX.charCodeAt((c >> 4) & 0xf));
136
- wvReplyPush(h, HEX.charCodeAt(c & 0xf));
137
- }
138
- }
124
+ // JSON.stringify yields undefined for undefined; the wire always needs a
125
+ // value, and a command that returns nothing should read as null in the page.
126
+ function encode(value: unknown): string {
127
+ if (value === undefined) return "null";
128
+ return JSON.stringify(value);
139
129
  }
140
130
 
141
131
  export function createApp(cfg: WindowConfig): JanelaApp {
@@ -144,6 +134,100 @@ export function createApp(cfg: WindowConfig): JanelaApp {
144
134
  wvSetSize(h, cfg.width, cfg.height, 0);
145
135
  wvInit(h, BOOTSTRAP);
146
136
 
137
+ // ---- the host loop -------------------------------------------------------
138
+ // scriptc's event loop is parked for as long as the program sits inside the
139
+ // wvRun() FFI call, so setTimeout/await never fire while the window is open.
140
+ // These queues are drained instead by the retained tick handler that the
141
+ // shim's ticker posts to the UI thread, and the ticker only runs while there
142
+ // is work — an idle app costs nothing.
143
+ const asyncNames: string[] = [];
144
+ const asyncHandlers: AsyncCommandHandler[] = [];
145
+ let taskFns: (() => void)[] = [];
146
+ let timerFns: (() => void)[] = [];
147
+ let timerDue: number[] = [];
148
+ let fsIds: number[] = [];
149
+ let fsCbs: FsCallback[] = [];
150
+ let ticking = false;
151
+
152
+ const wake = (): void => {
153
+ if (ticking) return;
154
+ ticking = true;
155
+ wvTickStart(h, 8);
156
+ };
157
+
158
+ const idle = (): void => {
159
+ if (!ticking) return;
160
+ if (taskFns.length > 0 || timerFns.length > 0 || fsIds.length > 0) return;
161
+ ticking = false;
162
+ wvTickStop(h);
163
+ };
164
+
165
+ // One turn of the loop: every task queued so far, plus every due timer.
166
+ // Tasks queued *by* this turn wait for the next one, so a defer() chain
167
+ // yields to the UI between slices instead of starving it.
168
+ const turn = (): void => {
169
+ const tasks = taskFns;
170
+ taskFns = [];
171
+ for (let i = 0; i < tasks.length; i++) tasks[i]();
172
+
173
+ if (timerFns.length > 0) {
174
+ const now = Date.now() + 0;
175
+ const keptFns: (() => void)[] = [];
176
+ const keptDue: number[] = [];
177
+ const fire: (() => void)[] = [];
178
+ for (let i = 0; i < timerFns.length; i++) {
179
+ if (timerDue[i] <= now) {
180
+ fire.push(timerFns[i]);
181
+ } else {
182
+ keptFns.push(timerFns[i]);
183
+ keptDue.push(timerDue[i]);
184
+ }
185
+ }
186
+ timerFns = keptFns;
187
+ timerDue = keptDue;
188
+ for (let i = 0; i < fire.length; i++) fire[i]();
189
+ }
190
+
191
+ // Finished file jobs: the worker thread has already done the blocking
192
+ // syscall, so all that happens on this (UI) thread is the drain.
193
+ if (fsIds.length > 0) {
194
+ const keptIds: number[] = [];
195
+ const keptCbs: FsCallback[] = [];
196
+ const doneIds: number[] = [];
197
+ const doneCbs: FsCallback[] = [];
198
+ const doneOk: boolean[] = [];
199
+ for (let i = 0; i < fsIds.length; i++) {
200
+ const st = wvFsStatus(h, fsIds[i]) + 0;
201
+ if (st === FS_PENDING) {
202
+ keptIds.push(fsIds[i]);
203
+ keptCbs.push(fsCbs[i]);
204
+ } else {
205
+ doneIds.push(fsIds[i]);
206
+ doneCbs.push(fsCbs[i]);
207
+ doneOk.push(st === FS_OK);
208
+ }
209
+ }
210
+ fsIds = keptIds;
211
+ fsCbs = keptCbs;
212
+ for (let i = 0; i < doneIds.length; i++) {
213
+ // On failure the payload IS the error message, so one take serves both
214
+ // outcomes. The sink runs synchronously inside wvFsTake (the callback
215
+ // is lifetime:"call"), so `payload` is set by the time it returns.
216
+ let payload = "";
217
+ wvFsTake(h, doneIds[i], (text) => {
218
+ payload = text;
219
+ });
220
+ wvFsFree(h, doneIds[i]);
221
+ if (doneOk[i]) {
222
+ doneCbs[i](null, payload);
223
+ } else {
224
+ doneCbs[i](payload, "");
225
+ }
226
+ }
227
+ }
228
+ idle();
229
+ };
230
+
147
231
  const app: JanelaApp = {
148
232
  handle: h,
149
233
  names: [],
@@ -154,10 +238,50 @@ export function createApp(cfg: WindowConfig): JanelaApp {
154
238
  app.handlers.push(handler);
155
239
  },
156
240
 
157
- emit: (event, payloadJson) => {
241
+ commandAsync: (name, handler) => {
242
+ asyncNames.push(name);
243
+ asyncHandlers.push(handler);
244
+ },
245
+
246
+ defer: (fn) => {
247
+ taskFns.push(fn);
248
+ wake();
249
+ },
250
+
251
+ sleep: (ms, fn) => {
252
+ timerFns.push(fn);
253
+ timerDue.push(Date.now() + (ms > 0 ? ms : 0));
254
+ wake();
255
+ },
256
+
257
+ readFileAsync: (path, cb) => {
258
+ const id = wvFsRead(h, path) + 0;
259
+ if (id < 0) {
260
+ app.defer(() => cb("EAGAIN: could not start a read of '" + path + "'", ""));
261
+ return;
262
+ }
263
+ fsIds.push(id);
264
+ fsCbs.push(cb);
265
+ wake();
266
+ },
267
+
268
+ writeFileAsync: (path, data, cb) => {
269
+ const id = wvFsWrite(h, path, data) + 0;
270
+ if (id < 0) {
271
+ app.defer(() => cb("EAGAIN: could not start a write of '" + path + "'"));
272
+ return;
273
+ }
274
+ fsIds.push(id);
275
+ // The write payload is empty on success; the shared callback shape just
276
+ // ignores the text argument.
277
+ fsCbs.push((err, _text) => cb(err));
278
+ wake();
279
+ },
280
+
281
+ emit: (event, payload) => {
158
282
  wvEval(
159
283
  h,
160
- "window.__wvEmit(" + JSON.stringify(event) + "," + payloadJson + ");",
284
+ "window.__wvEmit(" + JSON.stringify(event) + "," + encode(payload) + ");",
161
285
  );
162
286
  },
163
287
 
@@ -166,26 +290,49 @@ export function createApp(cfg: WindowConfig): JanelaApp {
166
290
  },
167
291
 
168
292
  run: (html) => {
169
- const INVOKE = wvBind(h, "__invoke") + 0;
170
- wvSetHtml(h, html);
171
-
172
- const rc = wvRun(h, (bindIndex, _seq) => {
173
- if (bindIndex !== INVOKE) {
174
- writeReply(h, '"unknown binding"');
175
- return 1;
176
- }
177
- const env = JSON.parse(readRequest(h)) as string[];
293
+ // Both handlers are retained: registered once here, called by the shim
294
+ // for as long as the window is open.
295
+ wvOnTick(h, turn);
296
+ wvOnInvoke(h, (req) => {
297
+ const env = JSON.parse(req) as string[];
178
298
  const cmd = env[0];
179
- const argsJson = env[1];
299
+ const args = JSON.parse(env[1]) as unknown;
180
300
  for (let i = 0; i < app.names.length; i++) {
181
301
  if (app.names[i] === cmd) {
182
- writeReply(h, app.handlers[i](argsJson));
302
+ wvReply(h, encode(app.handlers[i](args)));
183
303
  return 0;
184
304
  }
185
305
  }
186
- writeReply(h, JSON.stringify("unknown command: " + cmd));
306
+ for (let i = 0; i < asyncNames.length; i++) {
307
+ if (asyncNames[i] === cmd) {
308
+ // Park the page's promise: the shim holds this call's id and
309
+ // answers it when resolve/reject reaches wvResolve, whenever
310
+ // that is. Meanwhile the loop is free to serve other calls.
311
+ const id = wvDefer(h) + 0;
312
+ if (id < 0) {
313
+ wvReply(h, encode("cannot defer command: " + cmd));
314
+ return 1;
315
+ }
316
+ const settle = (status: number): ((value: unknown) => void) => {
317
+ let done = false;
318
+ return (value: unknown) => {
319
+ if (done) return; // a promise settles once
320
+ done = true;
321
+ wvReply(h, encode(value));
322
+ wvResolve(h, id, status);
323
+ };
324
+ };
325
+ asyncHandlers[i](args, settle(0), settle(1));
326
+ return 0;
327
+ }
328
+ }
329
+ wvReply(h, encode("unknown command: " + cmd));
187
330
  return 1; // rejects the frontend promise
188
- }) + 0;
331
+ });
332
+
333
+ wvBind(h, "__invoke");
334
+ wvSetHtml(h, html);
335
+ const rc = wvRun(h) + 0;
189
336
  return rc;
190
337
  },
191
338
  };