@unibridge/sdk 0.5.0 → 0.8.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.
Files changed (105) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +67 -0
  3. package/dist/client.d.ts +6 -0
  4. package/dist/client.d.ts.map +1 -0
  5. package/dist/client.js +59 -0
  6. package/dist/client.js.map +1 -0
  7. package/dist/commands/contracts.d.ts +8 -0
  8. package/dist/commands/contracts.d.ts.map +1 -0
  9. package/dist/commands/contracts.js +2 -0
  10. package/dist/commands/contracts.js.map +1 -0
  11. package/dist/commands/define.d.ts +19 -0
  12. package/dist/commands/define.d.ts.map +1 -0
  13. package/dist/commands/define.js +20 -0
  14. package/dist/commands/define.js.map +1 -0
  15. package/dist/commands/domain/contract.d.ts +10 -0
  16. package/dist/commands/domain/contract.d.ts.map +1 -0
  17. package/dist/commands/domain/contract.js +12 -0
  18. package/dist/commands/domain/contract.js.map +1 -0
  19. package/dist/commands/execute/client.d.ts +4 -0
  20. package/dist/commands/execute/client.d.ts.map +1 -0
  21. package/dist/commands/execute/client.js +5 -0
  22. package/dist/commands/execute/client.js.map +1 -0
  23. package/dist/commands/execute/contract.d.ts +4 -0
  24. package/dist/commands/execute/contract.d.ts.map +1 -0
  25. package/dist/commands/execute/contract.js +10 -0
  26. package/dist/commands/execute/contract.js.map +1 -0
  27. package/dist/commands/gameobject/contract.d.ts +224 -0
  28. package/dist/commands/gameobject/contract.d.ts.map +1 -0
  29. package/dist/commands/gameobject/contract.js +101 -0
  30. package/dist/commands/gameobject/contract.js.map +1 -0
  31. package/dist/commands/log/contract.d.ts +46 -0
  32. package/dist/commands/log/contract.d.ts.map +1 -0
  33. package/dist/commands/log/contract.js +31 -0
  34. package/dist/commands/log/contract.js.map +1 -0
  35. package/dist/commands/logs/contract.d.ts +46 -0
  36. package/dist/commands/logs/contract.d.ts.map +1 -0
  37. package/dist/commands/logs/contract.js +31 -0
  38. package/dist/commands/logs/contract.js.map +1 -0
  39. package/dist/commands/registry.d.ts +188 -0
  40. package/dist/commands/registry.d.ts.map +1 -0
  41. package/dist/commands/registry.js +9 -0
  42. package/dist/commands/registry.js.map +1 -0
  43. package/dist/commands/runtime.d.ts +7 -0
  44. package/dist/commands/runtime.d.ts.map +1 -0
  45. package/dist/commands/runtime.js +2 -0
  46. package/dist/commands/runtime.js.map +1 -0
  47. package/dist/commands/scene/client.d.ts +4 -0
  48. package/dist/commands/scene/client.d.ts.map +1 -0
  49. package/dist/commands/scene/client.js +7 -0
  50. package/dist/commands/scene/client.js.map +1 -0
  51. package/dist/commands/scene/contract.d.ts +127 -0
  52. package/dist/commands/scene/contract.d.ts.map +1 -0
  53. package/dist/commands/scene/contract.js +79 -0
  54. package/dist/commands/scene/contract.js.map +1 -0
  55. package/dist/commands/status/client.d.ts +4 -0
  56. package/dist/commands/status/client.d.ts.map +1 -0
  57. package/dist/commands/status/client.js +7 -0
  58. package/dist/commands/status/client.js.map +1 -0
  59. package/dist/commands/status/contract.d.ts +18 -0
  60. package/dist/commands/status/contract.d.ts.map +1 -0
  61. package/dist/commands/status/contract.js +16 -0
  62. package/dist/commands/status/contract.js.map +1 -0
  63. package/dist/commands/test/contract.d.ts +111 -0
  64. package/dist/commands/test/contract.d.ts.map +1 -0
  65. package/dist/commands/test/contract.js +71 -0
  66. package/dist/commands/test/contract.js.map +1 -0
  67. package/dist/connection.d.ts +37 -0
  68. package/dist/connection.d.ts.map +1 -0
  69. package/dist/connection.js +311 -0
  70. package/dist/connection.js.map +1 -0
  71. package/dist/hash.d.ts +4 -0
  72. package/dist/hash.d.ts.map +1 -0
  73. package/dist/hash.js +41 -0
  74. package/dist/hash.js.map +1 -0
  75. package/dist/index.d.ts +5 -0
  76. package/dist/index.d.ts.map +1 -0
  77. package/dist/index.js +3 -0
  78. package/dist/index.js.map +1 -0
  79. package/dist/project.d.ts +6 -0
  80. package/dist/project.d.ts.map +1 -0
  81. package/dist/project.js +71 -0
  82. package/dist/project.js.map +1 -0
  83. package/dist/types.d.ts +55 -0
  84. package/dist/types.d.ts.map +1 -0
  85. package/dist/types.js +2 -0
  86. package/dist/types.js.map +1 -0
  87. package/package.json +31 -5
  88. package/src/client.ts +0 -76
  89. package/src/commands/contracts.ts +0 -4
  90. package/src/commands/define.ts +0 -56
  91. package/src/commands/domain/contract.ts +0 -15
  92. package/src/commands/execute/contract.ts +0 -12
  93. package/src/commands/registry.ts +0 -6
  94. package/src/commands/runtime.ts +0 -7
  95. package/src/commands/scene/contract.ts +0 -46
  96. package/src/commands/status/contract.ts +0 -19
  97. package/src/connection.test.ts +0 -330
  98. package/src/connection.ts +0 -382
  99. package/src/hash.test.ts +0 -48
  100. package/src/hash.ts +0 -50
  101. package/src/index.ts +0 -10
  102. package/src/project.test.ts +0 -93
  103. package/src/project.ts +0 -99
  104. package/src/types.ts +0 -64
  105. package/tsconfig.json +0 -16
@@ -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
- }