@unibridge/sdk 0.5.0 → 0.9.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/LICENSE +21 -0
- package/README.md +67 -0
- package/dist/client.d.ts +6 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +59 -0
- package/dist/client.js.map +1 -0
- package/dist/commands/component/contract.d.ts +174 -0
- package/dist/commands/component/contract.d.ts.map +1 -0
- package/dist/commands/component/contract.js +142 -0
- package/dist/commands/component/contract.js.map +1 -0
- package/dist/commands/contracts.d.ts +9 -0
- package/dist/commands/contracts.d.ts.map +1 -0
- package/dist/commands/contracts.js +2 -0
- package/dist/commands/contracts.js.map +1 -0
- package/dist/commands/define.d.ts +19 -0
- package/dist/commands/define.d.ts.map +1 -0
- package/dist/commands/define.js +20 -0
- package/dist/commands/define.js.map +1 -0
- package/dist/commands/domain/contract.d.ts +10 -0
- package/dist/commands/domain/contract.d.ts.map +1 -0
- package/dist/commands/domain/contract.js +12 -0
- package/dist/commands/domain/contract.js.map +1 -0
- package/dist/commands/execute/contract.d.ts +4 -0
- package/dist/commands/execute/contract.d.ts.map +1 -0
- package/dist/commands/execute/contract.js +10 -0
- package/dist/commands/execute/contract.js.map +1 -0
- package/dist/commands/gameobject/contract.d.ts +320 -0
- package/dist/commands/gameobject/contract.d.ts.map +1 -0
- package/dist/commands/gameobject/contract.js +154 -0
- package/dist/commands/gameobject/contract.js.map +1 -0
- package/dist/commands/log/contract.d.ts +46 -0
- package/dist/commands/log/contract.d.ts.map +1 -0
- package/dist/commands/log/contract.js +31 -0
- package/dist/commands/log/contract.js.map +1 -0
- package/dist/commands/registry.d.ts +303 -0
- package/dist/commands/registry.d.ts.map +1 -0
- package/dist/commands/registry.js +10 -0
- package/dist/commands/registry.js.map +1 -0
- package/dist/commands/runtime.d.ts +7 -0
- package/dist/commands/runtime.d.ts.map +1 -0
- package/dist/commands/runtime.js +2 -0
- package/dist/commands/runtime.js.map +1 -0
- package/dist/commands/scene/contract.d.ts +127 -0
- package/dist/commands/scene/contract.d.ts.map +1 -0
- package/dist/commands/scene/contract.js +79 -0
- package/dist/commands/scene/contract.js.map +1 -0
- package/dist/commands/status/contract.d.ts +18 -0
- package/dist/commands/status/contract.d.ts.map +1 -0
- package/dist/commands/status/contract.js +16 -0
- package/dist/commands/status/contract.js.map +1 -0
- package/dist/commands/test/contract.d.ts +111 -0
- package/dist/commands/test/contract.d.ts.map +1 -0
- package/dist/commands/test/contract.js +71 -0
- package/dist/commands/test/contract.js.map +1 -0
- package/dist/connection.d.ts +37 -0
- package/dist/connection.d.ts.map +1 -0
- package/dist/connection.js +311 -0
- package/dist/connection.js.map +1 -0
- package/dist/hash.d.ts +4 -0
- package/dist/hash.d.ts.map +1 -0
- package/dist/hash.js +41 -0
- package/dist/hash.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/project.d.ts +6 -0
- package/dist/project.d.ts.map +1 -0
- package/dist/project.js +71 -0
- package/dist/project.js.map +1 -0
- package/dist/types.d.ts +55 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +31 -5
- package/src/client.ts +0 -76
- package/src/commands/contracts.ts +0 -4
- package/src/commands/define.ts +0 -56
- package/src/commands/domain/contract.ts +0 -15
- package/src/commands/execute/contract.ts +0 -12
- package/src/commands/registry.ts +0 -6
- package/src/commands/runtime.ts +0 -7
- package/src/commands/scene/contract.ts +0 -46
- package/src/commands/status/contract.ts +0 -19
- package/src/connection.test.ts +0 -330
- package/src/connection.ts +0 -382
- package/src/hash.test.ts +0 -48
- package/src/hash.ts +0 -50
- package/src/index.ts +0 -10
- package/src/project.test.ts +0 -93
- package/src/project.ts +0 -99
- package/src/types.ts +0 -64
- package/tsconfig.json +0 -16
package/src/connection.test.ts
DELETED
|
@@ -1,330 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, it } from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
import net from 'node:net'
|
|
4
|
-
import fs from 'node:fs'
|
|
5
|
-
import os from 'node:os'
|
|
6
|
-
import path from 'node:path'
|
|
7
|
-
import { PipeConnection } from './connection.ts'
|
|
8
|
-
import { stateDir } from './hash.ts'
|
|
9
|
-
|
|
10
|
-
const SOCK_PATH = '/tmp/unibridge-test.sock'
|
|
11
|
-
|
|
12
|
-
function createMockServer(handler: (data: Buffer) => Buffer) {
|
|
13
|
-
const sockets = new Set<net.Socket>()
|
|
14
|
-
const server = net.createServer((socket) => {
|
|
15
|
-
sockets.add(socket)
|
|
16
|
-
socket.on('close', () => {
|
|
17
|
-
sockets.delete(socket)
|
|
18
|
-
})
|
|
19
|
-
let buffer = Buffer.alloc(0)
|
|
20
|
-
socket.on('data', (chunk: Buffer | string) => {
|
|
21
|
-
const data = typeof chunk === 'string' ? Buffer.from(chunk) : chunk
|
|
22
|
-
buffer = Buffer.concat([buffer, data])
|
|
23
|
-
while (buffer.length >= 4) {
|
|
24
|
-
const len = buffer.readUInt32BE(0)
|
|
25
|
-
if (buffer.length < 4 + len) break
|
|
26
|
-
const msg = buffer.subarray(4, 4 + len)
|
|
27
|
-
buffer = buffer.subarray(4 + len)
|
|
28
|
-
const response = handler(msg)
|
|
29
|
-
const frame = Buffer.alloc(4 + response.length)
|
|
30
|
-
frame.writeUInt32BE(response.length, 0)
|
|
31
|
-
response.copy(frame, 4)
|
|
32
|
-
socket.write(frame)
|
|
33
|
-
}
|
|
34
|
-
})
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
try {
|
|
38
|
-
fs.unlinkSync(SOCK_PATH)
|
|
39
|
-
} catch {
|
|
40
|
-
// ignore missing socket
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
server.listen(SOCK_PATH)
|
|
44
|
-
const closeWithClients = server.close.bind(server)
|
|
45
|
-
; (server as net.Server & { close: net.Server['close'] }).close = ((callback?: (err?: Error) => void) => {
|
|
46
|
-
for (const socket of sockets) {
|
|
47
|
-
socket.destroy()
|
|
48
|
-
}
|
|
49
|
-
sockets.clear()
|
|
50
|
-
return closeWithClients(callback)
|
|
51
|
-
}) as net.Server['close']
|
|
52
|
-
return server
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
describe('PipeConnection', () => {
|
|
56
|
-
let server: net.Server | undefined
|
|
57
|
-
let conn: PipeConnection | undefined
|
|
58
|
-
let projectPath: string | undefined
|
|
59
|
-
|
|
60
|
-
afterEach(async () => {
|
|
61
|
-
conn?.disconnect()
|
|
62
|
-
if (server) {
|
|
63
|
-
await new Promise<void>((resolve) => server?.close(() => resolve()))
|
|
64
|
-
}
|
|
65
|
-
try {
|
|
66
|
-
fs.unlinkSync(SOCK_PATH)
|
|
67
|
-
} catch {
|
|
68
|
-
// ignore missing socket
|
|
69
|
-
}
|
|
70
|
-
if (projectPath) {
|
|
71
|
-
fs.rmSync(projectPath, { recursive: true, force: true })
|
|
72
|
-
projectPath = undefined
|
|
73
|
-
}
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
it('connects and sends/receives a message', async () => {
|
|
77
|
-
server = createMockServer((msg) => {
|
|
78
|
-
const req = JSON.parse(msg.toString())
|
|
79
|
-
return Buffer.from(JSON.stringify({
|
|
80
|
-
id: req.id,
|
|
81
|
-
success: true,
|
|
82
|
-
result: 'hello',
|
|
83
|
-
}))
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
conn = new PipeConnection()
|
|
87
|
-
await conn.connect(SOCK_PATH)
|
|
88
|
-
const res = await conn.send({
|
|
89
|
-
id: 'cmd-1',
|
|
90
|
-
command: 'execute',
|
|
91
|
-
params: { code: 'test' },
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
assert.equal(res.success, true)
|
|
95
|
-
assert.equal(res.result, 'hello')
|
|
96
|
-
})
|
|
97
|
-
|
|
98
|
-
it('deletes persisted result file after socket response is processed', async () => {
|
|
99
|
-
projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'unibridge-project-'))
|
|
100
|
-
const stateDirectory = stateDir(projectPath)
|
|
101
|
-
fs.mkdirSync(path.join(stateDirectory, 'results'), { recursive: true })
|
|
102
|
-
|
|
103
|
-
server = createMockServer((msg) => {
|
|
104
|
-
const req = JSON.parse(msg.toString())
|
|
105
|
-
const resultPath = path.join(stateDirectory, 'results', `${req.id}.json`)
|
|
106
|
-
fs.writeFileSync(resultPath, JSON.stringify({
|
|
107
|
-
id: req.id,
|
|
108
|
-
success: true,
|
|
109
|
-
result: 'socket-path',
|
|
110
|
-
error: null,
|
|
111
|
-
}))
|
|
112
|
-
|
|
113
|
-
return Buffer.from(JSON.stringify({
|
|
114
|
-
id: req.id,
|
|
115
|
-
success: true,
|
|
116
|
-
result: 'socket-path',
|
|
117
|
-
}))
|
|
118
|
-
})
|
|
119
|
-
|
|
120
|
-
conn = new PipeConnection({ projectPath })
|
|
121
|
-
await conn.connect(SOCK_PATH)
|
|
122
|
-
const response = await conn.send({
|
|
123
|
-
id: 'cmd-socket-cleanup',
|
|
124
|
-
command: 'execute',
|
|
125
|
-
params: { code: 'Debug.Log("x")' },
|
|
126
|
-
})
|
|
127
|
-
|
|
128
|
-
assert.equal(response.success, true)
|
|
129
|
-
assert.equal(response.result, 'socket-path')
|
|
130
|
-
const resultPath = path.join(stateDirectory, 'results', 'cmd-socket-cleanup.json')
|
|
131
|
-
assert.equal(fs.existsSync(resultPath), false)
|
|
132
|
-
})
|
|
133
|
-
|
|
134
|
-
it('matches responses to requests by ID', async () => {
|
|
135
|
-
server = createMockServer((msg) => {
|
|
136
|
-
const req = JSON.parse(msg.toString())
|
|
137
|
-
return Buffer.from(JSON.stringify({
|
|
138
|
-
id: req.id,
|
|
139
|
-
success: true,
|
|
140
|
-
result: req.id,
|
|
141
|
-
}))
|
|
142
|
-
})
|
|
143
|
-
|
|
144
|
-
conn = new PipeConnection()
|
|
145
|
-
await conn.connect(SOCK_PATH)
|
|
146
|
-
const [a, b] = await Promise.all([
|
|
147
|
-
conn.send({ id: 'cmd-a', command: 'execute', params: {} }),
|
|
148
|
-
conn.send({ id: 'cmd-b', command: 'execute', params: {} }),
|
|
149
|
-
])
|
|
150
|
-
|
|
151
|
-
assert.equal(a.result, 'cmd-a')
|
|
152
|
-
assert.equal(b.result, 'cmd-b')
|
|
153
|
-
})
|
|
154
|
-
|
|
155
|
-
it('reconnects after server restarts', async () => {
|
|
156
|
-
server = createMockServer((msg) => {
|
|
157
|
-
const req = JSON.parse(msg.toString())
|
|
158
|
-
return Buffer.from(JSON.stringify({ id: req.id, success: true, result: 'ok' }))
|
|
159
|
-
})
|
|
160
|
-
|
|
161
|
-
conn = new PipeConnection({ reconnectTimeout: 5000 })
|
|
162
|
-
await conn.connect(SOCK_PATH)
|
|
163
|
-
|
|
164
|
-
await new Promise<void>((resolve) => server?.close(() => resolve()))
|
|
165
|
-
try {
|
|
166
|
-
fs.unlinkSync(SOCK_PATH)
|
|
167
|
-
} catch {
|
|
168
|
-
// ignore missing socket
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
await new Promise((resolve) => setTimeout(resolve, 300))
|
|
172
|
-
server = createMockServer((msg) => {
|
|
173
|
-
const req = JSON.parse(msg.toString())
|
|
174
|
-
return Buffer.from(JSON.stringify({
|
|
175
|
-
id: req.id,
|
|
176
|
-
success: true,
|
|
177
|
-
result: 'reconnected',
|
|
178
|
-
}))
|
|
179
|
-
})
|
|
180
|
-
|
|
181
|
-
const res = await conn.send({
|
|
182
|
-
id: 'cmd-2',
|
|
183
|
-
command: 'execute',
|
|
184
|
-
params: {},
|
|
185
|
-
})
|
|
186
|
-
|
|
187
|
-
assert.equal(res.result, 'reconnected')
|
|
188
|
-
})
|
|
189
|
-
|
|
190
|
-
it('recovers pending response after reconnect without re-sending execute', async () => {
|
|
191
|
-
projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'unibridge-project-'))
|
|
192
|
-
const stateDirectory = stateDir(projectPath)
|
|
193
|
-
fs.mkdirSync(path.join(stateDirectory, 'results'), { recursive: true })
|
|
194
|
-
|
|
195
|
-
let sawExecute = false
|
|
196
|
-
server = createMockServer((msg) => {
|
|
197
|
-
const req = JSON.parse(msg.toString())
|
|
198
|
-
if (req.command === 'execute') {
|
|
199
|
-
sawExecute = true
|
|
200
|
-
setTimeout(() => {
|
|
201
|
-
server?.close()
|
|
202
|
-
try {
|
|
203
|
-
fs.unlinkSync(SOCK_PATH)
|
|
204
|
-
} catch {
|
|
205
|
-
// ignore missing socket
|
|
206
|
-
}
|
|
207
|
-
server = createMockServer((nextMsg) => {
|
|
208
|
-
const recoverReq = JSON.parse(nextMsg.toString())
|
|
209
|
-
if (recoverReq.command === 'recoverResults') {
|
|
210
|
-
const resultPath = path.join(stateDirectory, 'results', 'cmd-recover.json')
|
|
211
|
-
fs.writeFileSync(resultPath, JSON.stringify({
|
|
212
|
-
id: 'cmd-recover',
|
|
213
|
-
success: true,
|
|
214
|
-
result: 'recovered',
|
|
215
|
-
error: null,
|
|
216
|
-
}))
|
|
217
|
-
return Buffer.from(JSON.stringify({
|
|
218
|
-
id: recoverReq.id,
|
|
219
|
-
success: true,
|
|
220
|
-
result: JSON.stringify({
|
|
221
|
-
results: [
|
|
222
|
-
{
|
|
223
|
-
id: 'cmd-recover',
|
|
224
|
-
success: true,
|
|
225
|
-
result: 'recovered',
|
|
226
|
-
error: null,
|
|
227
|
-
},
|
|
228
|
-
],
|
|
229
|
-
}),
|
|
230
|
-
}))
|
|
231
|
-
}
|
|
232
|
-
return Buffer.from(JSON.stringify({
|
|
233
|
-
id: recoverReq.id,
|
|
234
|
-
success: false,
|
|
235
|
-
error: 'unexpected command',
|
|
236
|
-
}))
|
|
237
|
-
})
|
|
238
|
-
}, 50)
|
|
239
|
-
return Buffer.alloc(0)
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
return Buffer.from(JSON.stringify({
|
|
243
|
-
id: req.id,
|
|
244
|
-
success: false,
|
|
245
|
-
error: 'unexpected command',
|
|
246
|
-
}))
|
|
247
|
-
})
|
|
248
|
-
|
|
249
|
-
conn = new PipeConnection({ projectPath, commandTimeout: 2000, reconnectTimeout: 5000 })
|
|
250
|
-
await conn.connect(SOCK_PATH)
|
|
251
|
-
const response = await conn.send({
|
|
252
|
-
id: 'cmd-recover',
|
|
253
|
-
command: 'execute',
|
|
254
|
-
params: { code: 'Debug.Log("x")' },
|
|
255
|
-
})
|
|
256
|
-
|
|
257
|
-
assert.equal(sawExecute, true)
|
|
258
|
-
assert.equal(response.success, true)
|
|
259
|
-
assert.equal(response.result, 'recovered')
|
|
260
|
-
const recoveredResultPath = path.join(stateDirectory, 'results', 'cmd-recover.json')
|
|
261
|
-
assert.equal(fs.existsSync(recoveredResultPath), false)
|
|
262
|
-
})
|
|
263
|
-
|
|
264
|
-
it('does NOT re-send commands on reconnect', async () => {
|
|
265
|
-
let commandCount = 0
|
|
266
|
-
server = createMockServer(() => {
|
|
267
|
-
commandCount++
|
|
268
|
-
return Buffer.alloc(0)
|
|
269
|
-
})
|
|
270
|
-
|
|
271
|
-
conn = new PipeConnection({ commandTimeout: 500, reconnectTimeout: 2000 })
|
|
272
|
-
await conn.connect(SOCK_PATH)
|
|
273
|
-
|
|
274
|
-
const promise = conn.send({
|
|
275
|
-
id: 'cmd-reload',
|
|
276
|
-
command: 'execute',
|
|
277
|
-
params: {},
|
|
278
|
-
})
|
|
279
|
-
|
|
280
|
-
await assert.rejects(promise, /timeout/i)
|
|
281
|
-
assert.equal(commandCount, 1)
|
|
282
|
-
})
|
|
283
|
-
|
|
284
|
-
it('fires connect timeout when no server', async () => {
|
|
285
|
-
conn = new PipeConnection({ connectTimeout: 500 })
|
|
286
|
-
await assert.rejects(conn.connect('/tmp/nonexistent.sock'), /Connect timeout/i)
|
|
287
|
-
})
|
|
288
|
-
|
|
289
|
-
it('returns file result when socket response is lost during reload window', async () => {
|
|
290
|
-
projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'unibridge-project-'))
|
|
291
|
-
const stateDirectory = stateDir(projectPath)
|
|
292
|
-
fs.mkdirSync(path.join(stateDirectory, 'results'), { recursive: true })
|
|
293
|
-
|
|
294
|
-
server = createMockServer((msg) => {
|
|
295
|
-
const req = JSON.parse(msg.toString())
|
|
296
|
-
if (req.command === 'execute') {
|
|
297
|
-
setTimeout(() => {
|
|
298
|
-
const resultPath = path.join(stateDirectory, 'results', `${req.id}.json`)
|
|
299
|
-
fs.writeFileSync(resultPath, JSON.stringify({
|
|
300
|
-
id: req.id,
|
|
301
|
-
success: true,
|
|
302
|
-
result: 'from-file',
|
|
303
|
-
error: null,
|
|
304
|
-
}))
|
|
305
|
-
}, 100)
|
|
306
|
-
return Buffer.alloc(0)
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
return Buffer.from(JSON.stringify({
|
|
310
|
-
id: req.id,
|
|
311
|
-
success: false,
|
|
312
|
-
error: 'unexpected command',
|
|
313
|
-
}))
|
|
314
|
-
})
|
|
315
|
-
|
|
316
|
-
conn = new PipeConnection({ projectPath, commandTimeout: 1000, reconnectTimeout: 1000 })
|
|
317
|
-
await conn.connect(SOCK_PATH)
|
|
318
|
-
|
|
319
|
-
const response = await conn.send({
|
|
320
|
-
id: 'cmd-file-fallback',
|
|
321
|
-
command: 'execute',
|
|
322
|
-
params: { code: 'Debug.Log("x")' },
|
|
323
|
-
})
|
|
324
|
-
|
|
325
|
-
assert.equal(response.success, true)
|
|
326
|
-
assert.equal(response.result, 'from-file')
|
|
327
|
-
const resultPath = path.join(stateDirectory, 'results', 'cmd-file-fallback.json')
|
|
328
|
-
assert.equal(fs.existsSync(resultPath), false)
|
|
329
|
-
})
|
|
330
|
-
})
|
package/src/connection.ts
DELETED
|
@@ -1,382 +0,0 @@
|
|
|
1
|
-
import net from 'node:net'
|
|
2
|
-
import { randomUUID } from 'node:crypto'
|
|
3
|
-
import { existsSync, readFileSync, unlinkSync } from 'node:fs'
|
|
4
|
-
import path from 'node:path'
|
|
5
|
-
import { stateDir } from './hash.ts'
|
|
6
|
-
import type {
|
|
7
|
-
CommandRequest,
|
|
8
|
-
CommandResponse,
|
|
9
|
-
ServerMetadata,
|
|
10
|
-
TimeoutOptions,
|
|
11
|
-
} from './types.ts'
|
|
12
|
-
|
|
13
|
-
const DEFAULT_CONNECT_TIMEOUT = 5_000
|
|
14
|
-
const DEFAULT_COMMAND_TIMEOUT = 30_000
|
|
15
|
-
const DEFAULT_RECONNECT_TIMEOUT = 30_000
|
|
16
|
-
const EXPECTED_PROTOCOL_VERSION = 1
|
|
17
|
-
|
|
18
|
-
interface PendingRequest {
|
|
19
|
-
resolve: (value: CommandResponse) => void
|
|
20
|
-
reject: (reason?: unknown) => void
|
|
21
|
-
timer: NodeJS.Timeout
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
interface PipeConnectionOptions extends TimeoutOptions {
|
|
25
|
-
projectPath?: string
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export class PipeConnection {
|
|
29
|
-
private socket: net.Socket | undefined
|
|
30
|
-
private connected = false
|
|
31
|
-
private intentionalDisconnect = false
|
|
32
|
-
private frameBuffer = Buffer.alloc(0)
|
|
33
|
-
private pending = new Map<string, PendingRequest>()
|
|
34
|
-
private pipePathValue: string | undefined
|
|
35
|
-
private connectInFlight: Promise<void> | undefined
|
|
36
|
-
private readonly connectTimeout: number
|
|
37
|
-
private readonly commandTimeout: number
|
|
38
|
-
private readonly reconnectTimeout: number
|
|
39
|
-
private readonly projectPath: string | undefined
|
|
40
|
-
private recoveryInFlight: Promise<void> | undefined
|
|
41
|
-
|
|
42
|
-
constructor(options: PipeConnectionOptions = {}) {
|
|
43
|
-
this.connectTimeout = options.connectTimeout ?? DEFAULT_CONNECT_TIMEOUT
|
|
44
|
-
this.commandTimeout = options.commandTimeout ?? DEFAULT_COMMAND_TIMEOUT
|
|
45
|
-
this.reconnectTimeout = options.reconnectTimeout ?? DEFAULT_RECONNECT_TIMEOUT
|
|
46
|
-
this.projectPath = options.projectPath
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
async connect(pipePath: string): Promise<void> {
|
|
50
|
-
this.pipePathValue = pipePath
|
|
51
|
-
await this.ensureConnected(this.connectTimeout)
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
async send(request: CommandRequest): Promise<CommandResponse> {
|
|
55
|
-
if (!this.pipePathValue) {
|
|
56
|
-
throw new Error('Pipe path is not set. Call connect() before send().')
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
await this.ensureConnected(this.reconnectTimeout)
|
|
60
|
-
|
|
61
|
-
const timeout = this.commandTimeout
|
|
62
|
-
return new Promise<CommandResponse>((resolve, reject) => {
|
|
63
|
-
const timer = setTimeout(() => {
|
|
64
|
-
this.pending.delete(request.id)
|
|
65
|
-
const fileResult = this.readResultFromDisk(request.id)
|
|
66
|
-
if (fileResult) {
|
|
67
|
-
resolve(fileResult)
|
|
68
|
-
return
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
reject(new Error(`Command timeout (${Math.round(timeout / 1000)}s) — the command may have hung Unity's main thread`))
|
|
72
|
-
}, timeout)
|
|
73
|
-
|
|
74
|
-
this.pending.set(request.id, { resolve, reject, timer })
|
|
75
|
-
|
|
76
|
-
try {
|
|
77
|
-
this.writeFrame(request)
|
|
78
|
-
} catch (error) {
|
|
79
|
-
clearTimeout(timer)
|
|
80
|
-
this.pending.delete(request.id)
|
|
81
|
-
reject(error)
|
|
82
|
-
}
|
|
83
|
-
})
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
disconnect(): void {
|
|
87
|
-
this.intentionalDisconnect = true
|
|
88
|
-
this.connected = false
|
|
89
|
-
|
|
90
|
-
if (this.socket) {
|
|
91
|
-
this.socket.destroy()
|
|
92
|
-
this.socket = undefined
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
for (const [id, pending] of this.pending) {
|
|
96
|
-
clearTimeout(pending.timer)
|
|
97
|
-
pending.reject(new Error(`Connection closed before response for ${id}`))
|
|
98
|
-
}
|
|
99
|
-
this.pending.clear()
|
|
100
|
-
this.connectInFlight = undefined
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
serverMetadata(): ServerMetadata | null {
|
|
104
|
-
if (!this.projectPath) {
|
|
105
|
-
return null
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const metadataPath = path.join(stateDir(this.projectPath), 'server.json')
|
|
109
|
-
if (!existsSync(metadataPath)) {
|
|
110
|
-
return null
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
try {
|
|
114
|
-
return JSON.parse(readFileSync(metadataPath, 'utf-8')) as ServerMetadata
|
|
115
|
-
} catch {
|
|
116
|
-
return null
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
private async ensureConnected(timeout: number): Promise<void> {
|
|
121
|
-
if (this.connected && this.socket && !this.socket.destroyed) {
|
|
122
|
-
return
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
if (!this.pipePathValue) {
|
|
126
|
-
throw new Error('Pipe path is not set. Call connect() before send().')
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
if (!this.connectInFlight) {
|
|
130
|
-
this.connectInFlight = this.connectWithRetry(this.pipePathValue, timeout)
|
|
131
|
-
.finally(() => {
|
|
132
|
-
this.connectInFlight = undefined
|
|
133
|
-
})
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
await this.connectInFlight
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
private async connectWithRetry(pipePath: string, timeout: number): Promise<void> {
|
|
140
|
-
const started = Date.now()
|
|
141
|
-
let delay = 200
|
|
142
|
-
|
|
143
|
-
while (Date.now() - started < timeout) {
|
|
144
|
-
try {
|
|
145
|
-
await this.connectOnce(pipePath)
|
|
146
|
-
this.validateProtocolVersion()
|
|
147
|
-
this.requestPendingResults()
|
|
148
|
-
return
|
|
149
|
-
} catch {
|
|
150
|
-
await sleep(delay)
|
|
151
|
-
delay = Math.min(delay * 2, 5_000)
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
throw new Error(`Connect timeout (${Math.round(timeout / 1000)}s) — is Unity open with the unibridge plugin loaded?`)
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
private connectOnce(pipePath: string): Promise<void> {
|
|
159
|
-
return new Promise<void>((resolve, reject) => {
|
|
160
|
-
const socket = net.connect({ path: pipePath })
|
|
161
|
-
|
|
162
|
-
const onError = (error: Error) => {
|
|
163
|
-
socket.removeListener('connect', onConnect)
|
|
164
|
-
reject(error)
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const onConnect = () => {
|
|
168
|
-
socket.removeListener('error', onError)
|
|
169
|
-
this.socket = socket
|
|
170
|
-
this.connected = true
|
|
171
|
-
this.intentionalDisconnect = false
|
|
172
|
-
this.attachSocketHandlers(socket)
|
|
173
|
-
resolve()
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
socket.once('error', onError)
|
|
177
|
-
socket.once('connect', onConnect)
|
|
178
|
-
})
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
private attachSocketHandlers(socket: net.Socket): void {
|
|
182
|
-
socket.on('data', (chunk: Buffer | string) => {
|
|
183
|
-
if (typeof chunk !== 'string') {
|
|
184
|
-
this.frameBuffer = Buffer.concat([this.frameBuffer, chunk])
|
|
185
|
-
}
|
|
186
|
-
this.parseFrames()
|
|
187
|
-
})
|
|
188
|
-
|
|
189
|
-
socket.on('close', () => {
|
|
190
|
-
if (!this.intentionalDisconnect) {
|
|
191
|
-
this.connected = false
|
|
192
|
-
this.tryRecoverPending()
|
|
193
|
-
}
|
|
194
|
-
})
|
|
195
|
-
|
|
196
|
-
socket.on('error', () => {
|
|
197
|
-
this.connected = false
|
|
198
|
-
this.tryRecoverPending()
|
|
199
|
-
})
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
private parseFrames(): void {
|
|
203
|
-
while (this.frameBuffer.length >= 4) {
|
|
204
|
-
const messageLength = this.frameBuffer.readUInt32BE(0)
|
|
205
|
-
if (this.frameBuffer.length < 4 + messageLength) {
|
|
206
|
-
return
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const rawMessage = this.frameBuffer.subarray(4, 4 + messageLength)
|
|
210
|
-
this.frameBuffer = this.frameBuffer.subarray(4 + messageLength)
|
|
211
|
-
|
|
212
|
-
if (rawMessage.length === 0) {
|
|
213
|
-
continue
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
let parsed: CommandResponse
|
|
217
|
-
try {
|
|
218
|
-
parsed = JSON.parse(rawMessage.toString()) as CommandResponse
|
|
219
|
-
} catch {
|
|
220
|
-
continue
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
const pending = this.pending.get(parsed.id)
|
|
224
|
-
if (pending) {
|
|
225
|
-
clearTimeout(pending.timer)
|
|
226
|
-
this.pending.delete(parsed.id)
|
|
227
|
-
this.deleteResultFile(parsed.id)
|
|
228
|
-
pending.resolve(parsed)
|
|
229
|
-
continue
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
this.tryResolveRecoveredPayload(parsed)
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
private writeFrame(payload: unknown): void {
|
|
237
|
-
if (!this.socket || this.socket.destroyed) {
|
|
238
|
-
throw new Error('Connection is not active')
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
const body = Buffer.from(JSON.stringify(payload), 'utf-8')
|
|
242
|
-
const frame = Buffer.alloc(4 + body.length)
|
|
243
|
-
frame.writeUInt32BE(body.length, 0)
|
|
244
|
-
body.copy(frame, 4)
|
|
245
|
-
this.socket.write(frame)
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
private validateProtocolVersion(): void {
|
|
249
|
-
const metadata = this.serverMetadata()
|
|
250
|
-
if (!metadata) {
|
|
251
|
-
return
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
if (metadata.protocolVersion !== EXPECTED_PROTOCOL_VERSION) {
|
|
255
|
-
throw new Error(
|
|
256
|
-
`Protocol version mismatch: SDK expects ${EXPECTED_PROTOCOL_VERSION}, Unity plugin is ${metadata.protocolVersion}`,
|
|
257
|
-
)
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
private requestPendingResults(): void {
|
|
262
|
-
if (!this.socket || this.socket.destroyed || this.pending.size === 0) {
|
|
263
|
-
return
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
this.writeFrame({
|
|
267
|
-
id: `recover-${randomUUID()}`,
|
|
268
|
-
command: 'recoverResults',
|
|
269
|
-
params: { ids: [...this.pending.keys()] },
|
|
270
|
-
})
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
private tryRecoverPending(): void {
|
|
274
|
-
if (this.pending.size === 0 || this.recoveryInFlight) {
|
|
275
|
-
return
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
this.recoveryInFlight = this.ensureConnected(this.reconnectTimeout)
|
|
279
|
-
.catch(() => {
|
|
280
|
-
// Per-request timeouts and file fallback will handle failure.
|
|
281
|
-
})
|
|
282
|
-
.finally(() => {
|
|
283
|
-
this.recoveryInFlight = undefined
|
|
284
|
-
})
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
private tryResolveRecoveredPayload(response: CommandResponse): void {
|
|
288
|
-
let payload: unknown = response.result
|
|
289
|
-
if (typeof payload === 'string') {
|
|
290
|
-
try {
|
|
291
|
-
payload = JSON.parse(payload)
|
|
292
|
-
} catch {
|
|
293
|
-
return
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
if (!payload || typeof payload !== 'object' || !('results' in payload)) {
|
|
298
|
-
return
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
const maybeResults = (payload as { results?: unknown }).results
|
|
302
|
-
if (!Array.isArray(maybeResults)) {
|
|
303
|
-
return
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
for (const item of maybeResults) {
|
|
307
|
-
if (!item || typeof item !== 'object' || !('id' in item) || typeof item.id !== 'string') {
|
|
308
|
-
continue
|
|
309
|
-
}
|
|
310
|
-
const recoveredItem = item as Partial<CommandResponse> & { id: string }
|
|
311
|
-
|
|
312
|
-
const pending = this.pending.get(recoveredItem.id)
|
|
313
|
-
if (!pending) {
|
|
314
|
-
continue
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
const recovered: CommandResponse = {
|
|
318
|
-
id: recoveredItem.id,
|
|
319
|
-
success: typeof recoveredItem.success === 'boolean' ? recoveredItem.success : false,
|
|
320
|
-
result: recoveredItem.result,
|
|
321
|
-
error: typeof recoveredItem.error === 'string' ? recoveredItem.error : undefined,
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
clearTimeout(pending.timer)
|
|
325
|
-
this.pending.delete(recoveredItem.id)
|
|
326
|
-
this.deleteResultFile(recoveredItem.id)
|
|
327
|
-
pending.resolve(recovered)
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
private readResultFromDisk(requestId: string): CommandResponse | null {
|
|
332
|
-
if (!this.projectPath) {
|
|
333
|
-
return null
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
const resultPath = path.join(stateDir(this.projectPath), 'results', `${requestId}.json`)
|
|
337
|
-
if (!existsSync(resultPath)) {
|
|
338
|
-
return null
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
try {
|
|
342
|
-
const parsed = JSON.parse(readFileSync(resultPath, 'utf-8')) as Partial<CommandResponse>
|
|
343
|
-
if (typeof parsed.id !== 'string' || parsed.id !== requestId) {
|
|
344
|
-
return null
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
const response: CommandResponse = {
|
|
348
|
-
id: parsed.id,
|
|
349
|
-
success: Boolean(parsed.success),
|
|
350
|
-
result: parsed.result,
|
|
351
|
-
error: typeof parsed.error === 'string' ? parsed.error : undefined,
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
this.deleteResultFile(requestId)
|
|
355
|
-
|
|
356
|
-
return response
|
|
357
|
-
} catch {
|
|
358
|
-
return null
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
private deleteResultFile(requestId: string): void {
|
|
363
|
-
if (!this.projectPath) {
|
|
364
|
-
return
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
const resultPath = path.join(stateDir(this.projectPath), 'results', `${requestId}.json`)
|
|
368
|
-
if (!existsSync(resultPath)) {
|
|
369
|
-
return
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
try {
|
|
373
|
-
unlinkSync(resultPath)
|
|
374
|
-
} catch {
|
|
375
|
-
// Best-effort cleanup.
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
function sleep(ms: number): Promise<void> {
|
|
381
|
-
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
382
|
-
}
|