@juspay/neurolink 12.14.4 → 12.14.6
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/CHANGELOG.md +2 -2
- package/dist/browser/neurolink.min.js +370 -370
- package/dist/cli/commands/proxy.d.ts +1 -1
- package/dist/cli/commands/proxy.js +58 -4
- package/dist/cli/commands/proxyRestart.d.ts +3 -0
- package/dist/cli/commands/proxyRestart.js +88 -0
- package/dist/cli/parser.js +3 -1
- package/dist/proxy/bodyCaptureProcessing.d.ts +1 -0
- package/dist/proxy/bodyCaptureProcessing.js +19 -4
- package/dist/proxy/bodyCaptureWorker.js +35 -13
- package/dist/proxy/otelLogSink.d.ts +22 -1
- package/dist/proxy/otelLogSink.js +247 -8
- package/dist/proxy/proxyActivity.d.ts +4 -2
- package/dist/proxy/proxyActivity.js +20 -1
- package/dist/proxy/requestLogger.d.ts +1 -0
- package/dist/proxy/requestLogger.js +53 -15
- package/dist/proxy/restartControl.d.ts +12 -0
- package/dist/proxy/restartControl.js +283 -0
- package/dist/proxy/rollingWorkerSupervisor.js +8 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/proxy.d.ts +29 -0
- package/dist/types/proxyRestart.d.ts +49 -0
- package/dist/types/proxyRestart.js +1 -0
- package/docs-site/static/search-index.json +1 -1
- package/package.json +2 -1
- package/scripts/observability/check-proxy-telemetry.mjs +20 -7
- package/scripts/observability/query-proxy-history.mjs +193 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { createServer, request } from "node:http";
|
|
2
|
+
import { chmod, mkdir, rm } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
const statusSchema = z.object({
|
|
7
|
+
pid: z.number().int().positive(),
|
|
8
|
+
version: z.string(),
|
|
9
|
+
health: z.object({
|
|
10
|
+
ready: z.literal(true),
|
|
11
|
+
acceptingConnections: z.literal(true),
|
|
12
|
+
drainingForUpdate: z.literal(false),
|
|
13
|
+
}),
|
|
14
|
+
observability: z.object({
|
|
15
|
+
requestLogs: z.object({
|
|
16
|
+
diskEnabled: z.boolean(),
|
|
17
|
+
otel: z.object({ initialized: z.boolean() }),
|
|
18
|
+
}),
|
|
19
|
+
}),
|
|
20
|
+
});
|
|
21
|
+
const resultSchema = z.object({
|
|
22
|
+
ok: z.boolean(),
|
|
23
|
+
phase: z.enum([
|
|
24
|
+
"checked",
|
|
25
|
+
"refused",
|
|
26
|
+
"failed",
|
|
27
|
+
"activated",
|
|
28
|
+
"activated_unverified",
|
|
29
|
+
]),
|
|
30
|
+
message: z.string(),
|
|
31
|
+
supervisorPid: z.number().int().positive(),
|
|
32
|
+
previousWorkerPid: z.number().int().positive().optional(),
|
|
33
|
+
workerPid: z.number().int().positive().optional(),
|
|
34
|
+
version: z.string().optional(),
|
|
35
|
+
drainingWorkers: z.number().int().nonnegative(),
|
|
36
|
+
rejectedSocketsDelta: z.number().int().optional(),
|
|
37
|
+
failedTransfersDelta: z.number().int().optional(),
|
|
38
|
+
});
|
|
39
|
+
/**
|
|
40
|
+
* Own restart completion in the supervisor, so a disconnected CLI cannot leave
|
|
41
|
+
* admission closed. The control socket is private to the service's OS user.
|
|
42
|
+
* No update history, launcher, environment file or launchd unit is rewritten.
|
|
43
|
+
*/
|
|
44
|
+
export async function startProxyRestartControl(options) {
|
|
45
|
+
const instanceId = randomUUID();
|
|
46
|
+
const socketPath = join(options.stateDir, `restart-${process.pid}-${instanceId}.sock`);
|
|
47
|
+
await mkdir(options.stateDir, { recursive: true, mode: 0o700 });
|
|
48
|
+
// Never unlink an existing path: an unexpected owner must make setup fail.
|
|
49
|
+
let busy = false;
|
|
50
|
+
let closing = false;
|
|
51
|
+
const result = (phase, message) => {
|
|
52
|
+
const snapshot = options.server.snapshot();
|
|
53
|
+
return {
|
|
54
|
+
ok: phase === "checked" || phase === "activated",
|
|
55
|
+
phase,
|
|
56
|
+
message,
|
|
57
|
+
supervisorPid: process.pid,
|
|
58
|
+
workerPid: snapshot.active?.pid,
|
|
59
|
+
version: snapshot.active?.version,
|
|
60
|
+
drainingWorkers: snapshot.draining.length,
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
const assertIdle = () => {
|
|
64
|
+
const snapshot = options.server.snapshot();
|
|
65
|
+
if (closing || !snapshot.active) {
|
|
66
|
+
throw new Error("No serving worker is available for a rolling restart.");
|
|
67
|
+
}
|
|
68
|
+
if (snapshot.candidate || options.isUpdatePending()) {
|
|
69
|
+
throw new Error("An update or worker replacement is already in progress.");
|
|
70
|
+
}
|
|
71
|
+
if (snapshot.draining.length) {
|
|
72
|
+
throw new Error("A previous worker is still finishing requests; another restart is deferred.");
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
const operate = async (restart) => {
|
|
76
|
+
if (busy || closing) {
|
|
77
|
+
return result("refused", "A restart check or activation is already in progress.");
|
|
78
|
+
}
|
|
79
|
+
busy = true;
|
|
80
|
+
let activated = false;
|
|
81
|
+
let previousWorkerPid;
|
|
82
|
+
let handoffBaseline;
|
|
83
|
+
const transferDeltas = () => {
|
|
84
|
+
if (!handoffBaseline) {
|
|
85
|
+
return {};
|
|
86
|
+
}
|
|
87
|
+
const latest = options.server.snapshot();
|
|
88
|
+
return {
|
|
89
|
+
rejectedSocketsDelta: latest.rejectedSockets - handoffBaseline.rejectedSockets,
|
|
90
|
+
failedTransfersDelta: latest.failedTransfers - handoffBaseline.failedTransfers,
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
try {
|
|
94
|
+
assertIdle();
|
|
95
|
+
const before = options.server.snapshot();
|
|
96
|
+
const active = before.active;
|
|
97
|
+
if (!active) {
|
|
98
|
+
throw new Error("The serving worker exited during preflight.");
|
|
99
|
+
}
|
|
100
|
+
previousWorkerPid = active.pid;
|
|
101
|
+
const status = statusSchema.parse(await options.getStatus());
|
|
102
|
+
if (status.pid !== previousWorkerPid ||
|
|
103
|
+
status.version !== active.version) {
|
|
104
|
+
throw new Error("Serving worker identity changed during preflight; retry the check.");
|
|
105
|
+
}
|
|
106
|
+
const version = await options.getInstalledVersion();
|
|
107
|
+
if (!version || !/^\d+\.\d+\.\d+$/.test(version)) {
|
|
108
|
+
throw new Error("The configured worker executable did not report a valid installed version.");
|
|
109
|
+
}
|
|
110
|
+
assertIdle();
|
|
111
|
+
if (options.server.snapshot().active?.pid !== previousWorkerPid) {
|
|
112
|
+
throw new Error("Serving worker changed during preflight; retry the check.");
|
|
113
|
+
}
|
|
114
|
+
if (!restart) {
|
|
115
|
+
return {
|
|
116
|
+
...result("checked", "Rolling restart is available. Service settings and existing streams will be preserved."),
|
|
117
|
+
version,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
// The existing supervisor imposes a 120-second candidate readiness
|
|
121
|
+
// deadline and retains the serving worker when startup/activation fails.
|
|
122
|
+
// It switches new sockets before draining the previous generation.
|
|
123
|
+
handoffBaseline = options.server.snapshot();
|
|
124
|
+
const after = await options.server.replace(version);
|
|
125
|
+
activated = true;
|
|
126
|
+
const fresh = statusSchema.parse(await options.getStatus());
|
|
127
|
+
if (fresh.pid !== after.active?.pid ||
|
|
128
|
+
fresh.pid === previousWorkerPid ||
|
|
129
|
+
fresh.version !== version) {
|
|
130
|
+
throw new Error("The replacement's serving identity could not be verified.");
|
|
131
|
+
}
|
|
132
|
+
const beforeLogs = status.observability.requestLogs;
|
|
133
|
+
const afterLogs = fresh.observability.requestLogs;
|
|
134
|
+
// Preserve the selected sink in both directions. Unexpected disk writes
|
|
135
|
+
// regress OTel-only deployments; disabling an existing disk sink loses
|
|
136
|
+
// requested logs. Restart must not silently make either policy change.
|
|
137
|
+
if (beforeLogs.diskEnabled !== afterLogs.diskEnabled ||
|
|
138
|
+
(beforeLogs.otel.initialized && !afterLogs.otel.initialized)) {
|
|
139
|
+
throw new Error("The replacement's logging state regressed; inspect telemetry before further changes.");
|
|
140
|
+
}
|
|
141
|
+
const latest = options.server.snapshot();
|
|
142
|
+
const deltas = transferDeltas();
|
|
143
|
+
// These are observed failures, not attribution to the restart. A ready
|
|
144
|
+
// worker can coexist with a handoff whose no-loss claim is unverified.
|
|
145
|
+
if (latest.active?.pid !== fresh.pid ||
|
|
146
|
+
deltas.rejectedSocketsDelta !== 0 ||
|
|
147
|
+
deltas.failedTransfersDelta !== 0) {
|
|
148
|
+
throw new Error("Worker identity or socket transfer counters changed during verification.");
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
...result("activated", "Replacement is serving and accepting requests. Previous streams finish on their original worker."),
|
|
152
|
+
previousWorkerPid,
|
|
153
|
+
...deltas,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
// Schema errors contain paths only; never echo the status payload or env.
|
|
158
|
+
const message = error instanceof z.ZodError
|
|
159
|
+
? "Readiness, admission or logging status could not be verified."
|
|
160
|
+
: error instanceof Error
|
|
161
|
+
? error.message
|
|
162
|
+
: "Restart verification failed.";
|
|
163
|
+
return {
|
|
164
|
+
...result(activated ? "activated_unverified" : "failed", message),
|
|
165
|
+
previousWorkerPid,
|
|
166
|
+
...transferDeltas(),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
busy = false;
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
const control = createServer((req, res) => {
|
|
174
|
+
// The instance nonce prevents a stale client from acting on a reused path.
|
|
175
|
+
if (closing || req.headers["x-neurolink-instance"] !== instanceId) {
|
|
176
|
+
res.writeHead(409).end();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (req.headers["transfer-encoding"] ||
|
|
180
|
+
(req.headers["content-length"] && req.headers["content-length"] !== "0")) {
|
|
181
|
+
res.writeHead(400, { connection: "close" }).end();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (!((req.method === "GET" && req.url === "/check") ||
|
|
185
|
+
(req.method === "POST" && req.url === "/restart"))) {
|
|
186
|
+
res.writeHead(404).end();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
req.resume();
|
|
190
|
+
void operate(req.method === "POST")
|
|
191
|
+
.then((outcome) => {
|
|
192
|
+
if (res.destroyed || res.writableEnded) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
res.writeHead(outcome.ok ? 200 : 409, {
|
|
196
|
+
"content-type": "application/json",
|
|
197
|
+
connection: "close",
|
|
198
|
+
});
|
|
199
|
+
res.end(JSON.stringify(outcome));
|
|
200
|
+
})
|
|
201
|
+
.catch(() => {
|
|
202
|
+
if (res.destroyed || res.writableEnded) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
res.writeHead(500).end();
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
control.headersTimeout = 5_000;
|
|
209
|
+
control.requestTimeout = 5_000;
|
|
210
|
+
control.maxHeadersCount = 10;
|
|
211
|
+
control.maxConnections = 16;
|
|
212
|
+
await new Promise((resolve, reject) => {
|
|
213
|
+
control.once("error", reject);
|
|
214
|
+
control.listen(socketPath, () => {
|
|
215
|
+
control.off("error", reject);
|
|
216
|
+
// Keep handling errors after binding: a control-plane accept failure
|
|
217
|
+
// must not become an unhandled event that stops the serving listener.
|
|
218
|
+
control.on("error", (error) => {
|
|
219
|
+
options.log?.(`[proxy-supervisor] safe restart control error: ${error.message}`);
|
|
220
|
+
});
|
|
221
|
+
resolve();
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
try {
|
|
225
|
+
await chmod(socketPath, 0o600);
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
control.close();
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
identity: { protocol: 1, socketPath, instanceId },
|
|
233
|
+
close: async () => {
|
|
234
|
+
closing = true;
|
|
235
|
+
control.closeAllConnections();
|
|
236
|
+
await new Promise((resolve) => control.close(() => resolve()));
|
|
237
|
+
await rm(socketPath, { force: true });
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/** Perform one local control operation; never fall back to killing a process. */
|
|
242
|
+
export async function requestProxyRestart(identity, check) {
|
|
243
|
+
return new Promise((resolve, reject) => {
|
|
244
|
+
const req = request({
|
|
245
|
+
socketPath: identity.socketPath,
|
|
246
|
+
path: check ? "/check" : "/restart",
|
|
247
|
+
method: check ? "GET" : "POST",
|
|
248
|
+
headers: {
|
|
249
|
+
"x-neurolink-instance": identity.instanceId,
|
|
250
|
+
connection: "close",
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
const timeout = setTimeout(() => req.destroy(new Error("Restart result timed out; outcome is unknown. Inspect proxy status before retrying.")), check ? 15_000 : 150_000);
|
|
254
|
+
req.once("error", (error) => {
|
|
255
|
+
clearTimeout(timeout);
|
|
256
|
+
reject(error);
|
|
257
|
+
});
|
|
258
|
+
req.once("response", (res) => {
|
|
259
|
+
let body = "";
|
|
260
|
+
res.setEncoding("utf8");
|
|
261
|
+
res.on("data", (chunk) => {
|
|
262
|
+
body += chunk;
|
|
263
|
+
if (body.length > 65_536) {
|
|
264
|
+
req.destroy(new Error("Invalid restart control response."));
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
res.once("error", (error) => {
|
|
268
|
+
clearTimeout(timeout);
|
|
269
|
+
reject(error);
|
|
270
|
+
});
|
|
271
|
+
res.once("end", () => {
|
|
272
|
+
clearTimeout(timeout);
|
|
273
|
+
try {
|
|
274
|
+
resolve(resultSchema.parse(JSON.parse(body)));
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
reject(new Error("Restart control did not return a verified result; no service restart fallback was attempted."));
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
req.end();
|
|
282
|
+
});
|
|
283
|
+
}
|
|
@@ -253,6 +253,14 @@ export class RollingWorkerSupervisor {
|
|
|
253
253
|
if (this.candidate?.generation === generation) {
|
|
254
254
|
this.candidate = null;
|
|
255
255
|
}
|
|
256
|
+
// A candidate that never became ready may also ignore SIGTERM.
|
|
257
|
+
// Bound its cleanup independently of the still-serving generation.
|
|
258
|
+
const killTimeout = setTimeout(() => handle.terminate("SIGKILL"), 1_000);
|
|
259
|
+
killTimeout.unref?.();
|
|
260
|
+
const offCandidateExit = handle.onExit(() => {
|
|
261
|
+
clearTimeout(killTimeout);
|
|
262
|
+
offCandidateExit();
|
|
263
|
+
});
|
|
256
264
|
handle.terminate("SIGTERM");
|
|
257
265
|
dispose();
|
|
258
266
|
this.publishState();
|
package/dist/types/index.d.ts
CHANGED
package/dist/types/index.js
CHANGED
package/dist/types/proxy.d.ts
CHANGED
|
@@ -641,8 +641,31 @@ export type ProxyBodyCaptureWorkerSnapshot = {
|
|
|
641
641
|
pendingBytes: number;
|
|
642
642
|
maxPending: number;
|
|
643
643
|
maxPendingBytes: number;
|
|
644
|
+
/** Admission failures by exact guard, independent of processing failures. */
|
|
645
|
+
rejectionReasons: Record<string, number>;
|
|
644
646
|
lastError?: string;
|
|
645
647
|
};
|
|
648
|
+
/** Collector transport evidence; acknowledgement does not prove backend storage. */
|
|
649
|
+
export type ProxyBodyDeliveryResult = {
|
|
650
|
+
status: "transport_acknowledged" | "export_unconfirmed" | "rejected" | "partial";
|
|
651
|
+
/** Absent when publication was rejected before chunking. */
|
|
652
|
+
expectedChunks?: number;
|
|
653
|
+
acknowledgedChunks: number;
|
|
654
|
+
unconfirmedChunks: number;
|
|
655
|
+
droppedChunks: number;
|
|
656
|
+
notSubmittedChunks?: number;
|
|
657
|
+
reason?: string;
|
|
658
|
+
};
|
|
659
|
+
/** One bounded body publication, tracked across exporter callbacks. */
|
|
660
|
+
export type ProxyBodyPublicationProgress = {
|
|
661
|
+
acknowledged: number;
|
|
662
|
+
unconfirmed: number;
|
|
663
|
+
dropped: number;
|
|
664
|
+
emitted: number;
|
|
665
|
+
notify?: () => void;
|
|
666
|
+
};
|
|
667
|
+
/** Chunk emission stays in the request logger, which owns request attributes. */
|
|
668
|
+
export type ProxyBodyChunkEmitter = (chunk: string, index: number, count: number) => void;
|
|
646
669
|
export type ProxyRequestLoggerSnapshot = {
|
|
647
670
|
diskEnabled?: boolean;
|
|
648
671
|
otel?: ReturnType<typeof import("../proxy/otelLogSink.js").getProxyOtelLogSnapshot>;
|
|
@@ -1991,6 +2014,8 @@ export type ProxyAnalysisRoutingRecord = {
|
|
|
1991
2014
|
};
|
|
1992
2015
|
/** Request metadata retained by the HTTP adapter for terminal error logging. */
|
|
1993
2016
|
export type RuntimeRequestMetadata = {
|
|
2017
|
+
/** Last dispatched attempt, retained until this HTTP request terminates. */
|
|
2018
|
+
lastUpstreamAttempt?: RequestAttemptLogEntry;
|
|
1994
2019
|
requestId: string;
|
|
1995
2020
|
method: string;
|
|
1996
2021
|
path: string;
|
|
@@ -2022,6 +2047,8 @@ export type RawStreamCaptureResult = {
|
|
|
2022
2047
|
};
|
|
2023
2048
|
/** Single captured body/headers entry written to disk by the proxy logger. */
|
|
2024
2049
|
export type ProxyBodyCaptureEntry = {
|
|
2050
|
+
/** Unique capture identity shared by its index and every exported chunk. */
|
|
2051
|
+
captureId?: string;
|
|
2025
2052
|
timestamp: string;
|
|
2026
2053
|
requestId: string;
|
|
2027
2054
|
phase: string;
|
|
@@ -2169,6 +2196,8 @@ export type StoredBodyArtifact = {
|
|
|
2169
2196
|
storedFileBytes?: number;
|
|
2170
2197
|
redactedBody?: string;
|
|
2171
2198
|
bodyTruncated?: boolean;
|
|
2199
|
+
bodyCaptureLimitBytes?: number;
|
|
2200
|
+
originalRedactedBodyBytes?: number;
|
|
2172
2201
|
bodyWriteFailed?: boolean;
|
|
2173
2202
|
};
|
|
2174
2203
|
/** File the proxy logger tracks for rotation and cleanup. */
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { RollingProxyServer } from "./proxy.js";
|
|
2
|
+
import type { ProxySupervisorState } from "./cli.js";
|
|
3
|
+
/** Supervisor state with the optional local restart capability. */
|
|
4
|
+
export type ProxyRestartSupervisorState = ProxySupervisorState & {
|
|
5
|
+
restartControl?: ProxyRestartControlIdentity;
|
|
6
|
+
};
|
|
7
|
+
export type ProxyRestartArgs = {
|
|
8
|
+
check: boolean;
|
|
9
|
+
dev: boolean;
|
|
10
|
+
format: "text" | "json";
|
|
11
|
+
};
|
|
12
|
+
/** Local supervisor control identity; contains no account credentials. */
|
|
13
|
+
export type ProxyRestartControlIdentity = {
|
|
14
|
+
protocol: 1;
|
|
15
|
+
socketPath: string;
|
|
16
|
+
instanceId: string;
|
|
17
|
+
};
|
|
18
|
+
/** Terminal result of a local restart check or worker activation. */
|
|
19
|
+
export type ProxyRestartResult = {
|
|
20
|
+
ok: boolean;
|
|
21
|
+
phase: "checked" | "refused" | "failed" | "activated" | "activated_unverified";
|
|
22
|
+
message: string;
|
|
23
|
+
supervisorPid: number;
|
|
24
|
+
previousWorkerPid?: number;
|
|
25
|
+
workerPid?: number;
|
|
26
|
+
version?: string;
|
|
27
|
+
drainingWorkers: number;
|
|
28
|
+
/** Observed during handoff/verification; does not attribute the cause. */
|
|
29
|
+
rejectedSocketsDelta?: number;
|
|
30
|
+
failedTransfersDelta?: number;
|
|
31
|
+
};
|
|
32
|
+
/** CLI failure before a supervisor result can be authenticated or received. */
|
|
33
|
+
export type CliProxyRestartError = {
|
|
34
|
+
ok: false;
|
|
35
|
+
phase: "unverified";
|
|
36
|
+
message: string;
|
|
37
|
+
};
|
|
38
|
+
/** JSON emitted by the restart CLI, including an unknown control outcome. */
|
|
39
|
+
export type CliProxyRestartOutput = ProxyRestartResult | CliProxyRestartError;
|
|
40
|
+
/** Supervisor-owned restart dependencies, injectable for isolated process tests. */
|
|
41
|
+
export type ProxyRestartControlOptions = {
|
|
42
|
+
stateDir: string;
|
|
43
|
+
server: RollingProxyServer;
|
|
44
|
+
getInstalledVersion: () => Promise<string | undefined>;
|
|
45
|
+
isUpdatePending: () => boolean;
|
|
46
|
+
getStatus: () => Promise<unknown>;
|
|
47
|
+
/** Report control-server errors without stopping the serving listener. */
|
|
48
|
+
log?: (message: string) => void;
|
|
49
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|