janela 0.1.2 → 0.3.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.
Files changed (38) hide show
  1. package/README.md +135 -26
  2. package/bin/janela.mjs +382 -46
  3. package/package.json +3 -3
  4. package/runtime/janela.ts +289 -122
  5. package/shim/wvshim.cc +726 -67
  6. package/templates/index.html +31 -0
  7. package/templates/main.ts +62 -19
  8. package/templates/react/deps.json +4 -0
  9. package/templates/react/files/index.html +11 -0
  10. package/templates/react/files/janela.conf.json +10 -0
  11. package/templates/react/files/src/App.css +3 -0
  12. package/templates/react/files/src/App.jsx +38 -0
  13. package/templates/react/files/src/main.jsx +9 -0
  14. package/templates/react/files/src-host/main.ts +42 -0
  15. package/templates/react/files/vite.config.js +6 -0
  16. package/templates/solid/deps.json +4 -0
  17. package/templates/solid/files/index.html +11 -0
  18. package/templates/solid/files/janela.conf.json +10 -0
  19. package/templates/solid/files/src/App.css +3 -0
  20. package/templates/solid/files/src/App.jsx +35 -0
  21. package/templates/solid/files/src/main.jsx +4 -0
  22. package/templates/solid/files/src-host/main.ts +42 -0
  23. package/templates/solid/files/vite.config.js +6 -0
  24. package/templates/svelte/deps.json +7 -0
  25. package/templates/svelte/files/index.html +11 -0
  26. package/templates/svelte/files/janela.conf.json +10 -0
  27. package/templates/svelte/files/src/App.svelte +33 -0
  28. package/templates/svelte/files/src/main.js +4 -0
  29. package/templates/svelte/files/src-host/main.ts +42 -0
  30. package/templates/svelte/files/svelte.config.js +3 -0
  31. package/templates/svelte/files/vite.config.js +6 -0
  32. package/templates/vue/deps.json +4 -0
  33. package/templates/vue/files/index.html +11 -0
  34. package/templates/vue/files/janela.conf.json +10 -0
  35. package/templates/vue/files/src/App.vue +39 -0
  36. package/templates/vue/files/src/main.js +4 -0
  37. package/templates/vue/files/src-host/main.ts +42 -0
  38. package/templates/vue/files/vite.config.js +6 -0
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,19 +17,38 @@ 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
25
  declare function wvDefer(h: number): number;
26
26
  declare function wvResolve(h: number, id: number, status: number): number;
27
27
  declare function wvTickStart(h: number, intervalMs: number): number;
28
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 wvJobStatus(h: number, id: number): number;
32
+ declare function wvJobTake(h: number, id: number, sink: (text: string) => void): number;
33
+ declare function wvJobFree(h: number, id: number): number;
34
+ declare function wvDialog(
35
+ h: number,
36
+ kind: number,
37
+ flags: number,
38
+ title: string,
39
+ defaultPath: string,
40
+ defaultName: string,
41
+ filters: string,
42
+ ): number;
43
+ declare function wvSetFullscreen(h: number, on: number): number;
29
44
 
30
- // Bind index the shim uses for a timer tick rather than a page invoke.
31
- const TICK_BIND = 4294967295;
45
+ const JOB_PENDING = 0;
46
+ const JOB_OK = 1;
47
+
48
+ const DLG_OPEN = 0;
49
+ const DLG_SAVE = 1;
50
+ const DLG_MULTIPLE = 1;
51
+ const DLG_DIRECTORY = 2;
32
52
 
33
53
  // Injected into every page before it loads (webview_init).
34
54
  const BOOTSTRAP =
@@ -47,23 +67,61 @@ const BOOTSTRAP =
47
67
  " for (var i = 0; i < cbs.length; i++) cbs[i](payload);" +
48
68
  "};";
49
69
 
50
- // Handlers receive the invoke args as JSON text and must return JSON text
51
- // (what the frontend promise resolves with). Throwing is not supported by
52
- // scriptc across the FFI boundary return an error envelope instead.
53
- export type CommandHandler = (argsJson: string) => string;
70
+ // Handlers take the invoked arguments as a value and return a value; the
71
+ // runtime owns JSON at the boundary. `args` is whatever the page passed to
72
+ // janela.invoke(name, args) cast it to the shape you expect. The return
73
+ // value is what the page's promise resolves with.
74
+ //
75
+ // Throwing is not supported by scriptc across the FFI boundary. Use
76
+ // commandAsync's `reject` to fail a call, or return an error value.
77
+ export type CommandHandler = (args: unknown) => unknown;
54
78
 
55
79
  /**
56
80
  * An async command: return immediately, answer later. `resolve`/`reject` take
57
- * JSON text and settle the page's `await janela.invoke(...)` promise whenever
81
+ * a value and settle the page's `await janela.invoke(...)` promise whenever
58
82
  * they are called — from a later defer()/sleep() turn, or from another
59
83
  * command. The window stays responsive for as long as the call is pending.
60
84
  */
61
85
  export type AsyncCommandHandler = (
62
- argsJson: string,
63
- resolve: (json: string) => void,
64
- reject: (json: string) => void,
86
+ args: unknown,
87
+ resolve: (value: unknown) => void,
88
+ reject: (reason: unknown) => void,
65
89
  ) => void;
66
90
 
91
+ /**
92
+ * Completion of an async file operation. `err` is null on success; on failure
93
+ * it carries a Node-shaped message ("ENOENT: no such file or directory, open
94
+ * '/x'") and `text` is empty. Errors arrive as values, never as throws —
95
+ * scriptc cannot propagate an exception across the FFI boundary.
96
+ */
97
+ export type FsCallback = (err: string | null, text: string) => void;
98
+
99
+ /** A named group of extensions offered in a dialog's file-type popup. */
100
+ export interface DialogFilter {
101
+ name: string;
102
+ /** Bare extensions, no dot and no glob: ["png", "jpg"]. */
103
+ extensions: string[];
104
+ }
105
+
106
+ export interface OpenDialogOptions {
107
+ title?: string;
108
+ /** Directory the dialog opens in. */
109
+ defaultPath?: string;
110
+ /** Allow picking more than one entry. */
111
+ multiple?: boolean;
112
+ /** Pick directories instead of files. Not supported on Windows. */
113
+ directory?: boolean;
114
+ filters?: DialogFilter[];
115
+ }
116
+
117
+ export interface SaveDialogOptions {
118
+ title?: string;
119
+ defaultPath?: string;
120
+ /** Filename pre-filled in the name field. */
121
+ defaultName?: string;
122
+ filters?: DialogFilter[];
123
+ }
124
+
67
125
  export interface WindowConfig {
68
126
  title: string;
69
127
  width: number;
@@ -83,85 +141,56 @@ export interface JanelaApp {
83
141
  /** Run fn after at least ms. The host loop's timer; scriptc's setTimeout
84
142
  * cannot fire while the window is open (its loop is parked inside run()). */
85
143
  sleep: (ms: number, fn: () => void) => void;
86
- /** Fire an event into the page; payloadJson must be valid JSON text. */
87
- emit: (event: string, payloadJson: string) => void;
144
+ /**
145
+ * Read a file without blocking the window. The syscall runs on a shim
146
+ * worker thread; the callback lands on the UI thread on a later turn.
147
+ * Prefer this over node:fs readFileSync inside a command — that one blocks
148
+ * the loop, and with it the whole window.
149
+ */
150
+ readFileAsync: (path: string, cb: FsCallback) => void;
151
+ /** Write a file without blocking the window; cb(null) on success. */
152
+ writeFileAsync: (
153
+ path: string,
154
+ data: string,
155
+ cb: (err: string | null) => void,
156
+ ) => void;
157
+ /**
158
+ * Show the native "open" dialog. `cb` gets the chosen paths, or null if the
159
+ * user cancelled. The modal runs on a later turn of the UI thread, so
160
+ * calling this from inside a command does not block that command's reply —
161
+ * pair it with commandAsync when the page is waiting for the result.
162
+ */
163
+ openFileDialog: (
164
+ options: OpenDialogOptions,
165
+ cb: (paths: string[] | null, err?: string) => void,
166
+ ) => void;
167
+ /** Show the native "save" dialog; cb gets the path, or null on cancel. */
168
+ saveFileDialog: (
169
+ options: SaveDialogOptions,
170
+ cb: (path: string | null, err?: string) => void,
171
+ ) => void;
172
+ /** Change the window title at any time, not just at startup. */
173
+ setTitle: (title: string) => void;
174
+ /**
175
+ * Resize the window. `hint` is webview's sizing hint: 0 none, 1 minimum,
176
+ * 2 maximum, 3 fixed.
177
+ */
178
+ setSize: (width: number, height: number, hint?: number) => void;
179
+ /** Enter or leave fullscreen. */
180
+ setFullscreen: (on: boolean) => void;
181
+ /** Fire an event into the page; the payload is delivered as a value. */
182
+ emit: (event: string, payload: unknown) => void;
88
183
  /** Close the window and make run() return. */
89
184
  quit: () => void;
90
185
  /** Show the page and block until the window closes. Returns the run status. */
91
186
  run: (html: string) => number;
92
187
  }
93
188
 
94
- const HEX = "0123456789abcdef";
95
-
96
- function uEscape(unit: number): string {
97
- return (
98
- "\\u" +
99
- HEX.charAt((unit >> 12) & 0xf) +
100
- HEX.charAt((unit >> 8) & 0xf) +
101
- HEX.charAt((unit >> 4) & 0xf) +
102
- HEX.charAt(unit & 0xf)
103
- );
104
- }
105
-
106
- // The request arrives as UTF-8 JSON bytes. scriptc strings cannot hold lone
107
- // surrogates (String.fromCharCode(0xd83d) yields a replacement char), so we
108
- // never build non-ASCII chars directly: every code point >= 0x80 is re-emitted
109
- // as a JSON \uXXXX escape (a surrogate PAIR of escapes for astral planes),
110
- // which JSON.parse reconstructs correctly. Legal only because the payload is
111
- // always JSON, where non-ASCII can only occur inside strings.
112
- function readRequest(h: number): string {
113
- let out = "";
114
- const n = wvReqLen(h) + 0;
115
- let i = 0;
116
- while (i < n) {
117
- const b0 = wvReqByte(h, i) + 0;
118
- i = i + 1;
119
- let cp = b0;
120
- if ((b0 & 0xe0) === 0xc0 && i < n) {
121
- cp = ((b0 & 0x1f) << 6) | (wvReqByte(h, i) & 0x3f);
122
- i = i + 1;
123
- } else if ((b0 & 0xf0) === 0xe0 && i + 1 < n) {
124
- cp = ((b0 & 0x0f) << 12) | ((wvReqByte(h, i) & 0x3f) << 6) | (wvReqByte(h, i + 1) & 0x3f);
125
- i = i + 2;
126
- } else if ((b0 & 0xf8) === 0xf0 && i + 2 < n) {
127
- cp =
128
- ((b0 & 0x07) << 18) |
129
- ((wvReqByte(h, i) & 0x3f) << 12) |
130
- ((wvReqByte(h, i + 1) & 0x3f) << 6) |
131
- (wvReqByte(h, i + 2) & 0x3f);
132
- i = i + 3;
133
- }
134
- if (cp < 0x80) {
135
- out = out + String.fromCharCode(cp);
136
- } else if (cp < 0x10000) {
137
- out = out + uEscape(cp);
138
- } else {
139
- const v = cp - 0x10000;
140
- out = out + uEscape(0xd800 + (v >> 10)) + uEscape(0xdc00 + (v & 0x3ff));
141
- }
142
- }
143
- return out;
144
- }
145
-
146
- // The reply must be valid JSON when it reaches the page. Non-ASCII chars can
147
- // only legally occur inside JSON strings, where a \uXXXX escape is always
148
- // equivalent — so escaping every char >127 keeps the byte channel ASCII-clean
149
- // (surrogate halves escape individually, which JSON also permits).
150
- function writeReply(h: number, body: string): void {
151
- wvReplyReset(h);
152
- for (let i = 0; i < body.length; i++) {
153
- const c = body.charCodeAt(i);
154
- if (c < 0x80) {
155
- wvReplyPush(h, c);
156
- } else {
157
- wvReplyPush(h, 92); // backslash
158
- wvReplyPush(h, 117); // 'u'
159
- wvReplyPush(h, HEX.charCodeAt((c >> 12) & 0xf));
160
- wvReplyPush(h, HEX.charCodeAt((c >> 8) & 0xf));
161
- wvReplyPush(h, HEX.charCodeAt((c >> 4) & 0xf));
162
- wvReplyPush(h, HEX.charCodeAt(c & 0xf));
163
- }
164
- }
189
+ // JSON.stringify yields undefined for undefined; the wire always needs a
190
+ // value, and a command that returns nothing should read as null in the page.
191
+ function encode(value: unknown): string {
192
+ if (value === undefined) return "null";
193
+ return JSON.stringify(value);
165
194
  }
166
195
 
167
196
  export function createApp(cfg: WindowConfig): JanelaApp {
@@ -173,14 +202,16 @@ export function createApp(cfg: WindowConfig): JanelaApp {
173
202
  // ---- the host loop -------------------------------------------------------
174
203
  // scriptc's event loop is parked for as long as the program sits inside the
175
204
  // wvRun() FFI call, so setTimeout/await never fire while the window is open.
176
- // These queues are drained instead by TICK_BIND callbacks that the shim's
177
- // ticker posts to the UI thread, and the ticker only runs while there is
178
- // work — an idle app costs nothing.
205
+ // These queues are drained instead by the retained tick handler that the
206
+ // shim's ticker posts to the UI thread, and the ticker only runs while there
207
+ // is work — an idle app costs nothing.
179
208
  const asyncNames: string[] = [];
180
209
  const asyncHandlers: AsyncCommandHandler[] = [];
181
210
  let taskFns: (() => void)[] = [];
182
211
  let timerFns: (() => void)[] = [];
183
212
  let timerDue: number[] = [];
213
+ let jobIds: number[] = [];
214
+ let jobCbs: FsCallback[] = [];
184
215
  let ticking = false;
185
216
 
186
217
  const wake = (): void => {
@@ -191,7 +222,7 @@ export function createApp(cfg: WindowConfig): JanelaApp {
191
222
 
192
223
  const idle = (): void => {
193
224
  if (!ticking) return;
194
- if (taskFns.length > 0 || timerFns.length > 0) return;
225
+ if (taskFns.length > 0 || timerFns.length > 0 || jobIds.length > 0) return;
195
226
  ticking = false;
196
227
  wvTickStop(h);
197
228
  };
@@ -221,9 +252,96 @@ export function createApp(cfg: WindowConfig): JanelaApp {
221
252
  timerDue = keptDue;
222
253
  for (let i = 0; i < fire.length; i++) fire[i]();
223
254
  }
255
+
256
+ // Finished file jobs: the worker thread has already done the blocking
257
+ // syscall, so all that happens on this (UI) thread is the drain.
258
+ if (jobIds.length > 0) {
259
+ const keptIds: number[] = [];
260
+ const keptCbs: FsCallback[] = [];
261
+ const doneIds: number[] = [];
262
+ const doneCbs: FsCallback[] = [];
263
+ const doneOk: boolean[] = [];
264
+ for (let i = 0; i < jobIds.length; i++) {
265
+ const st = wvJobStatus(h, jobIds[i]) + 0;
266
+ if (st === JOB_PENDING) {
267
+ keptIds.push(jobIds[i]);
268
+ keptCbs.push(jobCbs[i]);
269
+ } else {
270
+ doneIds.push(jobIds[i]);
271
+ doneCbs.push(jobCbs[i]);
272
+ doneOk.push(st === JOB_OK);
273
+ }
274
+ }
275
+ jobIds = keptIds;
276
+ jobCbs = keptCbs;
277
+ for (let i = 0; i < doneIds.length; i++) {
278
+ // On failure the payload IS the error message, so one take serves both
279
+ // outcomes. The sink runs synchronously inside wvFsTake (the callback
280
+ // is lifetime:"call"), so `payload` is set by the time it returns.
281
+ let payload = "";
282
+ wvJobTake(h, doneIds[i], (text) => {
283
+ payload = text;
284
+ });
285
+ wvJobFree(h, doneIds[i]);
286
+ if (doneOk[i]) {
287
+ doneCbs[i](null, payload);
288
+ } else {
289
+ doneCbs[i](payload, "");
290
+ }
291
+ }
292
+ }
224
293
  idle();
225
294
  };
226
295
 
296
+ // Filters cross as "Name|ext,ext|Name|ext" — the shim needs no JSON parser
297
+ // for what is always a short, flat list.
298
+ const encodeFilters = (filters: DialogFilter[] | undefined): string => {
299
+ if (filters === undefined || filters.length === 0) return "";
300
+ const parts: string[] = [];
301
+ for (let i = 0; i < filters.length; i++) {
302
+ parts.push(filters[i].name);
303
+ parts.push(filters[i].extensions.join(","));
304
+ }
305
+ return parts.join("|");
306
+ };
307
+
308
+ // Both dialog kinds share one path: start the job, then let the same drain
309
+ // that serves file I/O deliver the answer on a later turn.
310
+ const startDialog = (
311
+ kind: number,
312
+ flags: number,
313
+ title: string | undefined,
314
+ defaultPath: string | undefined,
315
+ defaultName: string | undefined,
316
+ filters: DialogFilter[] | undefined,
317
+ cb: (paths: string[] | null, err?: string) => void,
318
+ ): void => {
319
+ const id = wvDialog(
320
+ h,
321
+ kind,
322
+ flags,
323
+ title === undefined ? "" : title,
324
+ defaultPath === undefined ? "" : defaultPath,
325
+ defaultName === undefined ? "" : defaultName,
326
+ encodeFilters(filters),
327
+ ) + 0;
328
+ if (id < 0) {
329
+ taskFns.push(() => cb(null, "EAGAIN: could not open a dialog"));
330
+ wake();
331
+ return;
332
+ }
333
+ jobIds.push(id);
334
+ jobCbs.push((err, text) => {
335
+ if (err !== null) {
336
+ cb(null, err);
337
+ return;
338
+ }
339
+ // "null" is a cancel; anything else is a JSON array of paths.
340
+ cb(JSON.parse(text) as string[] | null);
341
+ });
342
+ wake();
343
+ };
344
+
227
345
  const app: JanelaApp = {
228
346
  handle: h,
229
347
  names: [],
@@ -250,10 +368,65 @@ export function createApp(cfg: WindowConfig): JanelaApp {
250
368
  wake();
251
369
  },
252
370
 
253
- emit: (event, payloadJson) => {
371
+ readFileAsync: (path, cb) => {
372
+ const id = wvFsRead(h, path) + 0;
373
+ if (id < 0) {
374
+ app.defer(() => cb("EAGAIN: could not start a read of '" + path + "'", ""));
375
+ return;
376
+ }
377
+ jobIds.push(id);
378
+ jobCbs.push(cb);
379
+ wake();
380
+ },
381
+
382
+ writeFileAsync: (path, data, cb) => {
383
+ const id = wvFsWrite(h, path, data) + 0;
384
+ if (id < 0) {
385
+ app.defer(() => cb("EAGAIN: could not start a write of '" + path + "'"));
386
+ return;
387
+ }
388
+ jobIds.push(id);
389
+ // The write payload is empty on success; the shared callback shape just
390
+ // ignores the text argument.
391
+ jobCbs.push((err, _text) => cb(err));
392
+ wake();
393
+ },
394
+
395
+ openFileDialog: (options, cb) => {
396
+ let flags = 0;
397
+ if (options.multiple === true) flags = flags + DLG_MULTIPLE;
398
+ if (options.directory === true) flags = flags + DLG_DIRECTORY;
399
+ startDialog(DLG_OPEN, flags, options.title, options.defaultPath, "",
400
+ options.filters, (paths, err) => cb(paths, err));
401
+ },
402
+
403
+ saveFileDialog: (options, cb) => {
404
+ startDialog(DLG_SAVE, 0, options.title, options.defaultPath,
405
+ options.defaultName, options.filters, (paths, err) => {
406
+ if (paths === null) {
407
+ cb(null, err);
408
+ return;
409
+ }
410
+ cb(paths.length > 0 ? paths[0] : null, err);
411
+ });
412
+ },
413
+
414
+ setTitle: (title) => {
415
+ wvSetTitle(h, title);
416
+ },
417
+
418
+ setSize: (width, height, hint) => {
419
+ wvSetSize(h, width, height, hint === undefined ? 0 : hint);
420
+ },
421
+
422
+ setFullscreen: (on) => {
423
+ wvSetFullscreen(h, on ? 1 : 0);
424
+ },
425
+
426
+ emit: (event, payload) => {
254
427
  wvEval(
255
428
  h,
256
- "window.__wvEmit(" + JSON.stringify(event) + "," + payloadJson + ");",
429
+ "window.__wvEmit(" + JSON.stringify(event) + "," + encode(payload) + ");",
257
430
  );
258
431
  },
259
432
 
@@ -262,26 +435,16 @@ export function createApp(cfg: WindowConfig): JanelaApp {
262
435
  },
263
436
 
264
437
  run: (html) => {
265
- const INVOKE = wvBind(h, "__invoke") + 0;
266
- wvSetHtml(h, html);
267
-
268
- const rc = wvRun(h, (bindIndex, _seq) => {
269
- // A tick is not an invoke: nothing is waiting on a reply, so the
270
- // shim never calls webview_return for it.
271
- if (bindIndex === TICK_BIND) {
272
- turn();
273
- return 0;
274
- }
275
- if (bindIndex !== INVOKE) {
276
- writeReply(h, '"unknown binding"');
277
- return 1;
278
- }
279
- const env = JSON.parse(readRequest(h)) as string[];
438
+ // Both handlers are retained: registered once here, called by the shim
439
+ // for as long as the window is open.
440
+ wvOnTick(h, turn);
441
+ wvOnInvoke(h, (req) => {
442
+ const env = JSON.parse(req) as string[];
280
443
  const cmd = env[0];
281
- const argsJson = env[1];
444
+ const args = JSON.parse(env[1]) as unknown;
282
445
  for (let i = 0; i < app.names.length; i++) {
283
446
  if (app.names[i] === cmd) {
284
- writeReply(h, app.handlers[i](argsJson));
447
+ wvReply(h, encode(app.handlers[i](args)));
285
448
  return 0;
286
449
  }
287
450
  }
@@ -292,25 +455,29 @@ export function createApp(cfg: WindowConfig): JanelaApp {
292
455
  // that is. Meanwhile the loop is free to serve other calls.
293
456
  const id = wvDefer(h) + 0;
294
457
  if (id < 0) {
295
- writeReply(h, JSON.stringify("cannot defer command: " + cmd));
458
+ wvReply(h, encode("cannot defer command: " + cmd));
296
459
  return 1;
297
460
  }
298
- const settle = (status: number): ((json: string) => void) => {
461
+ const settle = (status: number): ((value: unknown) => void) => {
299
462
  let done = false;
300
- return (json: string) => {
463
+ return (value: unknown) => {
301
464
  if (done) return; // a promise settles once
302
465
  done = true;
303
- writeReply(h, json);
466
+ wvReply(h, encode(value));
304
467
  wvResolve(h, id, status);
305
468
  };
306
469
  };
307
- asyncHandlers[i](argsJson, settle(0), settle(1));
470
+ asyncHandlers[i](args, settle(0), settle(1));
308
471
  return 0;
309
472
  }
310
473
  }
311
- writeReply(h, JSON.stringify("unknown command: " + cmd));
474
+ wvReply(h, encode("unknown command: " + cmd));
312
475
  return 1; // rejects the frontend promise
313
- }) + 0;
476
+ });
477
+
478
+ wvBind(h, "__invoke");
479
+ wvSetHtml(h, html);
480
+ const rc = wvRun(h) + 0;
314
481
  return rc;
315
482
  },
316
483
  };