janela 0.3.0 → 0.4.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 (39) hide show
  1. package/README.md +77 -11
  2. package/api/global.d.ts +27 -0
  3. package/api/index.d.ts +37 -0
  4. package/api/index.js +35 -0
  5. package/bin/janela.mjs +21 -4
  6. package/package.json +15 -1
  7. package/runtime/janela.ts +138 -134
  8. package/runtime/types.ts +131 -0
  9. package/shim/wvshim.cc +55 -8
  10. package/templates/index.html +4 -2
  11. package/templates/main.ts +1 -1
  12. package/templates/react/deps.json +14 -2
  13. package/templates/react/files/index.html +1 -1
  14. package/templates/react/files/src/{App.jsx → App.tsx} +19 -8
  15. package/templates/react/files/src/main.tsx +12 -0
  16. package/templates/react/files/src-host/main.ts +1 -1
  17. package/templates/react/files/tsconfig.json +25 -0
  18. package/templates/solid/deps.json +11 -2
  19. package/templates/solid/files/index.html +1 -1
  20. package/templates/solid/files/src/App.tsx +50 -0
  21. package/templates/solid/files/src/main.tsx +7 -0
  22. package/templates/solid/files/src-host/main.ts +1 -1
  23. package/templates/solid/files/tsconfig.json +26 -0
  24. package/templates/svelte/deps.json +6 -1
  25. package/templates/svelte/files/index.html +1 -1
  26. package/templates/svelte/files/src/App.svelte +10 -7
  27. package/templates/svelte/files/src/main.ts +7 -0
  28. package/templates/svelte/files/src-host/main.ts +1 -1
  29. package/templates/svelte/files/tsconfig.json +23 -0
  30. package/templates/vue/deps.json +12 -2
  31. package/templates/vue/files/index.html +1 -1
  32. package/templates/vue/files/src/App.vue +9 -7
  33. package/templates/vue/files/src-host/main.ts +1 -1
  34. package/templates/vue/files/tsconfig.json +23 -0
  35. package/templates/react/files/src/main.jsx +0 -9
  36. package/templates/solid/files/src/App.jsx +0 -35
  37. package/templates/solid/files/src/main.jsx +0 -4
  38. package/templates/svelte/files/src/main.js +0 -4
  39. /package/templates/vue/files/src/{main.js → main.ts} +0 -0
package/README.md CHANGED
@@ -70,7 +70,7 @@ MSI.
70
70
 
71
71
  ```
72
72
  my-app/
73
- ├── index.html frontend — any HTML/JS; calls janela.invoke() / janela.listen()
73
+ ├── index.html frontend — any HTML/JS/TS; calls invoke() / listen()
74
74
  ├── src-host/main.ts backend — exports setup(app), registers commands
75
75
  └── janela.conf.json name, bundle identifier, version, window
76
76
  ```
@@ -79,17 +79,42 @@ A Vite project adds a `vite.config.js` and a `src/` tree — that config is what
79
79
  makes janela build the frontend with Vite instead of inlining `index.html`
80
80
  directly.
81
81
 
82
- Frontend API (injected before page load):
82
+ Frontend API import it, and your editor and `tsc` know the shapes:
83
+
84
+ ```ts
85
+ import { invoke, listen } from "janela/api";
86
+
87
+ const sum = await invoke<number>("add", { a: 2, b: 40 }); // call a backend command
88
+ listen<number>("added", (payload) => { ... }); // backend-fired events
89
+ ```
90
+
91
+ `janela` is already a devDependency of a scaffolded project, so there is
92
+ nothing extra to install. The generic is what the host command returns —
93
+ values cross the boundary as values, so there is no JSON to parse.
94
+
95
+ <details>
96
+ <summary>No bundler? Use the injected global instead</summary>
97
+
98
+ janela injects the same two functions as `window.janela` before every document
99
+ loads, which is what the `vanilla` template uses — it needs no `npm install` at
100
+ all:
83
101
 
84
102
  ```js
85
- const sum = await janela.invoke("add", { a: 2, b: 40 }); // call a backend command
86
- janela.listen("added", (payload) => { ... }); // backend-fired events
103
+ const sum = await janela.invoke("add", { a: 2, b: 40 });
104
+ janela.listen("added", (payload) => { ... });
87
105
  ```
88
106
 
89
- Backend API (`src-host/main.ts`):
107
+ TypeScript users on this path can pull in the ambient types with
108
+ `/// <reference types="janela/global" />`, or by adding `"janela/global"` to
109
+ `compilerOptions.types`. With a bundler, prefer the import — it needs no
110
+ ambient declaration.
111
+
112
+ </details>
113
+
114
+ Backend API (`src-host/main.ts`) — also typed, from the same package:
90
115
 
91
116
  ```ts
92
- import type { JanelaApp } from "./janela";
117
+ import type { JanelaApp } from "janela/host";
93
118
 
94
119
  export function setup(app: JanelaApp): void {
95
120
  app.command("add", (args) => { // values in, values out
@@ -154,10 +179,14 @@ Errors arrive as values, never throws — `err` carries a Node-shaped message
154
179
  (`ENOENT: no such file or directory, open '/x'`). UTF-8 round-trips exactly,
155
180
  astral characters included.
156
181
 
157
- The payload crosses in a single call (format 3), and the drain that follows it
158
- costs about **10 ms per MB on the UI thread** fine for config files and
159
- documents, still worth chunking for very large media. See
160
- [docs/async.md](../../docs/async.md) for the measurements.
182
+ The payload crosses in a single call (format 3), and the decode that follows is
183
+ spread across turns under a 4 ms budget, so a large read no longer stalls the
184
+ window: a 100 MB file's worst UI pause is ~25 ms (p99 4 ms) rather than ~176 ms,
185
+ at the same throughput. The remaining pause is the one unavoidable copy that
186
+ materialises the string for your callback. See
187
+ [docs/async.md](../../docs/async.md) for the measurements — and note that
188
+ indexing a large string in your own callback (`text.length`, `slice`) is O(n)
189
+ in scriptc and can cost far more than the read did.
161
190
 
162
191
  **Use `app.sleep`, not `setTimeout`.** scriptc's own event loop is parked for
163
192
  as long as the program sits inside the `run()` FFI call, so `setTimeout`,
@@ -206,11 +235,48 @@ nested modal loop would otherwise re-enter the host loop underneath a live TS
206
235
  frame; [docs/native-shell.md](../../docs/native-shell.md) has the details, the
207
236
  per-platform table, and the Windows GUI-subsystem note.
208
237
 
238
+ ## Migrating from 0.3.x
239
+
240
+ Nothing breaks: the injected `janela` global still works exactly as before.
241
+ What changed is the recommendation — the frontend now has a real module, so
242
+ editors and `tsc` can see it:
243
+
244
+ ```ts
245
+ // 0.3.x — an untyped global, invisible to tsc and ESLint
246
+ const sum = await janela.invoke("add", { a: 2, b: 40 });
247
+
248
+ // 0.4.x — typed, resolvable, and generic over what the command returns
249
+ import { invoke } from "janela/api";
250
+ const sum = await invoke<number>("add", { a: 2, b: 40 });
251
+ ```
252
+
253
+ The host side had the same problem and gets the same fix. `src-host/main.ts`
254
+ used to import `JanelaApp` from `"./janela"` — a path that only exists inside
255
+ `.janela/build/`, so an editor could never resolve it and the whole `app.*`
256
+ API was untyped:
257
+
258
+ ```ts
259
+ // 0.3.x — unresolved in the editor; JanelaApp was effectively `any`
260
+ import type { JanelaApp } from "./janela";
261
+
262
+ // 0.4.x — resolves against the installed package
263
+ import type { JanelaApp } from "janela/host";
264
+ ```
265
+
266
+ `janela build` rewrites that specifier to the local runtime copy while
267
+ assembling the compile unit, so the build stays fully static and a project
268
+ with no `node_modules` at all still compiles.
269
+
270
+ The framework templates (`vue`, `react`, `svelte`, `solid`) are TypeScript now
271
+ and scaffold with a `typecheck` script that covers `src/` and `src-host/`
272
+ alike. `vanilla` stays plain JavaScript on the global, so it still needs no
273
+ `npm install` before the first build.
274
+
209
275
  ## Migrating from 0.1.x
210
276
 
211
277
  Commands used to take and return **JSON text**; they now take and return
212
278
  **values**, with the runtime handling serialisation. The page-side API
213
- (`janela.invoke` / `janela.listen`) is unchanged.
279
+ (`invoke` / `listen`) is unchanged.
214
280
 
215
281
  ```ts
216
282
  // 0.1.x
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Types for the injected `janela` global, for pages that use it directly
3
+ * rather than importing `janela/api` — a plain `<script>` with no bundler,
4
+ * typically. Pull them in from a TypeScript project with:
5
+ *
6
+ * ```ts
7
+ * /// <reference types="janela/global" />
8
+ * ```
9
+ *
10
+ * or by adding `"janela/global"` to `compilerOptions.types` in tsconfig.json.
11
+ *
12
+ * If you have a bundler, prefer `import { invoke, listen } from "janela/api"` —
13
+ * it needs no ambient declaration and is what the templates use.
14
+ */
15
+
16
+ import type { JanelaBridge } from "./index.js";
17
+
18
+ declare global {
19
+ /** The host bridge janela injects before the document loads. */
20
+ const janela: JanelaBridge;
21
+
22
+ interface Window {
23
+ janela: JanelaBridge;
24
+ }
25
+ }
26
+
27
+ export {};
package/api/index.d.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The janela frontend API.
3
+ *
4
+ * Values cross the boundary as values — janela owns the JSON at the edge — so
5
+ * the generic parameter is what the host command returns, not a string to
6
+ * parse.
7
+ */
8
+
9
+ /** The bridge janela injects as `window.janela` before each document loads. */
10
+ export interface JanelaBridge {
11
+ invoke<T = unknown>(cmd: string, args?: unknown): Promise<T>;
12
+ listen<T = unknown>(event: string, cb: (payload: T) => void): void;
13
+ }
14
+
15
+ /**
16
+ * Call a command the host registered with `app.command` / `app.commandAsync`.
17
+ *
18
+ * ```ts
19
+ * const sum = await invoke<number>("add", { a: 2, b: 40 });
20
+ * ```
21
+ *
22
+ * Rejects if the command is unknown, if the handler rejected, or if the page
23
+ * is not running inside a janela window.
24
+ */
25
+ export declare function invoke<T = unknown>(cmd: string, args?: unknown): Promise<T>;
26
+
27
+ /**
28
+ * Subscribe to an event the host sends with `app.emit`.
29
+ *
30
+ * ```ts
31
+ * listen<number>("added", (sum) => console.log(sum));
32
+ * ```
33
+ */
34
+ export declare function listen<T = unknown>(
35
+ event: string,
36
+ cb: (payload: T) => void,
37
+ ): void;
package/api/index.js ADDED
@@ -0,0 +1,35 @@
1
+ // The janela frontend API — `import { invoke, listen } from "janela/api"`.
2
+ //
3
+ // janela injects a bridge as `window.janela` into every document before it
4
+ // loads, so this module is a thin wrapper over that global rather than a
5
+ // transport of its own. Importing it is the recommended style: bundlers
6
+ // resolve it, editors complete it, and `tsc` checks it. The global stays
7
+ // available unchanged for pages with no build step.
8
+
9
+ function bridge() {
10
+ const found = typeof globalThis === "undefined" ? undefined : globalThis.janela;
11
+ if (!found || typeof found.invoke !== "function") {
12
+ throw new Error(
13
+ "janela: no host bridge on this page (window.janela is undefined). " +
14
+ "The page is not running inside a janela window — run the app with " +
15
+ "`janela dev`, or `janela build` and launch the binary. Opening the " +
16
+ "page in a browser, or serving it with plain `vite`, leaves no host " +
17
+ "to talk to.",
18
+ );
19
+ }
20
+ return found;
21
+ }
22
+
23
+ /**
24
+ * Call a command the host registered with `app.command` / `app.commandAsync`.
25
+ * Arguments and the resolved value are ordinary values; janela owns the
26
+ * serialisation at the boundary.
27
+ */
28
+ export async function invoke(cmd, args) {
29
+ return bridge().invoke(cmd, args);
30
+ }
31
+
32
+ /** Subscribe to an event the host sends with `app.emit`. */
33
+ export function listen(event, cb) {
34
+ bridge().listen(event, cb);
35
+ }
package/bin/janela.mjs CHANGED
@@ -345,14 +345,17 @@ function ffiManifest(shimLib) {
345
345
  // Job accessors, shared by file I/O and dialogs: both are work whose
346
346
  // answer cannot be produced during the FFI call that starts it.
347
347
  { name: "wvJobStatus", symbol: "wv_job_status", params: ["i32", "i32"], returns: "i32" },
348
+ { name: "wvJobSize", symbol: "wv_job_size", params: ["i32", "i32"], returns: "f64" },
348
349
  {
349
- name: "wvJobTake", symbol: "wv_job_take",
350
+ // One slice per call, so a large payload decodes across several UI turns
351
+ // instead of stalling on all of it at once. Returns the bytes covered.
352
+ name: "wvJobTakeAt", symbol: "wv_job_take_at",
350
353
  params: [
351
- "i32", "i32",
354
+ "i32", "i32", "f64", "f64",
352
355
  { callback: { id: "sink", params: ["string", { context: "sink" }], returns: "void", lifetime: "call" } },
353
356
  { context: "sink" },
354
357
  ],
355
- returns: "i32",
358
+ returns: "f64",
356
359
  },
357
360
  { name: "wvJobFree", symbol: "wv_job_free", params: ["i32", "i32"], returns: "i32" },
358
361
  // Native dialogs: the modal runs on a later UI-thread turn, so asking for
@@ -478,9 +481,22 @@ function build(root, { devUrl = null, gui = true } = {}) {
478
481
 
479
482
  // Assemble the compile unit: runtime + user's commands + generated modules.
480
483
  cpSync(join(KIT, "runtime", "janela.ts"), join(buildDir, "janela.ts"));
484
+ cpSync(join(KIT, "runtime", "types.ts"), join(buildDir, "types.ts"));
481
485
  const mainSrc = join(root, "src-host", "main.ts");
482
486
  if (!existsSync(mainSrc)) fail("missing src-host/main.ts");
483
- cpSync(mainSrc, join(buildDir, "main.ts"));
487
+ // A project's main.ts imports from "janela/host" so that it resolves in the
488
+ // editor against the installed package. Here it is compiled next to the
489
+ // runtime instead, so the specifier is rewritten to that local copy: the
490
+ // build never resolves through node_modules, which keeps it static and will
491
+ // keep working when "janela/host" starts exporting values (not just types)
492
+ // as well.
493
+ writeFileSync(
494
+ join(buildDir, "main.ts"),
495
+ readFileSync(mainSrc, "utf8").replace(
496
+ /(\bfrom\s*)(['"])janela\/host\2/g,
497
+ "$1$2./janela$2",
498
+ ),
499
+ );
484
500
 
485
501
  const html = frontendHtml(root, conf, devUrl);
486
502
  writeFileSync(
@@ -613,6 +629,7 @@ function init(name, template) {
613
629
  const extra = JSON.parse(readFileSync(join(tdir, "deps.json"), "utf8"));
614
630
  pkg.type = "module";
615
631
  Object.assign(pkg.devDependencies, extra.devDependencies ?? {});
632
+ Object.assign(pkg.scripts, extra.scripts ?? {});
616
633
  if (extra.dependencies) pkg.dependencies = extra.dependencies;
617
634
  }
618
635
 
package/package.json CHANGED
@@ -1,12 +1,25 @@
1
1
  {
2
2
  "name": "janela",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Desktop apps in pure TypeScript, compiled to native. No Rust, no Node, no Electron.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "janela": "bin/janela.mjs",
8
8
  "jn": "bin/janela.mjs"
9
9
  },
10
+ "exports": {
11
+ "./api": {
12
+ "types": "./api/index.d.ts",
13
+ "default": "./api/index.js"
14
+ },
15
+ "./host": {
16
+ "types": "./runtime/types.ts"
17
+ },
18
+ "./global": {
19
+ "types": "./api/global.d.ts"
20
+ },
21
+ "./package.json": "./package.json"
22
+ },
10
23
  "dependencies": {
11
24
  "scriptc": "0.0.35"
12
25
  },
@@ -33,6 +46,7 @@
33
46
  "node": ">=24"
34
47
  },
35
48
  "files": [
49
+ "api/",
36
50
  "bin/",
37
51
  "runtime/",
38
52
  "shim/",
package/runtime/janela.ts CHANGED
@@ -29,7 +29,14 @@ declare function wvTickStop(h: number): number;
29
29
  declare function wvFsRead(h: number, path: string): number;
30
30
  declare function wvFsWrite(h: number, path: string, data: string): number;
31
31
  declare function wvJobStatus(h: number, id: number): number;
32
- declare function wvJobTake(h: number, id: number, sink: (text: string) => void): number;
32
+ declare function wvJobSize(h: number, id: number): number;
33
+ declare function wvJobTakeAt(
34
+ h: number,
35
+ id: number,
36
+ offset: number,
37
+ maxBytes: number,
38
+ sink: (text: string) => void,
39
+ ): number;
33
40
  declare function wvJobFree(h: number, id: number): number;
34
41
  declare function wvDialog(
35
42
  h: number,
@@ -67,124 +74,30 @@ const BOOTSTRAP =
67
74
  " for (var i = 0; i < cbs.length; i++) cbs[i](payload);" +
68
75
  "};";
69
76
 
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;
78
-
79
- /**
80
- * An async command: return immediately, answer later. `resolve`/`reject` take
81
- * a value and settle the page's `await janela.invoke(...)` promise whenever
82
- * they are called — from a later defer()/sleep() turn, or from another
83
- * command. The window stays responsive for as long as the call is pending.
84
- */
85
- export type AsyncCommandHandler = (
86
- args: unknown,
87
- resolve: (value: unknown) => void,
88
- reject: (reason: unknown) => void,
89
- ) => void;
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
-
125
- export interface WindowConfig {
126
- title: string;
127
- width: number;
128
- height: number;
129
- }
130
-
131
- export interface JanelaApp {
132
- handle: number;
133
- names: string[];
134
- handlers: CommandHandler[];
135
- /** Register a named command, callable from the page as janela.invoke(name, args). */
136
- command: (name: string, h: CommandHandler) => void;
137
- /** Register a command that answers later; see AsyncCommandHandler. */
138
- commandAsync: (name: string, h: AsyncCommandHandler) => void;
139
- /** Run fn on the next turn of the host loop — the way to slice long work. */
140
- defer: (fn: () => void) => void;
141
- /** Run fn after at least ms. The host loop's timer; scriptc's setTimeout
142
- * cannot fire while the window is open (its loop is parked inside run()). */
143
- sleep: (ms: number, fn: () => void) => 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;
183
- /** Close the window and make run() return. */
184
- quit: () => void;
185
- /** Show the page and block until the window closes. Returns the run status. */
186
- run: (html: string) => number;
187
- }
77
+ // The public host types live in ./types (shipped as `janela/host` too, so a
78
+ // user's editor can see them). Re-exported here because the compiled build
79
+ // resolves them through this module see the specifier rewrite in the CLI.
80
+ export type {
81
+ AsyncCommandHandler,
82
+ CommandHandler,
83
+ DialogFilter,
84
+ FsCallback,
85
+ JanelaApp,
86
+ OpenDialogOptions,
87
+ SaveDialogOptions,
88
+ WindowConfig,
89
+ } from "./types";
90
+
91
+ import type {
92
+ AsyncCommandHandler,
93
+ CommandHandler,
94
+ DialogFilter,
95
+ FsCallback,
96
+ JanelaApp,
97
+ OpenDialogOptions,
98
+ SaveDialogOptions,
99
+ WindowConfig,
100
+ } from "./types";
188
101
 
189
102
  // JSON.stringify yields undefined for undefined; the wire always needs a
190
103
  // value, and a command that returns nothing should read as null in the page.
@@ -214,19 +127,111 @@ export function createApp(cfg: WindowConfig): JanelaApp {
214
127
  let jobCbs: FsCallback[] = [];
215
128
  let ticking = false;
216
129
 
130
+ // ---- the drain -----------------------------------------------------------
131
+ // A finished job's bytes still have to be decoded into a TypeScript string,
132
+ // and that cost is proportional to the payload: taking a 100 MB file in one
133
+ // call froze the window for ~240 ms. So a finished job moves here and is
134
+ // decoded a slice at a time, giving the run loop the thread back between
135
+ // slices — total work is unchanged, but no single turn carries much of it.
136
+ //
137
+ // The budget is wall-clock rather than a byte count on purpose: a fixed
138
+ // chunk size fixes the WORST turn but also caps throughput (128 KB per 8 ms
139
+ // tick would cap reads at ~16 MB/s), whereas a time budget spends whatever
140
+ // the machine can do in the time available.
141
+ const DRAIN_BUDGET_MS = 4; // ≈ a quarter of a 60fps frame
142
+ const DRAIN_SLICE = 131072; // 128 KB — granularity within the budget
143
+ let drainIds: number[] = [];
144
+ let drainCbs: FsCallback[] = [];
145
+ let drainOk: boolean[] = [];
146
+ let drainParts: string[][] = [];
147
+ let drainOff: number[] = [];
148
+ let drainSize: number[] = [];
149
+
150
+ // Tick interval: 8 ms is plenty for timers and task chains, but while a
151
+ // payload is draining the loop is doing real work every turn, and waiting
152
+ // 8 ms between 4 ms slices would halve throughput for no benefit. So the
153
+ // ticker runs tighter for as long as there is a payload in flight.
154
+ const TICK_IDLE_MS = 8;
155
+ const TICK_DRAIN_MS = 4;
156
+ let tickMs = TICK_IDLE_MS;
157
+
158
+ const retick = (): void => {
159
+ const want = drainIds.length > 0 ? TICK_DRAIN_MS : TICK_IDLE_MS;
160
+ if (!ticking || want === tickMs) return;
161
+ tickMs = want;
162
+ wvTickStart(h, want);
163
+ };
164
+
217
165
  const wake = (): void => {
218
166
  if (ticking) return;
219
167
  ticking = true;
220
- wvTickStart(h, 8);
168
+ tickMs = drainIds.length > 0 ? TICK_DRAIN_MS : TICK_IDLE_MS;
169
+ wvTickStart(h, tickMs);
221
170
  };
222
171
 
223
172
  const idle = (): void => {
224
173
  if (!ticking) return;
225
- if (taskFns.length > 0 || timerFns.length > 0 || jobIds.length > 0) return;
174
+ if (
175
+ taskFns.length > 0 ||
176
+ timerFns.length > 0 ||
177
+ jobIds.length > 0 ||
178
+ drainIds.length > 0
179
+ ) {
180
+ return;
181
+ }
226
182
  ticking = false;
227
183
  wvTickStop(h);
228
184
  };
229
185
 
186
+ // Decode as much of the pending payloads as the budget allows, then yield.
187
+ // Slices are taken from one job at a time so a big read finishes promptly
188
+ // rather than every concurrent read finishing slowly.
189
+ const drainSome = (): void => {
190
+ if (drainIds.length === 0) return;
191
+ const started = Date.now() + 0;
192
+
193
+ while (drainIds.length > 0) {
194
+ let chunk = "";
195
+ const taken =
196
+ wvJobTakeAt(h, drainIds[0], drainOff[0], DRAIN_SLICE, (text) => {
197
+ chunk = text;
198
+ }) + 0;
199
+
200
+ // A negative count means the job vanished; treat the payload as final
201
+ // rather than spinning on it forever.
202
+ if (taken > 0) {
203
+ drainParts[0].push(chunk);
204
+ drainOff[0] = drainOff[0] + taken;
205
+ }
206
+
207
+ if (taken <= 0 || drainOff[0] >= drainSize[0]) {
208
+ // Joining is one unavoidable O(n) copy: the callback is handed a
209
+ // single string, so the whole payload must be materialised once.
210
+ const payload = drainParts[0].join("");
211
+ const cb = drainCbs[0];
212
+ const ok = drainOk[0];
213
+ wvJobFree(h, drainIds[0]);
214
+
215
+ drainIds = drainIds.slice(1);
216
+ drainCbs = drainCbs.slice(1);
217
+ drainOk = drainOk.slice(1);
218
+ drainParts = drainParts.slice(1);
219
+ drainOff = drainOff.slice(1);
220
+ drainSize = drainSize.slice(1);
221
+
222
+ if (ok) {
223
+ cb(null, payload);
224
+ } else {
225
+ cb(payload, "");
226
+ }
227
+ // User code just ran and may have taken a while; re-check the budget
228
+ // before starting another payload.
229
+ }
230
+
231
+ if (Date.now() - started >= DRAIN_BUDGET_MS) return;
232
+ }
233
+ };
234
+
230
235
  // One turn of the loop: every task queued so far, plus every due timer.
231
236
  // Tasks queued *by* this turn wait for the next one, so a defer() chain
232
237
  // yields to the UI between slices instead of starving it.
@@ -275,21 +280,20 @@ export function createApp(cfg: WindowConfig): JanelaApp {
275
280
  jobIds = keptIds;
276
281
  jobCbs = keptCbs;
277
282
  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
- }
283
+ // On failure the payload IS the error message, so one path serves both
284
+ // outcomes. Nothing is decoded here: the job joins the drain queue and
285
+ // its bytes are taken a slice at a time, under a time budget.
286
+ drainIds.push(doneIds[i]);
287
+ drainCbs.push(doneCbs[i]);
288
+ drainOk.push(doneOk[i]);
289
+ drainParts.push([]);
290
+ drainOff.push(0);
291
+ drainSize.push(wvJobSize(h, doneIds[i]) + 0);
291
292
  }
292
293
  }
294
+
295
+ drainSome();
296
+ retick();
293
297
  idle();
294
298
  };
295
299