@gjsify/worker_threads 0.1.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/README.md +27 -0
- package/lib/esm/index.js +138 -0
- package/lib/esm/message-port.js +118 -0
- package/lib/esm/worker.js +315 -0
- package/lib/types/index.d.ts +59 -0
- package/lib/types/message-port.d.ts +30 -0
- package/lib/types/worker.d.ts +42 -0
- package/package.json +44 -0
- package/src/index.spec.ts +1591 -0
- package/src/index.ts +148 -0
- package/src/message-port.ts +130 -0
- package/src/test.mts +4 -0
- package/src/worker.ts +394 -0
- package/tsconfig.json +31 -0
- package/tsconfig.tsbuildinfo +1 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Reference: Node.js lib/worker_threads.js
|
|
2
|
+
// Reimplemented for GJS — MessageChannel/BroadcastChannel are pure JS,
|
|
3
|
+
// Worker uses Gio.Subprocess (subprocess-based workers)
|
|
4
|
+
|
|
5
|
+
export { MessagePort } from './message-port.ts';
|
|
6
|
+
export { Worker } from './worker.ts';
|
|
7
|
+
export type { WorkerOptions } from './worker.ts';
|
|
8
|
+
|
|
9
|
+
import { MessagePort } from './message-port.ts';
|
|
10
|
+
import { Worker } from './worker.ts';
|
|
11
|
+
|
|
12
|
+
// When running inside a Worker subprocess, the bootstrap script sets
|
|
13
|
+
// globalThis.__gjsify_worker_context before importing the user's code.
|
|
14
|
+
const _ctx = (globalThis as Record<string, unknown>).__gjsify_worker_context as
|
|
15
|
+
{ isMainThread: boolean; parentPort: unknown; workerData: unknown; threadId: number } | undefined;
|
|
16
|
+
|
|
17
|
+
export const isMainThread: boolean = !_ctx;
|
|
18
|
+
export const parentPort: MessagePort | null = (_ctx?.parentPort as MessagePort) ?? null;
|
|
19
|
+
export const workerData: unknown = _ctx?.workerData ?? null;
|
|
20
|
+
export const threadId: number = _ctx?.threadId ?? 0;
|
|
21
|
+
export const resourceLimits: Record<string, unknown> = {};
|
|
22
|
+
export const SHARE_ENV: unique symbol = Symbol('worker_threads.SHARE_ENV');
|
|
23
|
+
|
|
24
|
+
export class MessageChannel {
|
|
25
|
+
readonly port1: MessagePort;
|
|
26
|
+
readonly port2: MessagePort;
|
|
27
|
+
|
|
28
|
+
constructor() {
|
|
29
|
+
this.port1 = new MessagePort();
|
|
30
|
+
this.port2 = new MessagePort();
|
|
31
|
+
this.port1._otherPort = this.port2;
|
|
32
|
+
this.port2._otherPort = this.port1;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const _broadcastRegistry = new Map<string, Set<BroadcastChannel>>();
|
|
37
|
+
|
|
38
|
+
export class BroadcastChannel {
|
|
39
|
+
readonly name: string;
|
|
40
|
+
private _closed = false;
|
|
41
|
+
onmessage: ((event: { data: unknown }) => void) | null = null;
|
|
42
|
+
onmessageerror: ((event: { data: unknown }) => void) | null = null;
|
|
43
|
+
|
|
44
|
+
constructor(name: string) {
|
|
45
|
+
this.name = String(name);
|
|
46
|
+
if (!_broadcastRegistry.has(this.name)) {
|
|
47
|
+
_broadcastRegistry.set(this.name, new Set());
|
|
48
|
+
}
|
|
49
|
+
_broadcastRegistry.get(this.name)!.add(this);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
postMessage(message: unknown): void {
|
|
53
|
+
if (this._closed) {
|
|
54
|
+
throw new Error('BroadcastChannel is closed');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const channels = _broadcastRegistry.get(this.name);
|
|
58
|
+
if (!channels) return;
|
|
59
|
+
|
|
60
|
+
for (const channel of channels) {
|
|
61
|
+
if (channel !== this && !channel._closed) {
|
|
62
|
+
// Clone per receiver per W3C spec — each recipient gets an independent copy
|
|
63
|
+
const cloned = structuredClone(message);
|
|
64
|
+
Promise.resolve().then(() => {
|
|
65
|
+
if (!channel._closed && channel.onmessage) {
|
|
66
|
+
channel.onmessage({ data: cloned });
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
close(): void {
|
|
74
|
+
if (this._closed) return;
|
|
75
|
+
this._closed = true;
|
|
76
|
+
const channels = _broadcastRegistry.get(this.name);
|
|
77
|
+
if (channels) {
|
|
78
|
+
channels.delete(this);
|
|
79
|
+
if (channels.size === 0) {
|
|
80
|
+
_broadcastRegistry.delete(this.name);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
this.onmessage = null;
|
|
84
|
+
this.onmessageerror = null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
addEventListener(type: string, listener: ((event: unknown) => void) | null): void {
|
|
88
|
+
if (type === 'message') {
|
|
89
|
+
this.onmessage = listener as ((event: { data: unknown }) => void) | null;
|
|
90
|
+
} else if (type === 'messageerror') {
|
|
91
|
+
this.onmessageerror = listener as ((event: { data: unknown }) => void) | null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
removeEventListener(type: string, _listener: ((event: unknown) => void) | null): void {
|
|
96
|
+
if (type === 'message') this.onmessage = null;
|
|
97
|
+
else if (type === 'messageerror') this.onmessageerror = null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
dispatchEvent(_event: Event): boolean {
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const _environmentData = new Map<string, unknown>();
|
|
106
|
+
|
|
107
|
+
export function setEnvironmentData(key: string, value: unknown): void {
|
|
108
|
+
if (value === undefined) {
|
|
109
|
+
_environmentData.delete(key);
|
|
110
|
+
} else {
|
|
111
|
+
_environmentData.set(key, value);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function getEnvironmentData(key: string): unknown {
|
|
116
|
+
return _environmentData.get(key);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function receiveMessageOnPort(port: MessagePort): { message: unknown } | undefined {
|
|
120
|
+
if (!port._hasQueuedMessages) return undefined;
|
|
121
|
+
return { message: port._dequeueMessage() };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// No-ops in GJS — all messages are cloned, not transferred
|
|
125
|
+
export function markAsUntransferable(_object: unknown): void {}
|
|
126
|
+
export function markAsUncloneable(_object: unknown): void {}
|
|
127
|
+
export function moveMessagePortToContext(port: MessagePort, _context: unknown): MessagePort {
|
|
128
|
+
return port;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export default {
|
|
132
|
+
isMainThread,
|
|
133
|
+
parentPort,
|
|
134
|
+
workerData,
|
|
135
|
+
threadId,
|
|
136
|
+
resourceLimits,
|
|
137
|
+
SHARE_ENV,
|
|
138
|
+
Worker,
|
|
139
|
+
MessageChannel,
|
|
140
|
+
MessagePort,
|
|
141
|
+
BroadcastChannel,
|
|
142
|
+
setEnvironmentData,
|
|
143
|
+
getEnvironmentData,
|
|
144
|
+
receiveMessageOnPort,
|
|
145
|
+
markAsUntransferable,
|
|
146
|
+
markAsUncloneable,
|
|
147
|
+
moveMessagePortToContext,
|
|
148
|
+
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Reference: Node.js lib/internal/worker/io.js
|
|
2
|
+
// Reimplemented for GJS using EventEmitter
|
|
3
|
+
|
|
4
|
+
import { EventEmitter } from 'node:events';
|
|
5
|
+
|
|
6
|
+
export class MessagePort extends EventEmitter {
|
|
7
|
+
private _started = false;
|
|
8
|
+
private _closed = false;
|
|
9
|
+
private _messageQueue: unknown[] = [];
|
|
10
|
+
/** @internal Linked port for in-process communication */
|
|
11
|
+
_otherPort: MessagePort | null = null;
|
|
12
|
+
/** @internal Maps addEventListener listeners to their internal wrappers */
|
|
13
|
+
private _aeWrappers: Map<((event: unknown) => void), ((data: unknown) => void)> = new Map();
|
|
14
|
+
|
|
15
|
+
start(): void {
|
|
16
|
+
if (this._started || this._closed) return;
|
|
17
|
+
this._started = true;
|
|
18
|
+
this._drainQueue();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
close(): void {
|
|
22
|
+
if (this._closed) return;
|
|
23
|
+
this._closed = true;
|
|
24
|
+
const other = this._otherPort;
|
|
25
|
+
this._otherPort = null;
|
|
26
|
+
if (other) other._otherPort = null;
|
|
27
|
+
this.emit('close');
|
|
28
|
+
this.removeAllListeners();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
postMessage(value: unknown, _transferList?: unknown[]): void {
|
|
32
|
+
if (this._closed) return;
|
|
33
|
+
const target = this._otherPort;
|
|
34
|
+
if (!target) return;
|
|
35
|
+
|
|
36
|
+
let cloned: unknown;
|
|
37
|
+
try {
|
|
38
|
+
cloned = structuredClone(value);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
this.emit('messageerror', err instanceof Error ? err : new Error('Could not clone message'));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
target._receiveMessage(cloned);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
ref(): this { return this; }
|
|
47
|
+
unref(): this { return this; }
|
|
48
|
+
|
|
49
|
+
_receiveMessage(message: unknown): void {
|
|
50
|
+
if (this._closed) return;
|
|
51
|
+
if (!this._started) {
|
|
52
|
+
this._messageQueue.push(message);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
this._dispatchMessage(message);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get _hasQueuedMessages(): boolean {
|
|
59
|
+
return this._messageQueue.length > 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
_dequeueMessage(): unknown | undefined {
|
|
63
|
+
return this._messageQueue.shift();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private _drainQueue(): void {
|
|
67
|
+
while (this._messageQueue.length > 0) {
|
|
68
|
+
this._dispatchMessage(this._messageQueue.shift());
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private _dispatchMessage(message: unknown): void {
|
|
73
|
+
Promise.resolve().then(() => {
|
|
74
|
+
if (!this._closed) {
|
|
75
|
+
this.emit('message', message);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Web-compatible addEventListener. Wraps message data in a MessageEvent-like
|
|
82
|
+
* object `{ data, type }` before calling the listener.
|
|
83
|
+
* Requires explicit `port.start()` call (unlike `on('message')` which auto-starts).
|
|
84
|
+
*/
|
|
85
|
+
addEventListener(type: string, listener: ((event: unknown) => void) | null): void {
|
|
86
|
+
if (!listener) return;
|
|
87
|
+
if (type === 'message') {
|
|
88
|
+
const wrapper = (data: unknown) => {
|
|
89
|
+
listener({ data, type: 'message' });
|
|
90
|
+
};
|
|
91
|
+
this._aeWrappers.set(listener, wrapper);
|
|
92
|
+
super.on('message', wrapper);
|
|
93
|
+
} else {
|
|
94
|
+
super.on(type, listener);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
removeEventListener(type: string, listener: ((event: unknown) => void) | null): void {
|
|
99
|
+
if (!listener) return;
|
|
100
|
+
if (type === 'message') {
|
|
101
|
+
const wrapper = this._aeWrappers.get(listener);
|
|
102
|
+
if (wrapper) {
|
|
103
|
+
super.off('message', wrapper);
|
|
104
|
+
this._aeWrappers.delete(listener);
|
|
105
|
+
}
|
|
106
|
+
} else {
|
|
107
|
+
super.off(type, listener);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
on(event: string | symbol, listener: (...args: unknown[]) => void): this {
|
|
112
|
+
super.on(event, listener);
|
|
113
|
+
if (event === 'message' && !this._started) {
|
|
114
|
+
this.start();
|
|
115
|
+
}
|
|
116
|
+
return this;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
addListener(event: string | symbol, listener: (...args: unknown[]) => void): this {
|
|
120
|
+
return this.on(event, listener);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
once(event: string | symbol, listener: (...args: unknown[]) => void): this {
|
|
124
|
+
super.once(event, listener);
|
|
125
|
+
if (event === 'message' && !this._started) {
|
|
126
|
+
this.start();
|
|
127
|
+
}
|
|
128
|
+
return this;
|
|
129
|
+
}
|
|
130
|
+
}
|
package/src/test.mts
ADDED
package/src/worker.ts
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
// Reference: Node.js lib/internal/worker.js
|
|
2
|
+
// Reimplemented for GJS using Gio.Subprocess (subprocess-based workers)
|
|
3
|
+
//
|
|
4
|
+
// Limitations:
|
|
5
|
+
// - No shared memory (SharedArrayBuffer not supported across processes)
|
|
6
|
+
// - Worker scripts must be pre-built .mjs files (no on-the-fly TypeScript)
|
|
7
|
+
// - Higher overhead than native threading (process spawning)
|
|
8
|
+
// - eval mode code must be self-contained (no bare specifier imports)
|
|
9
|
+
|
|
10
|
+
import Gio from '@girs/gio-2.0';
|
|
11
|
+
import GLib from '@girs/glib-2.0';
|
|
12
|
+
import { EventEmitter } from 'node:events';
|
|
13
|
+
|
|
14
|
+
let _nextThreadId = 1;
|
|
15
|
+
const _encoder = new TextEncoder();
|
|
16
|
+
|
|
17
|
+
// GJS worker bootstrap script — runs in the child gjs process,
|
|
18
|
+
// sets up IPC via stdin/stdout pipes using gi:// imports.
|
|
19
|
+
const BOOTSTRAP_CODE = `\
|
|
20
|
+
import GLib from 'gi://GLib';
|
|
21
|
+
import Gio from 'gi://Gio';
|
|
22
|
+
|
|
23
|
+
const loop = new GLib.MainLoop(null, false);
|
|
24
|
+
const stdinStream = Gio.UnixInputStream.new(0, false);
|
|
25
|
+
const dataIn = Gio.DataInputStream.new(stdinStream);
|
|
26
|
+
const stdoutStream = Gio.UnixOutputStream.new(1, false);
|
|
27
|
+
|
|
28
|
+
function send(obj) {
|
|
29
|
+
const line = JSON.stringify(obj) + '\\n';
|
|
30
|
+
stdoutStream.write_all(_encoder.encode(line), null);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Read init data (first line, blocking)
|
|
34
|
+
const [initLine] = dataIn.read_line_utf8(null);
|
|
35
|
+
const init = JSON.parse(initLine);
|
|
36
|
+
|
|
37
|
+
// Simplified EventEmitter for parentPort
|
|
38
|
+
const _listeners = new Map();
|
|
39
|
+
const parentPort = {
|
|
40
|
+
on(ev, fn) {
|
|
41
|
+
if (!_listeners.has(ev)) _listeners.set(ev, []);
|
|
42
|
+
_listeners.get(ev).push(fn);
|
|
43
|
+
return this;
|
|
44
|
+
},
|
|
45
|
+
once(ev, fn) {
|
|
46
|
+
const w = (...a) => { parentPort.off(ev, w); fn(...a); };
|
|
47
|
+
return parentPort.on(ev, w);
|
|
48
|
+
},
|
|
49
|
+
off(ev, fn) {
|
|
50
|
+
const a = _listeners.get(ev);
|
|
51
|
+
if (a) _listeners.set(ev, a.filter(f => f !== fn));
|
|
52
|
+
return this;
|
|
53
|
+
},
|
|
54
|
+
emit(ev, ...a) { (_listeners.get(ev) || []).forEach(fn => fn(...a)); },
|
|
55
|
+
postMessage(data) { send({ type: 'message', data }); },
|
|
56
|
+
close() { send({ type: 'exit', code: 0 }); loop.quit(); },
|
|
57
|
+
removeAllListeners(ev) {
|
|
58
|
+
if (ev) _listeners.delete(ev); else _listeners.clear();
|
|
59
|
+
return this;
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Set worker context globals (read by @gjsify/worker_threads when imported by user script)
|
|
64
|
+
globalThis.__gjsify_worker_context = {
|
|
65
|
+
isMainThread: false,
|
|
66
|
+
parentPort,
|
|
67
|
+
workerData: init.workerData ?? null,
|
|
68
|
+
threadId: init.threadId ?? 0,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
send({ type: 'online' });
|
|
72
|
+
|
|
73
|
+
// Async stdin reader for messages from parent
|
|
74
|
+
function readNext() {
|
|
75
|
+
dataIn.read_line_async(GLib.PRIORITY_DEFAULT, null, (source, result) => {
|
|
76
|
+
try {
|
|
77
|
+
const [line] = source.read_line_finish_utf8(result);
|
|
78
|
+
if (line === null) { loop.quit(); return; }
|
|
79
|
+
const msg = JSON.parse(line);
|
|
80
|
+
if (msg.type === 'message') parentPort.emit('message', msg.data);
|
|
81
|
+
else if (msg.type === 'terminate') { send({ type: 'exit', code: 1 }); loop.quit(); return; }
|
|
82
|
+
readNext();
|
|
83
|
+
} catch { loop.quit(); }
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
readNext();
|
|
87
|
+
|
|
88
|
+
// Execute worker code
|
|
89
|
+
try {
|
|
90
|
+
if (init.eval) {
|
|
91
|
+
const AsyncFn = Object.getPrototypeOf(async function(){}).constructor;
|
|
92
|
+
await new AsyncFn('parentPort', 'workerData', 'threadId', init.code)(
|
|
93
|
+
parentPort, init.workerData, init.threadId
|
|
94
|
+
);
|
|
95
|
+
} else {
|
|
96
|
+
await import(init.filename);
|
|
97
|
+
}
|
|
98
|
+
} catch (error) {
|
|
99
|
+
send({ type: 'error', message: error.message, stack: error.stack || '' });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
loop.run();
|
|
103
|
+
`;
|
|
104
|
+
|
|
105
|
+
const BOOTSTRAP_BYTES = _encoder.encode(BOOTSTRAP_CODE);
|
|
106
|
+
|
|
107
|
+
export interface WorkerOptions {
|
|
108
|
+
argv?: unknown[];
|
|
109
|
+
env?: Record<string, string> | symbol;
|
|
110
|
+
execArgv?: string[];
|
|
111
|
+
stdin?: boolean;
|
|
112
|
+
stdout?: boolean;
|
|
113
|
+
stderr?: boolean;
|
|
114
|
+
workerData?: unknown;
|
|
115
|
+
eval?: boolean;
|
|
116
|
+
transferList?: unknown[];
|
|
117
|
+
resourceLimits?: Record<string, number>;
|
|
118
|
+
name?: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export class Worker extends EventEmitter {
|
|
122
|
+
readonly threadId: number;
|
|
123
|
+
readonly resourceLimits: Record<string, unknown>;
|
|
124
|
+
|
|
125
|
+
private _subprocess: Gio.Subprocess | null = null;
|
|
126
|
+
private _stdinPipe: Gio.OutputStream | null = null;
|
|
127
|
+
private _exited = false;
|
|
128
|
+
private _bootstrapFile: Gio.File | null = null;
|
|
129
|
+
|
|
130
|
+
constructor(filename: string | URL, options?: WorkerOptions) {
|
|
131
|
+
super();
|
|
132
|
+
this.threadId = _nextThreadId++;
|
|
133
|
+
this.resourceLimits = options?.resourceLimits || {};
|
|
134
|
+
|
|
135
|
+
const isEval = options?.eval === true;
|
|
136
|
+
const resolvedFilename = Worker._resolveFilename(filename, isEval);
|
|
137
|
+
|
|
138
|
+
// Write bootstrap script to temp file
|
|
139
|
+
const tmpDir = GLib.get_tmp_dir();
|
|
140
|
+
const bootstrapPath = `${tmpDir}/gjsify-worker-${this.threadId}-${Date.now()}.mjs`;
|
|
141
|
+
this._bootstrapFile = Gio.File.new_for_path(bootstrapPath);
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
this._bootstrapFile.replace_contents(
|
|
145
|
+
BOOTSTRAP_BYTES,
|
|
146
|
+
null, false, Gio.FileCreateFlags.REPLACE_DESTINATION, null
|
|
147
|
+
);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
throw new Error(`Failed to create worker bootstrap: ${err instanceof Error ? err.message : err}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Spawn GJS subprocess
|
|
153
|
+
const launcher = new Gio.SubprocessLauncher({
|
|
154
|
+
flags: Gio.SubprocessFlags.STDIN_PIPE
|
|
155
|
+
| Gio.SubprocessFlags.STDOUT_PIPE
|
|
156
|
+
| Gio.SubprocessFlags.STDERR_PIPE,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
if (options?.env && typeof options.env === 'object') {
|
|
160
|
+
for (const [key, value] of Object.entries(options.env as Record<string, string>)) {
|
|
161
|
+
launcher.setenv(key, String(value), true);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
this._subprocess = launcher.spawnv(['gjs', '-m', bootstrapPath]);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
this._cleanup();
|
|
169
|
+
throw new Error(`Failed to spawn worker: ${err instanceof Error ? err.message : err}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
this._stdinPipe = this._subprocess.get_stdin_pipe();
|
|
173
|
+
const stdoutPipe = this._subprocess.get_stdout_pipe();
|
|
174
|
+
|
|
175
|
+
// Verify file exists for non-eval workers
|
|
176
|
+
if (!isEval) {
|
|
177
|
+
const filePath = resolvedFilename.startsWith('file://')
|
|
178
|
+
? resolvedFilename.slice(7)
|
|
179
|
+
: resolvedFilename;
|
|
180
|
+
const file = Gio.File.new_for_path(filePath);
|
|
181
|
+
if (!file.query_exists(null)) {
|
|
182
|
+
this._cleanup();
|
|
183
|
+
const err = new Error(`Cannot find module '${filePath}'`);
|
|
184
|
+
(err as any).code = 'ERR_MODULE_NOT_FOUND';
|
|
185
|
+
// Emit error asynchronously to match Node.js behavior
|
|
186
|
+
Promise.resolve().then(() => {
|
|
187
|
+
this.emit('error', err);
|
|
188
|
+
this._exited = true;
|
|
189
|
+
this.emit('exit', 1);
|
|
190
|
+
});
|
|
191
|
+
// Return early — subprocess was already cleaned up
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Send init data as first line on stdin
|
|
197
|
+
const initData = JSON.stringify({
|
|
198
|
+
threadId: this.threadId,
|
|
199
|
+
workerData: options?.workerData ?? null,
|
|
200
|
+
eval: isEval,
|
|
201
|
+
filename: isEval ? undefined : resolvedFilename,
|
|
202
|
+
code: isEval ? resolvedFilename : undefined,
|
|
203
|
+
}) + '\n';
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
this._stdinPipe!.write_all(_encoder.encode(initData), null);
|
|
207
|
+
} catch (err) {
|
|
208
|
+
this._cleanup();
|
|
209
|
+
throw new Error(`Failed to send init data: ${err instanceof Error ? err.message : err}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Read IPC messages from subprocess stdout
|
|
213
|
+
if (stdoutPipe) {
|
|
214
|
+
const dataStream = Gio.DataInputStream.new(stdoutPipe);
|
|
215
|
+
this._readMessages(dataStream);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Read stderr for error reporting
|
|
219
|
+
const stderrPipe = this._subprocess.get_stderr_pipe();
|
|
220
|
+
if (stderrPipe) {
|
|
221
|
+
this._readStderr(Gio.DataInputStream.new(stderrPipe));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Wait for process exit
|
|
225
|
+
this._subprocess.wait_async(null, () => {
|
|
226
|
+
this._onExit();
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
postMessage(value: unknown, _transferList?: unknown[]): void {
|
|
231
|
+
if (this._exited || !this._stdinPipe) return;
|
|
232
|
+
try {
|
|
233
|
+
const line = JSON.stringify({ type: 'message', data: value }) + '\n';
|
|
234
|
+
this._stdinPipe.write_all(_encoder.encode(line), null);
|
|
235
|
+
} catch {
|
|
236
|
+
// Worker stdin closed
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
terminate(): Promise<number> {
|
|
241
|
+
if (this._exited) return Promise.resolve(0);
|
|
242
|
+
|
|
243
|
+
// Register listener before sending terminate to avoid race
|
|
244
|
+
const exitPromise = new Promise<number>((resolve) => {
|
|
245
|
+
this.once('exit', (code: number) => resolve(code));
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// Send terminate command
|
|
249
|
+
try {
|
|
250
|
+
if (this._stdinPipe) {
|
|
251
|
+
const msg = JSON.stringify({ type: 'terminate' }) + '\n';
|
|
252
|
+
this._stdinPipe.write_all(_encoder.encode(msg), null);
|
|
253
|
+
}
|
|
254
|
+
} catch {}
|
|
255
|
+
|
|
256
|
+
// Force-exit after timeout
|
|
257
|
+
setTimeout(() => {
|
|
258
|
+
if (!this._exited && this._subprocess) {
|
|
259
|
+
this._subprocess.force_exit();
|
|
260
|
+
}
|
|
261
|
+
}, 500);
|
|
262
|
+
|
|
263
|
+
return exitPromise;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
ref(): this { return this; }
|
|
267
|
+
unref(): this { return this; }
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Resolve a worker filename to an absolute path or file:// URL.
|
|
271
|
+
*
|
|
272
|
+
* - URL instances → href string
|
|
273
|
+
* - file:// URLs → kept as-is
|
|
274
|
+
* - Absolute paths → converted to file:// URL
|
|
275
|
+
* - Relative paths (./foo, ../bar) → resolved relative to cwd, converted to file:// URL
|
|
276
|
+
* - eval mode → returned as-is (it's code, not a path)
|
|
277
|
+
*/
|
|
278
|
+
private static _resolveFilename(filename: string | URL, isEval: boolean): string {
|
|
279
|
+
if (isEval) return String(filename);
|
|
280
|
+
|
|
281
|
+
if (filename instanceof URL) return filename.href;
|
|
282
|
+
|
|
283
|
+
const str = String(filename);
|
|
284
|
+
|
|
285
|
+
// Already a URL
|
|
286
|
+
if (str.startsWith('file://') || str.startsWith('http://') || str.startsWith('https://')) {
|
|
287
|
+
return str;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Absolute path → file:// URL
|
|
291
|
+
if (str.startsWith('/')) {
|
|
292
|
+
return 'file://' + str;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Relative path → resolve from cwd
|
|
296
|
+
if (str.startsWith('./') || str.startsWith('../') || !str.includes('/')) {
|
|
297
|
+
const cwd = GLib.get_current_dir();
|
|
298
|
+
const resolved = GLib.build_filenamev([cwd, str]);
|
|
299
|
+
// Canonicalize (resolve ./ and ../)
|
|
300
|
+
const file = Gio.File.new_for_path(resolved);
|
|
301
|
+
const canonical = file.get_path();
|
|
302
|
+
return 'file://' + (canonical || resolved);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Fallback — treat as absolute
|
|
306
|
+
return 'file://' + str;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private _readMessages(dataStream: Gio.DataInputStream): void {
|
|
310
|
+
dataStream.read_line_async(
|
|
311
|
+
GLib.PRIORITY_DEFAULT,
|
|
312
|
+
null,
|
|
313
|
+
(_source: unknown, result: Gio.AsyncResult) => {
|
|
314
|
+
try {
|
|
315
|
+
const [line] = dataStream.read_line_finish_utf8(result);
|
|
316
|
+
if (line === null) return; // EOF
|
|
317
|
+
|
|
318
|
+
const msg = JSON.parse(line);
|
|
319
|
+
switch (msg.type) {
|
|
320
|
+
case 'online':
|
|
321
|
+
this.emit('online');
|
|
322
|
+
break;
|
|
323
|
+
case 'message':
|
|
324
|
+
this.emit('message', msg.data);
|
|
325
|
+
break;
|
|
326
|
+
case 'error': {
|
|
327
|
+
const err = new Error(msg.message);
|
|
328
|
+
if (msg.stack) err.stack = msg.stack;
|
|
329
|
+
this.emit('error', err);
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
this._readMessages(dataStream);
|
|
335
|
+
} catch {
|
|
336
|
+
// Stream closed or parse error
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private _stderrChunks: string[] = [];
|
|
343
|
+
|
|
344
|
+
private _readStderr(dataStream: Gio.DataInputStream): void {
|
|
345
|
+
dataStream.read_line_async(
|
|
346
|
+
GLib.PRIORITY_DEFAULT,
|
|
347
|
+
null,
|
|
348
|
+
(_source: unknown, result: Gio.AsyncResult) => {
|
|
349
|
+
try {
|
|
350
|
+
const [line] = dataStream.read_line_finish_utf8(result);
|
|
351
|
+
if (line === null) {
|
|
352
|
+
// EOF — if we collected stderr output and worker errored, emit it
|
|
353
|
+
if (this._stderrChunks.length > 0) {
|
|
354
|
+
const stderrText = this._stderrChunks.join('\n');
|
|
355
|
+
// Only emit if no IPC error was already emitted
|
|
356
|
+
if (this.listenerCount('error') === 0) {
|
|
357
|
+
this.emit('error', new Error(stderrText));
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
this._stderrChunks.push(line);
|
|
363
|
+
this._readStderr(dataStream);
|
|
364
|
+
} catch {
|
|
365
|
+
// Stream closed
|
|
366
|
+
}
|
|
367
|
+
},
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private _onExit(): void {
|
|
372
|
+
if (this._exited) return;
|
|
373
|
+
this._exited = true;
|
|
374
|
+
|
|
375
|
+
const exitCode = this._subprocess?.get_if_exited()
|
|
376
|
+
? this._subprocess.get_exit_status()
|
|
377
|
+
: 1;
|
|
378
|
+
|
|
379
|
+
this._cleanup();
|
|
380
|
+
this.emit('exit', exitCode);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
private _cleanup(): void {
|
|
384
|
+
if (this._bootstrapFile) {
|
|
385
|
+
try { this._bootstrapFile.delete(null); } catch {}
|
|
386
|
+
this._bootstrapFile = null;
|
|
387
|
+
}
|
|
388
|
+
if (this._stdinPipe) {
|
|
389
|
+
try { this._stdinPipe.close(null); } catch {}
|
|
390
|
+
this._stdinPipe = null;
|
|
391
|
+
}
|
|
392
|
+
this._subprocess = null;
|
|
393
|
+
}
|
|
394
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"module": "ESNext",
|
|
4
|
+
"target": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"types": [
|
|
7
|
+
"node"
|
|
8
|
+
],
|
|
9
|
+
"experimentalDecorators": true,
|
|
10
|
+
"emitDeclarationOnly": true,
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"allowImportingTsExtensions": true,
|
|
13
|
+
"outDir": "lib",
|
|
14
|
+
"rootDir": "src",
|
|
15
|
+
"declarationDir": "lib/types",
|
|
16
|
+
"composite": true,
|
|
17
|
+
"skipLibCheck": true,
|
|
18
|
+
"allowJs": true,
|
|
19
|
+
"checkJs": false,
|
|
20
|
+
"strict": false
|
|
21
|
+
},
|
|
22
|
+
"include": [
|
|
23
|
+
"src/**/*.ts"
|
|
24
|
+
],
|
|
25
|
+
"exclude": [
|
|
26
|
+
"src/test.ts",
|
|
27
|
+
"src/test.mts",
|
|
28
|
+
"src/**/*.spec.ts",
|
|
29
|
+
"src/**/*.spec.mts"
|
|
30
|
+
]
|
|
31
|
+
}
|