Workers and Helper Processes
Move CPU-bound computation to a Node worker so it doesn't block the interface. Native asynchronous I/O can stay on the application thread; await it instead.
Moving work to a worker
Start the worker from the application and send its results back as messages:
import { Worker } from "node:worker_threads";
const worker = new Worker(new URL("./indexer.ts", import.meta.url));
worker.on("message", (rows) => setRows(rows));Only one thread in a process can own GTKX's GLib integration, and in an application that is the main thread. A worker uses Node APIs and posts plain data to the main thread; it cannot import generated GI modules or make GTKX native calls.
For gtkx build to emit the worker, its URL must appear directly inside new Worker(...) and name the source file through a relative path.
During gtkx dev and Vitest, Node loads the worker's source directly, and gtkx dev restarts the app when a file the worker loaded changes. The worker's relative imports must name source files or project files, and its syntax must work with Node's type stripping. Exercise workers in tests as well as builds, since the production bundle can accept syntax that direct loading cannot.
For imports of TypeScript files, add "allowImportingTsExtensions": true to the scaffold's compilerOptions; it already uses NodeNext and noEmit. To have tsc report syntax that Node cannot strip, add "erasableSyntaxOnly": true as well. tsc then rejects parameter properties such as constructor(private readonly path: string), enums, namespaces with code and other TypeScript that would otherwise typecheck and build and only fail with a SyntaxError when the worker starts in gtkx dev or Vitest. The option applies to the whole project, including code that only Vite compiles.
Importing files in a worker
A worker imports project files with ?url, ?raw and ?inline, as the main thread does; Assets describes what each provides:
import { readFileSync } from "node:fs";
import captionsPath from "../../data/captions.txt?url";
const captions = readFileSync(captionsPath, "utf8");During gtkx dev and Vitest, a worker started with its own execArgv must include process.execArgv to keep this support. ?resource, ?icon and ?font make assets available to GTK, so import them from the main thread.
Standalone workers that own GTKX
In a Node helper that is not a GTKX application, a worker can own GTKX instead of the main thread. A thread starts GTKX when it first loads GTKX code, such as @gtkx/gi, @gtkx/react or @gtkx/i18n, and a second thread that loads GTKX code fails during bootstrap. A helper whose main thread calls t() from @gtkx/i18n therefore owns GTKX on that thread, so keep @gtkx/* imports out of the main thread of a helper that hands GTKX to a worker, and post plain data between them. gtkx build sets the program name and the locale on an entry's main thread only when that thread loads GTKX code, so building such a helper leaves GTKX to its worker:
import { Worker } from "node:worker_threads";
const [folder, output] = process.argv.slice(2);
const worker = new Worker(new URL("./thumbnailer.ts", import.meta.url), {
workerData: { folder, output },
});
worker.on("message", (message) => {
if (message === "cleanup-complete") void worker.terminate();
});Before the parent terminates a worker that owns GTKX, the worker must:
- Finish its native operations, or cancel them and await completion.
- Disconnect signal handlers and remove other native callback registrations.
- Call
quit()from@gtkx/runtime. - Post a cleanup-complete message. The parent calls
worker.terminate()only after it arrives.
quit() alone does not cancel pending operations or await their callbacks, and terminating a worker with live native work or registrations is unsupported.
Callbacks from other threads
JavaScript runs only on the thread that owns GTKX, but some libraries call back from threads of their own. GStreamer emits about-to-finish on playbin and playbin3, and handoff on identity and fakesink, from its streaming threads. When native code calls a JavaScript signal handler, callback or virtual function override from another thread, GTKX skips the call, as GJS does, and logs a GLib warning that names the signal and the emitting object:
Gtkx-WARNING **: The JavaScript handler for signal 'about-to-finish' on GstPlayBin3 0x55d0c2a1e0f0 was called on another thread and was skipped. …The application keeps running. The library sees a call that did nothing: a skipped signal handler or callback returns zero, false or null, and a skipped virtual function override runs the implementation it replaced. The warning appears once per handler. Use signals and messages that reach the main thread instead, such as the pipeline bus.
Gapless playback is the common case. A C or Vala player sets the next track's URI inside about-to-finish, which a JavaScript handler cannot do. playbin also keeps a uri set while it plays and switches to it without a gap when the current track ends, so queue the next track from the bus once the current one starts:
import * as GLib from "@gtkx/gi/glib";
import * as Gst from "@gtkx/gi/gst";
const tracks = [
"file:///music/01.ogg",
"file:///music/02.ogg",
"file:///music/03.ogg",
];
const player = Gst.ElementFactory.make("playbin", "player");
if (player === null)
throw new Error("GStreamer's playbin element is not installed");
player.getBus()?.addWatch(GLib.PRIORITY_DEFAULT, (_bus, message) => {
if (message.type === Gst.MessageType.STREAM_START) {
const next = tracks.shift();
if (next !== undefined) player.setProperty("uri", next);
}
return GLib.SOURCE_CONTINUE;
});
player.setProperty("uri", tracks.shift() ?? null);
player.setState(Gst.State.PLAYING);With GStreamer 1.28, playbin3 stalls when its uri changes while it plays, so use playbin for this.
Keeping a helper process alive
An application element, normally AdwApplication, keeps the Node event loop alive automatically. A plain Node helper has no application lifecycle, so pending GIO work alone does not keep the process alive. Hold the event loop around the operation with keepAlive from @gtkx/runtime:
import { keepAlive } from "@gtkx/runtime";
keepAlive(true);
try {
await runGioWork();
} finally {
keepAlive(false);
}keepAlive is a switch for the current JavaScript environment. Coordinate overlapping operations and release it after the last one. Application components should leave this to their application element. Calling quit() releases the hold and prevents it from being enabled again.