@tiga-ipc/mmap 0.1.0 → 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.
package/README.md CHANGED
@@ -2,12 +2,14 @@
2
2
 
3
3
  `@tiga-ipc/mmap` is the Node.js package for the Tiga memory-mapped IPC protocol.
4
4
 
5
- It provides a small CommonJS wrapper around the native `index.node` addon and exposes the current Tiga channel APIs:
5
+ It provides a CommonJS wrapper around the native `index.node` addon and exposes both low-level channel primitives and a higher-level server helper:
6
6
 
7
7
  - `initialized()`
8
8
  - `tigaWrite(name, message, options?)`
9
9
  - `tigaRead(name, options?)`
10
10
  - `tigaInvoke(requestName, responseName, method, data, options?)`
11
+ - `startTigaServer(options)`
12
+ - `createTigaServer(options)`
11
13
 
12
14
  ## Install
13
15
 
@@ -19,39 +21,66 @@ Current package scope:
19
21
 
20
22
  - Windows only
21
23
  - File-backed mapping usage
22
- - `mappingDirectory` must be passed explicitly by the caller
24
+ - `ipcDirectory` must be passed explicitly by the caller
23
25
 
24
26
  ## Usage
25
27
 
26
28
  ```js
27
- const { tigaInvoke, tigaWrite, tigaRead } = require('@tiga-ipc/mmap');
28
-
29
- const mappingDirectory = 'C:\\temp\\tiga-ipc';
30
-
31
- const reply = tigaInvoke(
32
- 'sample.req.client-a',
33
- 'sample.resp.client-a',
34
- 'echo',
35
- 'hello from node',
36
- {
37
- mappingDirectory,
38
- timeoutMs: 3000,
39
- },
40
- );
41
-
42
- console.log(reply);
43
-
44
- tigaWrite('sample.events', 'event payload', {
45
- mappingDirectory,
46
- mediaType: 'text/plain',
47
- });
29
+ const {
30
+ startTigaServer,
31
+ tigaInvoke,
32
+ tigaWrite,
33
+ tigaRead,
34
+ } = require('@tiga-ipc/mmap');
35
+
36
+ async function main() {
37
+ const ipcDirectory = 'C:\\temp\\tiga-ipc';
38
+ const server = startTigaServer({
39
+ channelName: 'sample',
40
+ ipcDirectory,
41
+ onInvoke(method, data) {
42
+ if (method === 'echo') {
43
+ return `reply:${data}`;
44
+ }
45
+
46
+ throw new Error(`method not supported: ${method}`);
47
+ },
48
+ });
49
+
50
+ try {
51
+ const reply = tigaInvoke(
52
+ 'sample.req.client-a',
53
+ 'sample.resp.client-a',
54
+ 'echo',
55
+ 'hello from node',
56
+ {
57
+ ipcDirectory,
58
+ timeoutMs: 3000,
59
+ },
60
+ );
61
+
62
+ console.log(reply);
63
+
64
+ tigaWrite('sample.events', 'event payload', {
65
+ ipcDirectory,
66
+ mediaType: 'text/plain',
67
+ });
68
+
69
+ const result = tigaRead('sample.events', {
70
+ ipcDirectory,
71
+ lastId: 0,
72
+ });
73
+
74
+ console.log(result.lastId, result.entries.length);
75
+ } finally {
76
+ await server.close();
77
+ }
78
+ }
48
79
 
49
- const result = tigaRead('sample.events', {
50
- mappingDirectory,
51
- lastId: 0,
80
+ main().catch((error) => {
81
+ console.error(error);
82
+ process.exit(1);
52
83
  });
53
-
54
- console.log(result.lastId, result.entries.length);
55
84
  ```
56
85
 
57
86
  ## API
@@ -60,7 +89,7 @@ console.log(result.lastId, result.entries.length);
60
89
 
61
90
  - `name: string`
62
91
  - `message: Buffer | string`
63
- - `options?.mappingDirectory: string`
92
+ - `options?.ipcDirectory: string`
64
93
  - `options?.mediaType?: string`
65
94
 
66
95
  Returns a short write result string from the native addon.
@@ -68,7 +97,7 @@ Returns a short write result string from the native addon.
68
97
  ### `tigaRead(name, options?)`
69
98
 
70
99
  - `name: string`
71
- - `options?.mappingDirectory: string`
100
+ - `options?.ipcDirectory: string`
72
101
  - `options?.lastId?: number`
73
102
 
74
103
  Returns:
@@ -90,16 +119,55 @@ interface TigaReadResult {
90
119
  - `responseName: string`
91
120
  - `method: string`
92
121
  - `data: string`
93
- - `options?.mappingDirectory: string`
122
+ - `options?.ipcDirectory: string`
94
123
  - `options?.timeoutMs?: number`
95
124
 
96
125
  Returns the response payload string.
97
126
 
127
+ ### `startTigaServer(options)` / `createTigaServer(options)`
128
+
129
+ - `options.channelName: string`
130
+ - `options.ipcDirectory: string`
131
+ - `options.discoveryIntervalMs?: number`
132
+ - `options.waitTimeoutMs?: number`
133
+ - `options.onInvoke(method, data, context): unknown | Promise<unknown>`
134
+ - `options.onError?(error, context): void`
135
+
136
+ Returns a `TigaServer` instance. `startTigaServer(...)` starts it immediately. `createTigaServer(...)` returns the instance so the caller can decide when to call `server.start()`.
137
+
138
+ `context` includes:
139
+
140
+ ```ts
141
+ interface TigaServerContext {
142
+ channelName: string;
143
+ clientId: string;
144
+ requestName: string;
145
+ responseName: string;
146
+ ipcDirectory: string;
147
+ requestId: string;
148
+ entryId: number;
149
+ mediaType?: string | null;
150
+ }
151
+ ```
152
+
153
+ The server helper keeps transport concerns inside the package:
154
+
155
+ - discovers per-client request channels under the configured `ipcDirectory`
156
+ - registers request listeners using the native notification mechanism
157
+ - decodes invoke payloads and writes response payloads back to the matching response channel
158
+ - surfaces business logic as a single `onInvoke(...)` callback
159
+
160
+ ### `createTigaNotificationListener(name, options?)`
161
+
162
+ Advanced low-level API for consumers that need direct access to the native notification wait handle. Most applications should use `startTigaServer(...)` instead.
163
+
98
164
  ## Notes
99
165
 
100
166
  - This package intentionally uses the current `tiga*` API only. The old generic `write/read` export surface is not part of the published package entry.
101
167
  - The native binary is packaged as `index.node`, so publish from a Windows environment after rebuilding the addon you want to ship.
102
168
  - For repository examples and cross-language smoke tests, see the monorepo root README and `examples/`.
169
+ - `tigaInvoke(...)` is synchronous. In practice the server and client should live in different processes, which matches the real Tiga IPC usage model.
170
+ - For per-client channels, the first invoke may wait up to the discovery interval until the server notices the newly created request channel and registers a listener.
103
171
 
104
172
  ## License
105
173
 
package/index.d.ts CHANGED
@@ -14,17 +14,54 @@ export interface TigaEntry {
14
14
  mediaType?: string
15
15
  }
16
16
  export interface TigaChannelOptions {
17
- mappingDirectory?: string
17
+ ipcDirectory?: string
18
18
  }
19
- export interface TigaWriteOptions extends TigaChannelOptions {
20
- mediaType?: string | null
19
+ export interface TigaWriteOptions {
20
+ mediaType?: string
21
+ ipcDirectory?: string
21
22
  }
22
- export interface TigaReadOptions extends TigaChannelOptions {
23
+ export interface TigaReadOptions {
23
24
  lastId?: number
25
+ ipcDirectory?: string
24
26
  }
25
- export interface TigaInvokeOptions extends TigaChannelOptions {
27
+ export interface TigaInvokeOptions {
26
28
  timeoutMs?: number
29
+ ipcDirectory?: string
30
+ }
31
+ export declare function tigaWrite(name: string, message: Buffer | string, options?: TigaWriteOptions | undefined | null): string
32
+ export declare function tigaRead(name: string, options?: TigaReadOptions | undefined | null): TigaReadResult
33
+ export declare function tigaInvoke(requestName: string, responseName: string, method: string, data: string, options?: TigaInvokeOptions | undefined | null): string
34
+ export declare function createTigaNotificationListener(name: string, options?: TigaChannelOptions | undefined | null): TigaNotificationListener
35
+ export declare class TigaNotificationListener {
36
+ wait(timeoutMs?: number): boolean
37
+ close(): void
38
+ get closed(): boolean
39
+ }
40
+ export interface TigaServerContext {
41
+ channelName: string
42
+ clientId: string
43
+ requestName: string
44
+ responseName: string
45
+ ipcDirectory: string
46
+ requestId: string
47
+ entryId: number
48
+ mediaType?: string | null
49
+ }
50
+ export interface TigaServerOptions extends TigaChannelOptions {
51
+ channelName: string
52
+ discoveryIntervalMs?: number
53
+ waitTimeoutMs?: number
54
+ onInvoke(method: string, data: unknown, context: TigaServerContext): unknown | Promise<unknown>
55
+ onError?(error: Error, context: Record<string, unknown>): void
56
+ }
57
+ export declare class TigaServer {
58
+ constructor(options: TigaServerOptions)
59
+ readonly channelName: string
60
+ readonly ipcDirectory: string
61
+ readonly closed: boolean
62
+ readonly started: boolean
63
+ start(): TigaServer
64
+ close(): Promise<void>
27
65
  }
28
- export declare function tigaWrite(name: string, message: Buffer | string, options?: TigaWriteOptions): string
29
- export declare function tigaRead(name: string, options?: TigaReadOptions): TigaReadResult
30
- export declare function tigaInvoke(requestName: string, responseName: string, method: string, data: string, options?: TigaInvokeOptions): string
66
+ export declare function createTigaServer(options: TigaServerOptions): TigaServer
67
+ export declare function startTigaServer(options: TigaServerOptions): TigaServer
package/index.js CHANGED
@@ -1,8 +1 @@
1
- const binding = require('./index.node');
2
-
3
- module.exports = {
4
- initialized: binding.initialized,
5
- tigaWrite: binding.tigaWrite,
6
- tigaRead: binding.tigaRead,
7
- tigaInvoke: binding.tigaInvoke,
8
- };
1
+ module.exports = require('./runtime');
package/index.node CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiga-ipc/mmap",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Windows memory-mapped IPC bindings for Tiga channels.",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -16,19 +16,22 @@
16
16
  "README.md",
17
17
  "index.js",
18
18
  "index.d.ts",
19
- "index.node"
19
+ "index.node",
20
+ "runtime/"
20
21
  ],
21
22
  "os": [
22
23
  "win32"
23
24
  ],
24
25
  "scripts": {
25
26
  "test": "cargo test",
26
- "cargo-build": "npx @napi-rs/cli build",
27
- "cross-build": "npx @napi-rs/cli build",
27
+ "cargo-build": "node ./scripts/build-addon.js",
28
+ "cross-build": "node ./scripts/build-addon.js",
28
29
  "debug": "npm run cargo-build --",
29
30
  "build": "npm run cargo-build -- --release",
30
31
  "cross": "npm run cross-build -- --release",
31
- "prepack": "node -e \"require('fs').accessSync('index.node')\""
32
+ "sync:types": "node ./scripts/sync-package-types.js",
33
+ "publish:check": "npm publish --dry-run --access public",
34
+ "prepack": "node -e \"require('fs').accessSync('index.node')\" && npm run sync:types"
32
35
  },
33
36
  "keywords": [
34
37
  "ipc",
@@ -51,6 +54,9 @@
51
54
  "url": "https://github.com/sugarbearr/TigaIpc/issues"
52
55
  },
53
56
  "homepage": "https://github.com/sugarbearr/TigaIpc",
57
+ "dependencies": {
58
+ "@msgpack/msgpack": "^3.1.2"
59
+ },
54
60
  "devDependencies": {
55
61
  "@napi-rs/cli": "^2.18.0"
56
62
  }
@@ -0,0 +1,13 @@
1
+ const binding = require('./native');
2
+ const {
3
+ TigaServer,
4
+ createTigaServer,
5
+ startTigaServer,
6
+ } = require('./server');
7
+
8
+ module.exports = {
9
+ ...binding,
10
+ TigaServer,
11
+ createTigaServer,
12
+ startTigaServer,
13
+ };
@@ -0,0 +1 @@
1
+ module.exports = require('../index.node');
@@ -0,0 +1,73 @@
1
+ const zlib = require('zlib');
2
+
3
+ const { decode, encode } = require('@msgpack/msgpack');
4
+
5
+ const TIGA_MEDIA_TYPE_MSGPACK = 'application/x-msgpack';
6
+ const TIGA_MEDIA_TYPE_MSGPACK_COMPRESSED =
7
+ 'application/x-msgpack-compressed';
8
+
9
+ const parseTigaInvokeEntry = (entry) => {
10
+ try {
11
+ const raw =
12
+ entry && entry.mediaType === TIGA_MEDIA_TYPE_MSGPACK_COMPRESSED
13
+ ? zlib.gunzipSync(entry.message)
14
+ : entry && entry.message;
15
+ const decoded = decode(raw);
16
+ if (!Array.isArray(decoded) || decoded.length < 4) {
17
+ return null;
18
+ }
19
+
20
+ const [id, protocol, method, data] = decoded;
21
+ if (protocol !== 1 || typeof id !== 'string' || typeof method !== 'string') {
22
+ return null;
23
+ }
24
+
25
+ return { id, method, data };
26
+ } catch {
27
+ return null;
28
+ }
29
+ };
30
+
31
+ const buildTigaResponseBuffer = (id, data, code) =>
32
+ Buffer.from(encode([id, 2, data, code]));
33
+
34
+ const stringifyTigaResponseData = (value) => {
35
+ if (typeof value === 'string') {
36
+ return value;
37
+ }
38
+
39
+ if (Buffer.isBuffer(value)) {
40
+ return value.toString('utf8');
41
+ }
42
+
43
+ if (value === undefined) {
44
+ return '';
45
+ }
46
+
47
+ try {
48
+ return JSON.stringify(value);
49
+ } catch {
50
+ return String(value);
51
+ }
52
+ };
53
+
54
+ const getTigaErrorMessage = (error, fallback = 'invoke failed') => {
55
+ if (error instanceof Error && error.message) {
56
+ return error.message;
57
+ }
58
+
59
+ if (typeof error === 'string' && error.trim()) {
60
+ return error;
61
+ }
62
+
63
+ return fallback;
64
+ };
65
+
66
+ module.exports = {
67
+ TIGA_MEDIA_TYPE_MSGPACK,
68
+ TIGA_MEDIA_TYPE_MSGPACK_COMPRESSED,
69
+ buildTigaResponseBuffer,
70
+ getTigaErrorMessage,
71
+ parseTigaInvokeEntry,
72
+ stringifyTigaResponseData,
73
+ };
@@ -0,0 +1,436 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { Worker } = require('worker_threads');
4
+
5
+ const binding = require('./native');
6
+ const {
7
+ TIGA_MEDIA_TYPE_MSGPACK,
8
+ buildTigaResponseBuffer,
9
+ getTigaErrorMessage,
10
+ parseTigaInvokeEntry,
11
+ stringifyTigaResponseData,
12
+ } = require('./protocol');
13
+
14
+ const FILE_PREFIX = 'tiga_';
15
+ const STATE_SUFFIX = '_state';
16
+ const SINGLE_CLIENT_ID = '__single__';
17
+ const DEFAULT_DISCOVERY_INTERVAL_MS = 1000;
18
+ const DEFAULT_WAIT_TIMEOUT_MS = 1000;
19
+ const WORKER_CLOSE_TIMEOUT_MS = 2000;
20
+
21
+ const ensureString = (value, label) => {
22
+ if (typeof value !== 'string' || value.trim().length === 0) {
23
+ throw new Error(`${label} is required`);
24
+ }
25
+
26
+ return value.trim();
27
+ };
28
+
29
+ const getStatePath = (ipcDirectory, name) =>
30
+ path.join(ipcDirectory, `${FILE_PREFIX}${name}${STATE_SUFFIX}`);
31
+
32
+ const hasSingleChannel = (channelName, ipcDirectory) =>
33
+ fs.existsSync(getStatePath(ipcDirectory, channelName));
34
+
35
+ const listClientIds = (channelName, ipcDirectory) => {
36
+ try {
37
+ const prefix = `${FILE_PREFIX}${channelName}.req.`;
38
+ return fs
39
+ .readdirSync(ipcDirectory)
40
+ .filter((fileName) => fileName.startsWith(prefix))
41
+ .filter((fileName) => fileName.endsWith(STATE_SUFFIX))
42
+ .map((fileName) =>
43
+ fileName.slice(prefix.length, fileName.length - STATE_SUFFIX.length),
44
+ )
45
+ .filter((clientId) => clientId.length > 0);
46
+ } catch {
47
+ return [];
48
+ }
49
+ };
50
+
51
+ const toClientState = (clientId, channelName) =>
52
+ clientId === SINGLE_CLIENT_ID
53
+ ? {
54
+ key: SINGLE_CLIENT_ID,
55
+ clientId,
56
+ requestName: channelName,
57
+ responseName: channelName,
58
+ }
59
+ : {
60
+ key: clientId,
61
+ clientId,
62
+ requestName: `${channelName}.req.${clientId}`,
63
+ responseName: `${channelName}.resp.${clientId}`,
64
+ };
65
+
66
+ class TigaServer {
67
+ constructor(options) {
68
+ const resolved = options || {};
69
+ this.channelName = ensureString(resolved.channelName, 'channelName');
70
+ this.ipcDirectory = ensureString(resolved.ipcDirectory, 'ipcDirectory');
71
+ if (typeof resolved.onInvoke !== 'function') {
72
+ throw new Error('onInvoke must be a function');
73
+ }
74
+
75
+ this.onInvoke = resolved.onInvoke;
76
+ this.onError =
77
+ typeof resolved.onError === 'function' ? resolved.onError : null;
78
+ this.discoveryIntervalMs = Math.max(
79
+ 100,
80
+ Number(resolved.discoveryIntervalMs || DEFAULT_DISCOVERY_INTERVAL_MS),
81
+ );
82
+ this.waitTimeoutMs = Math.max(
83
+ 100,
84
+ Number(resolved.waitTimeoutMs || DEFAULT_WAIT_TIMEOUT_MS),
85
+ );
86
+
87
+ this.clients = new Map();
88
+ this.discoveryTimer = null;
89
+ this.closed = false;
90
+ this.started = false;
91
+ }
92
+
93
+ start() {
94
+ if (this.started) {
95
+ return this;
96
+ }
97
+
98
+ fs.mkdirSync(this.ipcDirectory, { recursive: true });
99
+ this.started = true;
100
+ this.closed = false;
101
+ this.discoveryTimer = setInterval(() => {
102
+ this.discover().catch((error) => {
103
+ this.reportError(error, {
104
+ stage: 'discover',
105
+ channelName: this.channelName,
106
+ ipcDirectory: this.ipcDirectory,
107
+ });
108
+ });
109
+ }, this.discoveryIntervalMs);
110
+ this.discover().catch((error) => {
111
+ this.reportError(error, {
112
+ stage: 'discover',
113
+ channelName: this.channelName,
114
+ ipcDirectory: this.ipcDirectory,
115
+ });
116
+ });
117
+
118
+ return this;
119
+ }
120
+
121
+ async close() {
122
+ if (this.closed) {
123
+ return;
124
+ }
125
+
126
+ this.closed = true;
127
+ this.started = false;
128
+ if (this.discoveryTimer) {
129
+ clearInterval(this.discoveryTimer);
130
+ this.discoveryTimer = null;
131
+ }
132
+
133
+ const clients = Array.from(this.clients.values());
134
+ this.clients.clear();
135
+ await Promise.all(clients.map((client) => this.closeClient(client)));
136
+ }
137
+
138
+ async discover() {
139
+ if (this.closed) {
140
+ return;
141
+ }
142
+
143
+ const nextClients = listClientIds(this.channelName, this.ipcDirectory);
144
+ nextClients.forEach((clientId) => {
145
+ if (!this.clients.has(clientId)) {
146
+ this.registerClient(toClientState(clientId, this.channelName));
147
+ }
148
+ });
149
+
150
+ if (
151
+ !this.clients.has(SINGLE_CLIENT_ID) &&
152
+ hasSingleChannel(this.channelName, this.ipcDirectory)
153
+ ) {
154
+ this.registerClient(toClientState(SINGLE_CLIENT_ID, this.channelName));
155
+ }
156
+ }
157
+
158
+ registerClient(client) {
159
+ const state = {
160
+ ...client,
161
+ lastId: 0,
162
+ draining: false,
163
+ pendingDrain: false,
164
+ closing: false,
165
+ worker: null,
166
+ workerExit: null,
167
+ restartTimer: null,
168
+ };
169
+
170
+ this.clients.set(state.key, state);
171
+ this.startWorker(state);
172
+ }
173
+
174
+ startWorker(client) {
175
+ if (this.closed || client.closing) {
176
+ return;
177
+ }
178
+
179
+ const worker = new Worker(
180
+ path.join(__dirname, 'workers', 'notification-listener.js'),
181
+ {
182
+ workerData: {
183
+ name: client.requestName,
184
+ ipcDirectory: this.ipcDirectory,
185
+ waitTimeoutMs: this.waitTimeoutMs,
186
+ },
187
+ },
188
+ );
189
+
190
+ client.worker = worker;
191
+ client.workerExit = new Promise((resolve) => {
192
+ worker.once('exit', () => resolve());
193
+ });
194
+
195
+ worker.on('message', (message) => {
196
+ if (!message || client.closing || this.closed) {
197
+ return;
198
+ }
199
+
200
+ if (message.type === 'ready' || message.type === 'signal') {
201
+ this.scheduleDrain(client);
202
+ return;
203
+ }
204
+
205
+ if (message.type === 'error') {
206
+ this.reportError(new Error(message.message), {
207
+ stage: 'listener',
208
+ clientId: client.clientId,
209
+ requestName: client.requestName,
210
+ responseName: client.responseName,
211
+ channelName: this.channelName,
212
+ ipcDirectory: this.ipcDirectory,
213
+ });
214
+ }
215
+ });
216
+
217
+ worker.on('error', (error) => {
218
+ this.reportError(error, {
219
+ stage: 'listener',
220
+ clientId: client.clientId,
221
+ requestName: client.requestName,
222
+ responseName: client.responseName,
223
+ channelName: this.channelName,
224
+ ipcDirectory: this.ipcDirectory,
225
+ });
226
+ });
227
+
228
+ worker.on('exit', () => {
229
+ client.worker = null;
230
+ client.workerExit = null;
231
+ if (!this.closed && !client.closing) {
232
+ this.scheduleWorkerRestart(client);
233
+ }
234
+ });
235
+ }
236
+
237
+ scheduleWorkerRestart(client) {
238
+ if (client.restartTimer || this.closed || client.closing) {
239
+ return;
240
+ }
241
+
242
+ client.restartTimer = setTimeout(() => {
243
+ client.restartTimer = null;
244
+ if (!this.closed && !client.closing && this.clients.has(client.key)) {
245
+ this.startWorker(client);
246
+ }
247
+ }, 200);
248
+ }
249
+
250
+ scheduleDrain(client) {
251
+ if (this.closed || client.closing) {
252
+ return;
253
+ }
254
+
255
+ client.pendingDrain = true;
256
+ if (client.draining) {
257
+ return;
258
+ }
259
+
260
+ client.draining = true;
261
+ void this.runDrainLoop(client);
262
+ }
263
+
264
+ async runDrainLoop(client) {
265
+ try {
266
+ while (client.pendingDrain && !client.closing && !this.closed) {
267
+ client.pendingDrain = false;
268
+ await this.drainClient(client);
269
+ }
270
+ } finally {
271
+ client.draining = false;
272
+ if (client.pendingDrain && !client.closing && !this.closed) {
273
+ this.scheduleDrain(client);
274
+ }
275
+ }
276
+ }
277
+
278
+ async drainClient(client) {
279
+ let result;
280
+ try {
281
+ result = binding.tigaRead(client.requestName, {
282
+ lastId: client.lastId,
283
+ ipcDirectory: this.ipcDirectory,
284
+ });
285
+ } catch (error) {
286
+ this.reportError(error, {
287
+ stage: 'read',
288
+ clientId: client.clientId,
289
+ requestName: client.requestName,
290
+ responseName: client.responseName,
291
+ channelName: this.channelName,
292
+ ipcDirectory: this.ipcDirectory,
293
+ });
294
+ return;
295
+ }
296
+
297
+ if (
298
+ result &&
299
+ typeof result.lastId === 'number' &&
300
+ result.lastId < client.lastId
301
+ ) {
302
+ client.lastId = result.lastId;
303
+ }
304
+
305
+ const entries = Array.isArray(result && result.entries)
306
+ ? result.entries.filter((entry) => entry && entry.id > client.lastId)
307
+ : [];
308
+
309
+ for (const entry of entries) {
310
+ await this.processEntry(client, entry);
311
+ client.lastId = Math.max(client.lastId, entry.id);
312
+ }
313
+ }
314
+
315
+ async processEntry(client, entry) {
316
+ const invoke = parseTigaInvokeEntry(entry);
317
+ if (!invoke) {
318
+ return;
319
+ }
320
+
321
+ const context = {
322
+ channelName: this.channelName,
323
+ clientId: client.clientId,
324
+ requestName: client.requestName,
325
+ responseName: client.responseName,
326
+ ipcDirectory: this.ipcDirectory,
327
+ requestId: invoke.id,
328
+ entryId: entry.id,
329
+ mediaType: entry.mediaType || null,
330
+ };
331
+
332
+ try {
333
+ const result = await Promise.resolve(
334
+ this.onInvoke(invoke.method, invoke.data, context),
335
+ );
336
+ this.writeResponse(
337
+ client,
338
+ buildTigaResponseBuffer(
339
+ invoke.id,
340
+ stringifyTigaResponseData(result),
341
+ 0,
342
+ ),
343
+ );
344
+ } catch (error) {
345
+ const message = getTigaErrorMessage(error);
346
+ this.reportError(error, {
347
+ ...context,
348
+ stage: 'invoke',
349
+ method: invoke.method,
350
+ });
351
+ this.writeResponse(
352
+ client,
353
+ buildTigaResponseBuffer(invoke.id, message, -1),
354
+ );
355
+ }
356
+ }
357
+
358
+ writeResponse(client, payload) {
359
+ try {
360
+ binding.tigaWrite(client.responseName, payload, {
361
+ mediaType: TIGA_MEDIA_TYPE_MSGPACK,
362
+ ipcDirectory: this.ipcDirectory,
363
+ });
364
+ } catch (error) {
365
+ this.reportError(error, {
366
+ stage: 'write',
367
+ clientId: client.clientId,
368
+ requestName: client.requestName,
369
+ responseName: client.responseName,
370
+ channelName: this.channelName,
371
+ ipcDirectory: this.ipcDirectory,
372
+ });
373
+ }
374
+ }
375
+
376
+ async closeClient(client) {
377
+ client.closing = true;
378
+ client.pendingDrain = false;
379
+ if (client.restartTimer) {
380
+ clearTimeout(client.restartTimer);
381
+ client.restartTimer = null;
382
+ }
383
+
384
+ const worker = client.worker;
385
+ const workerExit = client.workerExit;
386
+ client.worker = null;
387
+ client.workerExit = null;
388
+ if (!worker) {
389
+ return;
390
+ }
391
+
392
+ try {
393
+ worker.postMessage({ type: 'close' });
394
+ } catch {
395
+ // Ignore worker shutdown races.
396
+ }
397
+
398
+ if (workerExit) {
399
+ const terminated = workerExit.then(() => true);
400
+ const timeout = new Promise((resolve) => {
401
+ setTimeout(() => resolve(false), WORKER_CLOSE_TIMEOUT_MS);
402
+ });
403
+
404
+ const exited = await Promise.race([terminated, timeout]);
405
+ if (exited) {
406
+ return;
407
+ }
408
+ }
409
+
410
+ await worker.terminate();
411
+ }
412
+
413
+ reportError(error, context) {
414
+ if (!this.onError) {
415
+ return;
416
+ }
417
+
418
+ try {
419
+ const normalizedError =
420
+ error instanceof Error ? error : new Error(String(error));
421
+ this.onError(normalizedError, context);
422
+ } catch {
423
+ // Ignore user callback errors to keep the server alive.
424
+ }
425
+ }
426
+ }
427
+
428
+ const createTigaServer = (options) => new TigaServer(options);
429
+
430
+ const startTigaServer = (options) => createTigaServer(options).start();
431
+
432
+ module.exports = {
433
+ TigaServer,
434
+ createTigaServer,
435
+ startTigaServer,
436
+ };
@@ -0,0 +1,72 @@
1
+ const { parentPort, workerData } = require('worker_threads');
2
+
3
+ const { createTigaNotificationListener } = require('../native');
4
+
5
+ const post = (message) => {
6
+ if (parentPort) {
7
+ parentPort.postMessage(message);
8
+ }
9
+ };
10
+
11
+ const getErrorMessage = (error) => {
12
+ if (error instanceof Error && error.message) {
13
+ return error.message;
14
+ }
15
+
16
+ return String(error);
17
+ };
18
+
19
+ let closed = false;
20
+ let listener;
21
+
22
+ if (!parentPort) {
23
+ throw new Error('tiga notification worker requires parentPort');
24
+ }
25
+
26
+ parentPort.on('message', (message) => {
27
+ if (!message || message.type !== 'close') {
28
+ return;
29
+ }
30
+
31
+ closed = true;
32
+ if (listener) {
33
+ try {
34
+ listener.close();
35
+ } catch {
36
+ // Ignore close races during shutdown.
37
+ }
38
+ }
39
+ });
40
+
41
+ try {
42
+ listener = createTigaNotificationListener(workerData.name, {
43
+ ipcDirectory: workerData.ipcDirectory,
44
+ });
45
+ post({ type: 'ready' });
46
+
47
+ while (!closed && !listener.closed) {
48
+ const signaled = listener.wait(workerData.waitTimeoutMs);
49
+ if (closed || listener.closed) {
50
+ break;
51
+ }
52
+
53
+ if (signaled) {
54
+ post({ type: 'signal' });
55
+ }
56
+ }
57
+ } catch (error) {
58
+ post({
59
+ type: 'error',
60
+ message: getErrorMessage(error),
61
+ });
62
+ } finally {
63
+ if (listener && !listener.closed) {
64
+ try {
65
+ listener.close();
66
+ } catch {
67
+ // Ignore close races during shutdown.
68
+ }
69
+ }
70
+
71
+ post({ type: 'closed' });
72
+ }