@reset-framework/backend 1.2.2
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 +22 -0
- package/package.json +24 -0
- package/src/index.d.ts +83 -0
- package/src/index.js +29 -0
- package/src/runner.js +558 -0
package/README.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# @reset-framework/backend
|
|
2
|
+
|
|
3
|
+
`@reset-framework/backend` provides the optional TypeScript sidecar runtime for Reset apps.
|
|
4
|
+
|
|
5
|
+
Use it from an app-local backend entry such as `backend/src/index.ts`:
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { defineBackend } from "@reset-framework/backend"
|
|
9
|
+
|
|
10
|
+
export default defineBackend(({ handle, process }) => {
|
|
11
|
+
handle("system.ping", async () => ({ ok: true }))
|
|
12
|
+
|
|
13
|
+
handle("git.version", async () => {
|
|
14
|
+
return process.run({
|
|
15
|
+
command: "git",
|
|
16
|
+
args: ["--version"]
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`reset-framework-cli` bundles this backend into the desktop app and the native runtime talks to it over a stdio protocol.
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@reset-framework/backend",
|
|
3
|
+
"version": "1.2.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Optional TypeScript sidecar backend runtime for Reset Framework apps.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"types": "./src/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./src/index.d.ts",
|
|
14
|
+
"import": "./src/index.js",
|
|
15
|
+
"default": "./src/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"src",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
]
|
|
24
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export type BackendHandler<TParams = unknown, TResult = unknown> = (
|
|
2
|
+
params: TParams,
|
|
3
|
+
context: BackendContext
|
|
4
|
+
) => Promise<TResult> | TResult;
|
|
5
|
+
|
|
6
|
+
export interface BackendProcessInfo {
|
|
7
|
+
id: string;
|
|
8
|
+
pid: number;
|
|
9
|
+
command: string;
|
|
10
|
+
args: string[];
|
|
11
|
+
cwd: string;
|
|
12
|
+
running: boolean;
|
|
13
|
+
startedAt: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface BackendProcessSpawnOptions {
|
|
17
|
+
command: string;
|
|
18
|
+
args?: string[];
|
|
19
|
+
cwd?: string;
|
|
20
|
+
env?: Record<string, string>;
|
|
21
|
+
shell?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface BackendProcessRunOptions extends BackendProcessSpawnOptions {
|
|
25
|
+
input?: string;
|
|
26
|
+
timeoutMs?: number;
|
|
27
|
+
maxBufferBytes?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface BackendProcessRunResult {
|
|
31
|
+
command: string;
|
|
32
|
+
args: string[];
|
|
33
|
+
cwd: string;
|
|
34
|
+
exitCode: number | null;
|
|
35
|
+
signal: string | null;
|
|
36
|
+
stdout: string;
|
|
37
|
+
stderr: string;
|
|
38
|
+
timedOut: boolean;
|
|
39
|
+
failed: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface BackendProcessApi {
|
|
43
|
+
spawn(options: BackendProcessSpawnOptions): Promise<BackendProcessInfo>;
|
|
44
|
+
run(options: BackendProcessRunOptions): Promise<BackendProcessRunResult>;
|
|
45
|
+
write(processId: string, data: string): Promise<{ written: boolean }>;
|
|
46
|
+
kill(
|
|
47
|
+
processId: string,
|
|
48
|
+
options?: { signal?: string }
|
|
49
|
+
): Promise<{ killed: boolean }>;
|
|
50
|
+
list(): Promise<{ processes: BackendProcessInfo[] }>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface BackendContext {
|
|
54
|
+
handle<TParams = unknown, TResult = unknown>(
|
|
55
|
+
method: string,
|
|
56
|
+
handler: BackendHandler<TParams, TResult>
|
|
57
|
+
): void;
|
|
58
|
+
emit(eventName: string, payload?: unknown): void;
|
|
59
|
+
process: BackendProcessApi;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface BackendDefinition {
|
|
63
|
+
__resetBackend: true;
|
|
64
|
+
setup(context: BackendContext): Promise<void> | void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class BackendError extends Error {
|
|
68
|
+
code: string;
|
|
69
|
+
details?: unknown;
|
|
70
|
+
constructor(code: string, message: string, details?: unknown);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createBackendError(
|
|
74
|
+
code: string,
|
|
75
|
+
message: string,
|
|
76
|
+
details?: unknown
|
|
77
|
+
): BackendError;
|
|
78
|
+
|
|
79
|
+
export function defineBackend(
|
|
80
|
+
setup: BackendDefinition["setup"]
|
|
81
|
+
): BackendDefinition;
|
|
82
|
+
|
|
83
|
+
export function isBackendDefinition(value: unknown): value is BackendDefinition;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export class BackendError extends Error {
|
|
2
|
+
constructor(code, message, details = undefined) {
|
|
3
|
+
super(message)
|
|
4
|
+
this.name = "BackendError"
|
|
5
|
+
this.code = typeof code === "string" && code.trim() !== "" ? code : "backend_error"
|
|
6
|
+
if (details !== undefined) {
|
|
7
|
+
this.details = details
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createBackendError(code, message, details = undefined) {
|
|
13
|
+
return new BackendError(code, message, details)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function defineBackend(setup) {
|
|
17
|
+
if (typeof setup !== "function") {
|
|
18
|
+
throw new TypeError("defineBackend requires a setup function.")
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return Object.freeze({
|
|
22
|
+
__resetBackend: true,
|
|
23
|
+
setup
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isBackendDefinition(value) {
|
|
28
|
+
return Boolean(value?.__resetBackend === true && typeof value?.setup === "function")
|
|
29
|
+
}
|
package/src/runner.js
ADDED
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
import { spawn } from "node:child_process"
|
|
2
|
+
import process from "node:process"
|
|
3
|
+
import { createInterface } from "node:readline"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
import { pathToFileURL } from "node:url"
|
|
6
|
+
import util from "node:util"
|
|
7
|
+
|
|
8
|
+
import { BackendError, isBackendDefinition } from "./index.js"
|
|
9
|
+
|
|
10
|
+
const protocolVersion = "reset-backend.v1"
|
|
11
|
+
|
|
12
|
+
function writeProtocolMessage(message) {
|
|
13
|
+
process.stdout.write(`${JSON.stringify(message)}\n`)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function writeStderrLine(prefix, args) {
|
|
17
|
+
const message = util.format(...args)
|
|
18
|
+
process.stderr.write(`${prefix}${message}\n`)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function installProtocolSafeConsole() {
|
|
22
|
+
const levels = {
|
|
23
|
+
log: "[reset-backend:log] ",
|
|
24
|
+
info: "[reset-backend:info] ",
|
|
25
|
+
debug: "[reset-backend:debug] ",
|
|
26
|
+
warn: "[reset-backend:warn] ",
|
|
27
|
+
error: "[reset-backend:error] "
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const [name, prefix] of Object.entries(levels)) {
|
|
31
|
+
console[name] = (...args) => {
|
|
32
|
+
writeStderrLine(prefix, args)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function requireRecord(value, message) {
|
|
38
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
39
|
+
throw new BackendError("invalid_argument", message)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return value
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function requireString(value, message) {
|
|
46
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
47
|
+
throw new BackendError("invalid_argument", message)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return value
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function optionalStringArray(value, message) {
|
|
54
|
+
if (value === undefined) {
|
|
55
|
+
return []
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
|
|
59
|
+
throw new BackendError("invalid_argument", message)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return [...value]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function optionalRecordOfStrings(value, message) {
|
|
66
|
+
if (value === undefined) {
|
|
67
|
+
return {}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const record = requireRecord(value, message)
|
|
71
|
+
const normalized = {}
|
|
72
|
+
|
|
73
|
+
for (const [key, entry] of Object.entries(record)) {
|
|
74
|
+
if (typeof entry !== "string") {
|
|
75
|
+
throw new BackendError("invalid_argument", message)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
normalized[key] = entry
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return normalized
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function serializeError(error) {
|
|
85
|
+
if (error instanceof BackendError) {
|
|
86
|
+
return {
|
|
87
|
+
code: error.code,
|
|
88
|
+
message: error.message,
|
|
89
|
+
details: error.details
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (error instanceof Error) {
|
|
94
|
+
return {
|
|
95
|
+
code: "internal_error",
|
|
96
|
+
message: error.message
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
code: "internal_error",
|
|
102
|
+
message: typeof error === "string" ? error : "Unknown backend error"
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function createProcessManager(emit) {
|
|
107
|
+
let nextId = 1
|
|
108
|
+
const processes = new Map()
|
|
109
|
+
|
|
110
|
+
function toInfo(state) {
|
|
111
|
+
return Object.freeze({
|
|
112
|
+
id: state.id,
|
|
113
|
+
pid: state.child.pid ?? 0,
|
|
114
|
+
command: state.command,
|
|
115
|
+
args: [...state.args],
|
|
116
|
+
cwd: state.cwd,
|
|
117
|
+
running: state.running,
|
|
118
|
+
startedAt: state.startedAt
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function wireChildState(state) {
|
|
123
|
+
state.child.stdout?.on("data", (chunk) => {
|
|
124
|
+
const text = String(chunk)
|
|
125
|
+
emit("process.stdout", {
|
|
126
|
+
processId: state.id,
|
|
127
|
+
text
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
state.child.stderr?.on("data", (chunk) => {
|
|
132
|
+
const text = String(chunk)
|
|
133
|
+
emit("process.stderr", {
|
|
134
|
+
processId: state.id,
|
|
135
|
+
text
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
state.child.once("error", (error) => {
|
|
140
|
+
emit("process.error", {
|
|
141
|
+
processId: state.id,
|
|
142
|
+
message: error instanceof Error ? error.message : String(error)
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
state.child.once("exit", (code, signal) => {
|
|
147
|
+
state.running = false
|
|
148
|
+
emit("process.exit", {
|
|
149
|
+
processId: state.id,
|
|
150
|
+
exitCode: typeof code === "number" ? code : null,
|
|
151
|
+
signal: typeof signal === "string" ? signal : null
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function spawnProcess(options) {
|
|
157
|
+
const record = requireRecord(options, "process.spawn requires an options object")
|
|
158
|
+
const command = requireString(record.command, "process.spawn requires a non-empty 'command'")
|
|
159
|
+
const args = optionalStringArray(record.args, "process.spawn 'args' must be a string array")
|
|
160
|
+
const cwd =
|
|
161
|
+
record.cwd === undefined
|
|
162
|
+
? process.cwd()
|
|
163
|
+
: requireString(record.cwd, "process.spawn 'cwd' must be a string")
|
|
164
|
+
const env = {
|
|
165
|
+
...process.env,
|
|
166
|
+
...optionalRecordOfStrings(record.env, "process.spawn 'env' must be a string map")
|
|
167
|
+
}
|
|
168
|
+
const shell = record.shell === true
|
|
169
|
+
|
|
170
|
+
const child = spawn(command, args, {
|
|
171
|
+
cwd,
|
|
172
|
+
env,
|
|
173
|
+
shell,
|
|
174
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
const state = {
|
|
178
|
+
id: `proc_${nextId++}`,
|
|
179
|
+
child,
|
|
180
|
+
command,
|
|
181
|
+
args,
|
|
182
|
+
cwd,
|
|
183
|
+
startedAt: new Date().toISOString(),
|
|
184
|
+
running: true
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
processes.set(state.id, state)
|
|
188
|
+
wireChildState(state)
|
|
189
|
+
|
|
190
|
+
emit("process.started", {
|
|
191
|
+
processId: state.id,
|
|
192
|
+
pid: child.pid ?? 0,
|
|
193
|
+
command,
|
|
194
|
+
args,
|
|
195
|
+
cwd
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
return toInfo(state)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function runProcess(options) {
|
|
202
|
+
const record = requireRecord(options, "process.run requires an options object")
|
|
203
|
+
const command = requireString(record.command, "process.run requires a non-empty 'command'")
|
|
204
|
+
const args = optionalStringArray(record.args, "process.run 'args' must be a string array")
|
|
205
|
+
const cwd =
|
|
206
|
+
record.cwd === undefined
|
|
207
|
+
? process.cwd()
|
|
208
|
+
: requireString(record.cwd, "process.run 'cwd' must be a string")
|
|
209
|
+
const env = {
|
|
210
|
+
...process.env,
|
|
211
|
+
...optionalRecordOfStrings(record.env, "process.run 'env' must be a string map")
|
|
212
|
+
}
|
|
213
|
+
const shell = record.shell === true
|
|
214
|
+
const input =
|
|
215
|
+
record.input === undefined
|
|
216
|
+
? null
|
|
217
|
+
: requireString(record.input, "process.run 'input' must be a string")
|
|
218
|
+
const timeoutMs =
|
|
219
|
+
record.timeoutMs === undefined
|
|
220
|
+
? 0
|
|
221
|
+
: Number(record.timeoutMs)
|
|
222
|
+
const maxBufferBytes =
|
|
223
|
+
record.maxBufferBytes === undefined
|
|
224
|
+
? 8 * 1024 * 1024
|
|
225
|
+
: Number(record.maxBufferBytes)
|
|
226
|
+
|
|
227
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
|
|
228
|
+
throw new BackendError("invalid_argument", "process.run 'timeoutMs' must be a non-negative number")
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (!Number.isFinite(maxBufferBytes) || maxBufferBytes <= 0) {
|
|
232
|
+
throw new BackendError("invalid_argument", "process.run 'maxBufferBytes' must be a positive number")
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return await new Promise((resolve, reject) => {
|
|
236
|
+
const child = spawn(command, args, {
|
|
237
|
+
cwd,
|
|
238
|
+
env,
|
|
239
|
+
shell,
|
|
240
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
let stdout = ""
|
|
244
|
+
let stderr = ""
|
|
245
|
+
let finished = false
|
|
246
|
+
let timedOut = false
|
|
247
|
+
let timeout = null
|
|
248
|
+
|
|
249
|
+
const fail = (error) => {
|
|
250
|
+
if (finished) {
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
finished = true
|
|
255
|
+
if (timeout) {
|
|
256
|
+
clearTimeout(timeout)
|
|
257
|
+
}
|
|
258
|
+
reject(error)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const append = (currentValue, update, chunk) => {
|
|
262
|
+
const text = String(chunk)
|
|
263
|
+
const nextSize = Buffer.byteLength(currentValue(), "utf8") + Buffer.byteLength(text, "utf8")
|
|
264
|
+
if (nextSize > maxBufferBytes) {
|
|
265
|
+
fail(new BackendError("max_buffer_exceeded", "process.run exceeded its output buffer limit", {
|
|
266
|
+
maxBufferBytes
|
|
267
|
+
}))
|
|
268
|
+
child.kill("SIGKILL")
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
update(currentValue() + text)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
child.stdout?.on("data", (chunk) => append(() => stdout, (next) => { stdout = next }, chunk))
|
|
276
|
+
child.stderr?.on("data", (chunk) => append(() => stderr, (next) => { stderr = next }, chunk))
|
|
277
|
+
child.once("error", fail)
|
|
278
|
+
child.once("exit", (code, signal) => {
|
|
279
|
+
if (finished) {
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
finished = true
|
|
284
|
+
if (timeout) {
|
|
285
|
+
clearTimeout(timeout)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
resolve({
|
|
289
|
+
command,
|
|
290
|
+
args,
|
|
291
|
+
cwd,
|
|
292
|
+
exitCode: typeof code === "number" ? code : null,
|
|
293
|
+
signal: typeof signal === "string" ? signal : null,
|
|
294
|
+
stdout,
|
|
295
|
+
stderr,
|
|
296
|
+
timedOut,
|
|
297
|
+
failed: timedOut || code !== 0
|
|
298
|
+
})
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
if (input !== null) {
|
|
302
|
+
child.stdin?.write(input)
|
|
303
|
+
}
|
|
304
|
+
child.stdin?.end()
|
|
305
|
+
|
|
306
|
+
if (timeoutMs > 0) {
|
|
307
|
+
timeout = setTimeout(() => {
|
|
308
|
+
timedOut = true
|
|
309
|
+
child.kill("SIGTERM")
|
|
310
|
+
}, timeoutMs)
|
|
311
|
+
}
|
|
312
|
+
})
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function writeToProcess(processId, data) {
|
|
316
|
+
const id = requireString(processId, "process.write requires a process id")
|
|
317
|
+
const text = requireString(data, "process.write requires string data")
|
|
318
|
+
const state = processes.get(id)
|
|
319
|
+
|
|
320
|
+
if (!state || !state.running || !state.child.stdin) {
|
|
321
|
+
throw new BackendError("process_not_found", `Backend process '${id}' is not running`)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
state.child.stdin.write(text)
|
|
325
|
+
return { written: true }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function killProcess(processId, options = {}) {
|
|
329
|
+
const id = requireString(processId, "process.kill requires a process id")
|
|
330
|
+
const state = processes.get(id)
|
|
331
|
+
|
|
332
|
+
if (!state || !state.running) {
|
|
333
|
+
return { killed: false }
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const signal =
|
|
337
|
+
options?.signal === undefined
|
|
338
|
+
? "SIGTERM"
|
|
339
|
+
: requireString(options.signal, "process.kill 'signal' must be a string")
|
|
340
|
+
|
|
341
|
+
state.child.kill(signal)
|
|
342
|
+
return { killed: true }
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function listProcesses() {
|
|
346
|
+
return {
|
|
347
|
+
processes: [...processes.values()].map((state) => toInfo(state))
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function disposeAll() {
|
|
352
|
+
for (const state of processes.values()) {
|
|
353
|
+
if (!state.running) {
|
|
354
|
+
continue
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
state.child.kill("SIGTERM")
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
spawn: spawnProcess,
|
|
363
|
+
run: runProcess,
|
|
364
|
+
write: writeToProcess,
|
|
365
|
+
kill: killProcess,
|
|
366
|
+
list: listProcesses,
|
|
367
|
+
disposeAll
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function loadBackendEntry(entryFile, context) {
|
|
372
|
+
const moduleUrl = pathToFileURL(path.resolve(entryFile)).href
|
|
373
|
+
const loaded = await import(moduleUrl)
|
|
374
|
+
const candidate = loaded.default ?? loaded.backend ?? loaded
|
|
375
|
+
|
|
376
|
+
if (typeof candidate === "function") {
|
|
377
|
+
await candidate(context)
|
|
378
|
+
return
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (isBackendDefinition(candidate)) {
|
|
382
|
+
await candidate.setup(context)
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (candidate && typeof candidate.setup === "function") {
|
|
387
|
+
await candidate.setup(context)
|
|
388
|
+
return
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (candidate && typeof candidate.handlers === "object" && candidate.handlers !== null) {
|
|
392
|
+
for (const [method, handler] of Object.entries(candidate.handlers)) {
|
|
393
|
+
context.handle(method, handler)
|
|
394
|
+
}
|
|
395
|
+
return
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (candidate && candidate !== loaded) {
|
|
399
|
+
throw new Error("Backend entry must export defineBackend(...), a setup function, or a handlers map.")
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function main() {
|
|
404
|
+
installProtocolSafeConsole()
|
|
405
|
+
|
|
406
|
+
const entryFile = process.argv[2]
|
|
407
|
+
if (!entryFile) {
|
|
408
|
+
throw new Error("Missing backend entry file argument.")
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const handlers = new Map()
|
|
412
|
+
|
|
413
|
+
const emit = (event, payload = null) => {
|
|
414
|
+
const eventName = requireString(event, "Backend event name must be a non-empty string")
|
|
415
|
+
writeProtocolMessage({
|
|
416
|
+
type: "event",
|
|
417
|
+
event: eventName,
|
|
418
|
+
payload
|
|
419
|
+
})
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const processApi = createProcessManager(emit)
|
|
423
|
+
|
|
424
|
+
const context = Object.freeze({
|
|
425
|
+
handle(method, handler) {
|
|
426
|
+
const methodName = requireString(method, "Backend method name must be a non-empty string")
|
|
427
|
+
if (typeof handler !== "function") {
|
|
428
|
+
throw new TypeError(`Backend handler for '${methodName}' must be a function.`)
|
|
429
|
+
}
|
|
430
|
+
if (handlers.has(methodName)) {
|
|
431
|
+
throw new Error(`Backend method already registered: ${methodName}`)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
handlers.set(methodName, handler)
|
|
435
|
+
},
|
|
436
|
+
emit,
|
|
437
|
+
process: Object.freeze({
|
|
438
|
+
spawn(options) {
|
|
439
|
+
return processApi.spawn(options)
|
|
440
|
+
},
|
|
441
|
+
run(options) {
|
|
442
|
+
return processApi.run(options)
|
|
443
|
+
},
|
|
444
|
+
write(processId, data) {
|
|
445
|
+
return processApi.write(processId, data)
|
|
446
|
+
},
|
|
447
|
+
kill(processId, options) {
|
|
448
|
+
return processApi.kill(processId, options)
|
|
449
|
+
},
|
|
450
|
+
list() {
|
|
451
|
+
return processApi.list()
|
|
452
|
+
}
|
|
453
|
+
})
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
context.handle("process.spawn", (params) => processApi.spawn(params))
|
|
457
|
+
context.handle("process.run", (params) => processApi.run(params))
|
|
458
|
+
context.handle("process.write", (params) => {
|
|
459
|
+
const record = requireRecord(params, "process.write requires an options object")
|
|
460
|
+
return processApi.write(
|
|
461
|
+
requireString(record.processId, "process.write requires 'processId'"),
|
|
462
|
+
requireString(record.data, "process.write requires 'data'")
|
|
463
|
+
)
|
|
464
|
+
})
|
|
465
|
+
context.handle("process.kill", (params) => {
|
|
466
|
+
const record = requireRecord(params, "process.kill requires an options object")
|
|
467
|
+
return processApi.kill(
|
|
468
|
+
requireString(record.processId, "process.kill requires 'processId'"),
|
|
469
|
+
record.signal === undefined ? {} : { signal: record.signal }
|
|
470
|
+
)
|
|
471
|
+
})
|
|
472
|
+
context.handle("process.list", () => processApi.list())
|
|
473
|
+
|
|
474
|
+
await loadBackendEntry(entryFile, context)
|
|
475
|
+
|
|
476
|
+
writeProtocolMessage({
|
|
477
|
+
type: "ready",
|
|
478
|
+
protocol: protocolVersion,
|
|
479
|
+
methods: [...handlers.keys()].sort(),
|
|
480
|
+
runtime: {
|
|
481
|
+
name: process.release?.name ?? "node",
|
|
482
|
+
version: process.version,
|
|
483
|
+
platform: process.platform,
|
|
484
|
+
arch: process.arch
|
|
485
|
+
}
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
const input = createInterface({
|
|
489
|
+
input: process.stdin,
|
|
490
|
+
crlfDelay: Infinity
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
process.once("SIGTERM", async () => {
|
|
494
|
+
await processApi.disposeAll()
|
|
495
|
+
process.exit(0)
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
input.on("line", async (line) => {
|
|
499
|
+
if (typeof line !== "string" || line.trim() === "") {
|
|
500
|
+
return
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
let message
|
|
504
|
+
try {
|
|
505
|
+
message = JSON.parse(line)
|
|
506
|
+
} catch {
|
|
507
|
+
writeStderrLine("[reset-backend:warn] ", ["Ignoring invalid protocol message"])
|
|
508
|
+
return
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (message?.type !== "invoke" || typeof message?.id !== "number") {
|
|
512
|
+
return
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const method = typeof message.method === "string" ? message.method : ""
|
|
516
|
+
const handler = handlers.get(method)
|
|
517
|
+
if (!handler) {
|
|
518
|
+
writeProtocolMessage({
|
|
519
|
+
type: "response",
|
|
520
|
+
id: message.id,
|
|
521
|
+
ok: false,
|
|
522
|
+
error: {
|
|
523
|
+
code: "backend_method_not_found",
|
|
524
|
+
message: `Unknown backend method: ${method}`
|
|
525
|
+
}
|
|
526
|
+
})
|
|
527
|
+
return
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
try {
|
|
531
|
+
const result = await handler(message.params, context)
|
|
532
|
+
writeProtocolMessage({
|
|
533
|
+
type: "response",
|
|
534
|
+
id: message.id,
|
|
535
|
+
ok: true,
|
|
536
|
+
result
|
|
537
|
+
})
|
|
538
|
+
} catch (error) {
|
|
539
|
+
writeProtocolMessage({
|
|
540
|
+
type: "response",
|
|
541
|
+
id: message.id,
|
|
542
|
+
ok: false,
|
|
543
|
+
error: serializeError(error)
|
|
544
|
+
})
|
|
545
|
+
}
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
input.once("close", async () => {
|
|
549
|
+
await processApi.disposeAll()
|
|
550
|
+
process.exit(0)
|
|
551
|
+
})
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
main().catch((error) => {
|
|
555
|
+
const serialized = serializeError(error)
|
|
556
|
+
writeStderrLine("[reset-backend:fatal] ", [serialized.message])
|
|
557
|
+
process.exit(1)
|
|
558
|
+
})
|