@tiga-ipc/mmap 0.1.0 → 0.2.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 +94 -26
- package/index.d.ts +44 -7
- package/index.js +1 -8
- package/index.node +0 -0
- package/package.json +11 -5
- package/runtime/index.js +13 -0
- package/runtime/native.js +1 -0
- package/runtime/protocol.js +73 -0
- package/runtime/server.js +439 -0
- package/runtime/workers/notification-listener.js +72 -0
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
|
|
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
|
|
|
@@ -24,34 +26,61 @@ Current package scope:
|
|
|
24
26
|
## Usage
|
|
25
27
|
|
|
26
28
|
```js
|
|
27
|
-
const {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
{
|
|
29
|
+
const {
|
|
30
|
+
startTigaServer,
|
|
31
|
+
tigaInvoke,
|
|
32
|
+
tigaWrite,
|
|
33
|
+
tigaRead,
|
|
34
|
+
} = require('@tiga-ipc/mmap');
|
|
35
|
+
|
|
36
|
+
async function main() {
|
|
37
|
+
const mappingDirectory = 'C:\\temp\\tiga-ipc';
|
|
38
|
+
const server = startTigaServer({
|
|
39
|
+
baseName: 'sample',
|
|
37
40
|
mappingDirectory,
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
+
mappingDirectory,
|
|
58
|
+
timeoutMs: 3000,
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
console.log(reply);
|
|
63
|
+
|
|
64
|
+
tigaWrite('sample.events', 'event payload', {
|
|
65
|
+
mappingDirectory,
|
|
66
|
+
mediaType: 'text/plain',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const result = tigaRead('sample.events', {
|
|
70
|
+
mappingDirectory,
|
|
71
|
+
lastId: 0,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
console.log(result.lastId, result.entries.length);
|
|
75
|
+
} finally {
|
|
76
|
+
await server.close();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
48
79
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
@@ -95,11 +124,50 @@ interface TigaReadResult {
|
|
|
95
124
|
|
|
96
125
|
Returns the response payload string.
|
|
97
126
|
|
|
127
|
+
### `startTigaServer(options)` / `createTigaServer(options)`
|
|
128
|
+
|
|
129
|
+
- `options.baseName: string`
|
|
130
|
+
- `options.mappingDirectory: 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
|
+
baseName: string;
|
|
143
|
+
clientId: string;
|
|
144
|
+
requestName: string;
|
|
145
|
+
responseName: string;
|
|
146
|
+
mappingDirectory: 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 `mappingDirectory`
|
|
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
|
@@ -16,15 +16,52 @@ export interface TigaEntry {
|
|
|
16
16
|
export interface TigaChannelOptions {
|
|
17
17
|
mappingDirectory?: string
|
|
18
18
|
}
|
|
19
|
-
export interface TigaWriteOptions
|
|
20
|
-
mediaType?: string
|
|
19
|
+
export interface TigaWriteOptions {
|
|
20
|
+
mediaType?: string
|
|
21
|
+
mappingDirectory?: string
|
|
21
22
|
}
|
|
22
|
-
export interface TigaReadOptions
|
|
23
|
+
export interface TigaReadOptions {
|
|
23
24
|
lastId?: number
|
|
25
|
+
mappingDirectory?: string
|
|
24
26
|
}
|
|
25
|
-
export interface TigaInvokeOptions
|
|
27
|
+
export interface TigaInvokeOptions {
|
|
26
28
|
timeoutMs?: number
|
|
29
|
+
mappingDirectory?: 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
|
+
baseName: string
|
|
42
|
+
clientId: string
|
|
43
|
+
requestName: string
|
|
44
|
+
responseName: string
|
|
45
|
+
mappingDirectory: string
|
|
46
|
+
requestId: string
|
|
47
|
+
entryId: number
|
|
48
|
+
mediaType?: string | null
|
|
49
|
+
}
|
|
50
|
+
export interface TigaServerOptions extends TigaChannelOptions {
|
|
51
|
+
baseName: 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 baseName: string
|
|
60
|
+
readonly mappingDirectory: string
|
|
61
|
+
readonly closed: boolean
|
|
62
|
+
readonly started: boolean
|
|
63
|
+
start(): TigaServer
|
|
64
|
+
close(): Promise<void>
|
|
27
65
|
}
|
|
28
|
-
export declare function
|
|
29
|
-
export declare function
|
|
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
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.
|
|
3
|
+
"version": "0.2.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": "
|
|
27
|
-
"cross-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
|
-
"
|
|
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
|
}
|
package/runtime/index.js
ADDED
|
@@ -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,439 @@
|
|
|
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 = (mappingDirectory, name) =>
|
|
30
|
+
path.join(mappingDirectory, `${FILE_PREFIX}${name}${STATE_SUFFIX}`);
|
|
31
|
+
|
|
32
|
+
const hasSingleChannel = (baseName, mappingDirectory) =>
|
|
33
|
+
fs.existsSync(getStatePath(mappingDirectory, baseName));
|
|
34
|
+
|
|
35
|
+
const listClientIds = (baseName, mappingDirectory) => {
|
|
36
|
+
try {
|
|
37
|
+
const prefix = `${FILE_PREFIX}${baseName}.req.`;
|
|
38
|
+
return fs
|
|
39
|
+
.readdirSync(mappingDirectory)
|
|
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, baseName) =>
|
|
52
|
+
clientId === SINGLE_CLIENT_ID
|
|
53
|
+
? {
|
|
54
|
+
key: SINGLE_CLIENT_ID,
|
|
55
|
+
clientId,
|
|
56
|
+
requestName: baseName,
|
|
57
|
+
responseName: baseName,
|
|
58
|
+
}
|
|
59
|
+
: {
|
|
60
|
+
key: clientId,
|
|
61
|
+
clientId,
|
|
62
|
+
requestName: `${baseName}.req.${clientId}`,
|
|
63
|
+
responseName: `${baseName}.resp.${clientId}`,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
class TigaServer {
|
|
67
|
+
constructor(options) {
|
|
68
|
+
const resolved = options || {};
|
|
69
|
+
this.baseName = ensureString(resolved.baseName, 'baseName');
|
|
70
|
+
this.mappingDirectory = ensureString(
|
|
71
|
+
resolved.mappingDirectory,
|
|
72
|
+
'mappingDirectory',
|
|
73
|
+
);
|
|
74
|
+
if (typeof resolved.onInvoke !== 'function') {
|
|
75
|
+
throw new Error('onInvoke must be a function');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
this.onInvoke = resolved.onInvoke;
|
|
79
|
+
this.onError =
|
|
80
|
+
typeof resolved.onError === 'function' ? resolved.onError : null;
|
|
81
|
+
this.discoveryIntervalMs = Math.max(
|
|
82
|
+
100,
|
|
83
|
+
Number(resolved.discoveryIntervalMs || DEFAULT_DISCOVERY_INTERVAL_MS),
|
|
84
|
+
);
|
|
85
|
+
this.waitTimeoutMs = Math.max(
|
|
86
|
+
100,
|
|
87
|
+
Number(resolved.waitTimeoutMs || DEFAULT_WAIT_TIMEOUT_MS),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
this.clients = new Map();
|
|
91
|
+
this.discoveryTimer = null;
|
|
92
|
+
this.closed = false;
|
|
93
|
+
this.started = false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
start() {
|
|
97
|
+
if (this.started) {
|
|
98
|
+
return this;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
fs.mkdirSync(this.mappingDirectory, { recursive: true });
|
|
102
|
+
this.started = true;
|
|
103
|
+
this.closed = false;
|
|
104
|
+
this.discoveryTimer = setInterval(() => {
|
|
105
|
+
this.discover().catch((error) => {
|
|
106
|
+
this.reportError(error, {
|
|
107
|
+
stage: 'discover',
|
|
108
|
+
baseName: this.baseName,
|
|
109
|
+
mappingDirectory: this.mappingDirectory,
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
}, this.discoveryIntervalMs);
|
|
113
|
+
this.discover().catch((error) => {
|
|
114
|
+
this.reportError(error, {
|
|
115
|
+
stage: 'discover',
|
|
116
|
+
baseName: this.baseName,
|
|
117
|
+
mappingDirectory: this.mappingDirectory,
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return this;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async close() {
|
|
125
|
+
if (this.closed) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
this.closed = true;
|
|
130
|
+
this.started = false;
|
|
131
|
+
if (this.discoveryTimer) {
|
|
132
|
+
clearInterval(this.discoveryTimer);
|
|
133
|
+
this.discoveryTimer = null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const clients = Array.from(this.clients.values());
|
|
137
|
+
this.clients.clear();
|
|
138
|
+
await Promise.all(clients.map((client) => this.closeClient(client)));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async discover() {
|
|
142
|
+
if (this.closed) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const nextClients = listClientIds(this.baseName, this.mappingDirectory);
|
|
147
|
+
nextClients.forEach((clientId) => {
|
|
148
|
+
if (!this.clients.has(clientId)) {
|
|
149
|
+
this.registerClient(toClientState(clientId, this.baseName));
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
if (
|
|
154
|
+
!this.clients.has(SINGLE_CLIENT_ID) &&
|
|
155
|
+
hasSingleChannel(this.baseName, this.mappingDirectory)
|
|
156
|
+
) {
|
|
157
|
+
this.registerClient(toClientState(SINGLE_CLIENT_ID, this.baseName));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
registerClient(client) {
|
|
162
|
+
const state = {
|
|
163
|
+
...client,
|
|
164
|
+
lastId: 0,
|
|
165
|
+
draining: false,
|
|
166
|
+
pendingDrain: false,
|
|
167
|
+
closing: false,
|
|
168
|
+
worker: null,
|
|
169
|
+
workerExit: null,
|
|
170
|
+
restartTimer: null,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
this.clients.set(state.key, state);
|
|
174
|
+
this.startWorker(state);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
startWorker(client) {
|
|
178
|
+
if (this.closed || client.closing) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const worker = new Worker(
|
|
183
|
+
path.join(__dirname, 'workers', 'notification-listener.js'),
|
|
184
|
+
{
|
|
185
|
+
workerData: {
|
|
186
|
+
name: client.requestName,
|
|
187
|
+
mappingDirectory: this.mappingDirectory,
|
|
188
|
+
waitTimeoutMs: this.waitTimeoutMs,
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
client.worker = worker;
|
|
194
|
+
client.workerExit = new Promise((resolve) => {
|
|
195
|
+
worker.once('exit', () => resolve());
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
worker.on('message', (message) => {
|
|
199
|
+
if (!message || client.closing || this.closed) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (message.type === 'ready' || message.type === 'signal') {
|
|
204
|
+
this.scheduleDrain(client);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (message.type === 'error') {
|
|
209
|
+
this.reportError(new Error(message.message), {
|
|
210
|
+
stage: 'listener',
|
|
211
|
+
clientId: client.clientId,
|
|
212
|
+
requestName: client.requestName,
|
|
213
|
+
responseName: client.responseName,
|
|
214
|
+
baseName: this.baseName,
|
|
215
|
+
mappingDirectory: this.mappingDirectory,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
worker.on('error', (error) => {
|
|
221
|
+
this.reportError(error, {
|
|
222
|
+
stage: 'listener',
|
|
223
|
+
clientId: client.clientId,
|
|
224
|
+
requestName: client.requestName,
|
|
225
|
+
responseName: client.responseName,
|
|
226
|
+
baseName: this.baseName,
|
|
227
|
+
mappingDirectory: this.mappingDirectory,
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
worker.on('exit', () => {
|
|
232
|
+
client.worker = null;
|
|
233
|
+
client.workerExit = null;
|
|
234
|
+
if (!this.closed && !client.closing) {
|
|
235
|
+
this.scheduleWorkerRestart(client);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
scheduleWorkerRestart(client) {
|
|
241
|
+
if (client.restartTimer || this.closed || client.closing) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
client.restartTimer = setTimeout(() => {
|
|
246
|
+
client.restartTimer = null;
|
|
247
|
+
if (!this.closed && !client.closing && this.clients.has(client.key)) {
|
|
248
|
+
this.startWorker(client);
|
|
249
|
+
}
|
|
250
|
+
}, 200);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
scheduleDrain(client) {
|
|
254
|
+
if (this.closed || client.closing) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
client.pendingDrain = true;
|
|
259
|
+
if (client.draining) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
client.draining = true;
|
|
264
|
+
void this.runDrainLoop(client);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async runDrainLoop(client) {
|
|
268
|
+
try {
|
|
269
|
+
while (client.pendingDrain && !client.closing && !this.closed) {
|
|
270
|
+
client.pendingDrain = false;
|
|
271
|
+
await this.drainClient(client);
|
|
272
|
+
}
|
|
273
|
+
} finally {
|
|
274
|
+
client.draining = false;
|
|
275
|
+
if (client.pendingDrain && !client.closing && !this.closed) {
|
|
276
|
+
this.scheduleDrain(client);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async drainClient(client) {
|
|
282
|
+
let result;
|
|
283
|
+
try {
|
|
284
|
+
result = binding.tigaRead(client.requestName, {
|
|
285
|
+
lastId: client.lastId,
|
|
286
|
+
mappingDirectory: this.mappingDirectory,
|
|
287
|
+
});
|
|
288
|
+
} catch (error) {
|
|
289
|
+
this.reportError(error, {
|
|
290
|
+
stage: 'read',
|
|
291
|
+
clientId: client.clientId,
|
|
292
|
+
requestName: client.requestName,
|
|
293
|
+
responseName: client.responseName,
|
|
294
|
+
baseName: this.baseName,
|
|
295
|
+
mappingDirectory: this.mappingDirectory,
|
|
296
|
+
});
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (
|
|
301
|
+
result &&
|
|
302
|
+
typeof result.lastId === 'number' &&
|
|
303
|
+
result.lastId < client.lastId
|
|
304
|
+
) {
|
|
305
|
+
client.lastId = result.lastId;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const entries = Array.isArray(result && result.entries)
|
|
309
|
+
? result.entries.filter((entry) => entry && entry.id > client.lastId)
|
|
310
|
+
: [];
|
|
311
|
+
|
|
312
|
+
for (const entry of entries) {
|
|
313
|
+
await this.processEntry(client, entry);
|
|
314
|
+
client.lastId = Math.max(client.lastId, entry.id);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async processEntry(client, entry) {
|
|
319
|
+
const invoke = parseTigaInvokeEntry(entry);
|
|
320
|
+
if (!invoke) {
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const context = {
|
|
325
|
+
baseName: this.baseName,
|
|
326
|
+
clientId: client.clientId,
|
|
327
|
+
requestName: client.requestName,
|
|
328
|
+
responseName: client.responseName,
|
|
329
|
+
mappingDirectory: this.mappingDirectory,
|
|
330
|
+
requestId: invoke.id,
|
|
331
|
+
entryId: entry.id,
|
|
332
|
+
mediaType: entry.mediaType || null,
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
const result = await Promise.resolve(
|
|
337
|
+
this.onInvoke(invoke.method, invoke.data, context),
|
|
338
|
+
);
|
|
339
|
+
this.writeResponse(
|
|
340
|
+
client,
|
|
341
|
+
buildTigaResponseBuffer(
|
|
342
|
+
invoke.id,
|
|
343
|
+
stringifyTigaResponseData(result),
|
|
344
|
+
0,
|
|
345
|
+
),
|
|
346
|
+
);
|
|
347
|
+
} catch (error) {
|
|
348
|
+
const message = getTigaErrorMessage(error);
|
|
349
|
+
this.reportError(error, {
|
|
350
|
+
...context,
|
|
351
|
+
stage: 'invoke',
|
|
352
|
+
method: invoke.method,
|
|
353
|
+
});
|
|
354
|
+
this.writeResponse(
|
|
355
|
+
client,
|
|
356
|
+
buildTigaResponseBuffer(invoke.id, message, -1),
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
writeResponse(client, payload) {
|
|
362
|
+
try {
|
|
363
|
+
binding.tigaWrite(client.responseName, payload, {
|
|
364
|
+
mediaType: TIGA_MEDIA_TYPE_MSGPACK,
|
|
365
|
+
mappingDirectory: this.mappingDirectory,
|
|
366
|
+
});
|
|
367
|
+
} catch (error) {
|
|
368
|
+
this.reportError(error, {
|
|
369
|
+
stage: 'write',
|
|
370
|
+
clientId: client.clientId,
|
|
371
|
+
requestName: client.requestName,
|
|
372
|
+
responseName: client.responseName,
|
|
373
|
+
baseName: this.baseName,
|
|
374
|
+
mappingDirectory: this.mappingDirectory,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async closeClient(client) {
|
|
380
|
+
client.closing = true;
|
|
381
|
+
client.pendingDrain = false;
|
|
382
|
+
if (client.restartTimer) {
|
|
383
|
+
clearTimeout(client.restartTimer);
|
|
384
|
+
client.restartTimer = null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const worker = client.worker;
|
|
388
|
+
const workerExit = client.workerExit;
|
|
389
|
+
client.worker = null;
|
|
390
|
+
client.workerExit = null;
|
|
391
|
+
if (!worker) {
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
try {
|
|
396
|
+
worker.postMessage({ type: 'close' });
|
|
397
|
+
} catch {
|
|
398
|
+
// Ignore worker shutdown races.
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (workerExit) {
|
|
402
|
+
const terminated = workerExit.then(() => true);
|
|
403
|
+
const timeout = new Promise((resolve) => {
|
|
404
|
+
setTimeout(() => resolve(false), WORKER_CLOSE_TIMEOUT_MS);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
const exited = await Promise.race([terminated, timeout]);
|
|
408
|
+
if (exited) {
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
await worker.terminate();
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
reportError(error, context) {
|
|
417
|
+
if (!this.onError) {
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
try {
|
|
422
|
+
const normalizedError =
|
|
423
|
+
error instanceof Error ? error : new Error(String(error));
|
|
424
|
+
this.onError(normalizedError, context);
|
|
425
|
+
} catch {
|
|
426
|
+
// Ignore user callback errors to keep the server alive.
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const createTigaServer = (options) => new TigaServer(options);
|
|
432
|
+
|
|
433
|
+
const startTigaServer = (options) => createTigaServer(options).start();
|
|
434
|
+
|
|
435
|
+
module.exports = {
|
|
436
|
+
TigaServer,
|
|
437
|
+
createTigaServer,
|
|
438
|
+
startTigaServer,
|
|
439
|
+
};
|
|
@@ -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
|
+
mappingDirectory: workerData.mappingDirectory,
|
|
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
|
+
}
|