@unibridge/sdk 0.5.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/package.json +22 -0
- package/src/client.ts +76 -0
- package/src/commands/contracts.ts +4 -0
- package/src/commands/define.ts +56 -0
- package/src/commands/domain/contract.ts +15 -0
- package/src/commands/execute/contract.ts +12 -0
- package/src/commands/registry.ts +6 -0
- package/src/commands/runtime.ts +7 -0
- package/src/commands/scene/contract.ts +46 -0
- package/src/commands/status/contract.ts +19 -0
- package/src/connection.test.ts +330 -0
- package/src/connection.ts +382 -0
- package/src/hash.test.ts +48 -0
- package/src/hash.ts +50 -0
- package/src/index.ts +10 -0
- package/src/project.test.ts +93 -0
- package/src/project.ts +99 -0
- package/src/types.ts +64 -0
- package/tsconfig.json +16 -0
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@unibridge/sdk",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "node --test src/*.test.ts src/**/*.test.ts",
|
|
8
|
+
"build": "tsc -p tsconfig.json",
|
|
9
|
+
"prepublishOnly": "npm run build"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [],
|
|
12
|
+
"author": "",
|
|
13
|
+
"license": "ISC",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"valibot": "^1.2.0"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { PipeConnection } from './connection.ts'
|
|
3
|
+
import { pipePath } from './hash.ts'
|
|
4
|
+
import { findUnityProject } from './project.ts'
|
|
5
|
+
import type { ClientOptions, CommandResponse, UniBridgeClient } from './types.ts'
|
|
6
|
+
import { buildClientMethods } from './commands/define.ts'
|
|
7
|
+
import type { CommandRuntime, ExecuteGuard } from './commands/runtime.ts'
|
|
8
|
+
import { allCommands } from './commands/registry.ts'
|
|
9
|
+
|
|
10
|
+
export class UniBridgeError extends Error {
|
|
11
|
+
constructor(message: string) {
|
|
12
|
+
super(message)
|
|
13
|
+
this.name = 'UniBridgeError'
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function unwrap(response: CommandResponse): unknown {
|
|
18
|
+
if (!response.success) {
|
|
19
|
+
throw new UniBridgeError(response.error ?? 'Command failed')
|
|
20
|
+
}
|
|
21
|
+
return response.result
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function createRuntime(
|
|
25
|
+
sendCommand: (command: string, params: Record<string, unknown>) => Promise<CommandResponse>,
|
|
26
|
+
ensureExecuteEnabled: () => void,
|
|
27
|
+
): CommandRuntime & ExecuteGuard {
|
|
28
|
+
return {
|
|
29
|
+
async send(command: string, params: Record<string, unknown>): Promise<unknown> {
|
|
30
|
+
return unwrap(await sendCommand(command, params))
|
|
31
|
+
},
|
|
32
|
+
ensureExecuteEnabled,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createClient(options: ClientOptions = {}): UniBridgeClient {
|
|
37
|
+
const projectPath = options.projectPath ?? findUnityProject()
|
|
38
|
+
const connection = new PipeConnection({
|
|
39
|
+
projectPath,
|
|
40
|
+
connectTimeout: options.connectTimeout,
|
|
41
|
+
commandTimeout: options.commandTimeout,
|
|
42
|
+
reconnectTimeout: options.reconnectTimeout,
|
|
43
|
+
})
|
|
44
|
+
const callerExecuteEnabled = options.enableExecute ?? true
|
|
45
|
+
|
|
46
|
+
async function sendCommand(
|
|
47
|
+
command: string,
|
|
48
|
+
params: Record<string, unknown>,
|
|
49
|
+
): Promise<CommandResponse> {
|
|
50
|
+
await connection.connect(pipePath(projectPath))
|
|
51
|
+
return connection.send({ id: randomUUID(), command, params })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function ensureExecuteEnabled(): void {
|
|
55
|
+
if (!callerExecuteEnabled) {
|
|
56
|
+
throw new UniBridgeError('Execute is disabled by client or plugin configuration.')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const metadata = connection.serverMetadata()
|
|
60
|
+
const serverExecuteEnabled = metadata?.capabilities?.executeEnabled ?? true
|
|
61
|
+
if (!serverExecuteEnabled) {
|
|
62
|
+
throw new UniBridgeError('Execute is disabled by client or plugin configuration.')
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const runtime = createRuntime(sendCommand, ensureExecuteEnabled)
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
projectPath,
|
|
70
|
+
...buildClientMethods(runtime, allCommands),
|
|
71
|
+
close(): void {
|
|
72
|
+
connection.disconnect()
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export type { DomainReloadResult } from './domain/contract.ts'
|
|
2
|
+
export type { ExecuteResult } from './execute/contract.ts'
|
|
3
|
+
export type { SceneActiveResult, SceneCreateResult, SceneInfo, SceneOpenResult } from './scene/contract.ts'
|
|
4
|
+
export type { StatusResult } from './status/contract.ts'
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import * as v from 'valibot'
|
|
2
|
+
import type { CommandRuntime, ExecuteGuard } from './runtime.ts'
|
|
3
|
+
|
|
4
|
+
export interface CommandDef<
|
|
5
|
+
TMethod extends string,
|
|
6
|
+
TArgs extends unknown[],
|
|
7
|
+
TResult,
|
|
8
|
+
> {
|
|
9
|
+
readonly method: TMethod
|
|
10
|
+
readonly wire: string
|
|
11
|
+
readonly params: (...args: TArgs) => Record<string, unknown>
|
|
12
|
+
readonly result: v.GenericSchema<unknown, TResult>
|
|
13
|
+
readonly guard?: 'execute'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function defineCommand<
|
|
17
|
+
TMethod extends string,
|
|
18
|
+
TArgs extends unknown[],
|
|
19
|
+
TResult,
|
|
20
|
+
>(def: CommandDef<TMethod, TArgs, TResult>): CommandDef<TMethod, TArgs, TResult> {
|
|
21
|
+
return def
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
25
|
+
export type InferResult<T> = T extends CommandDef<string, any[], infer R> ? R : never
|
|
26
|
+
|
|
27
|
+
type CommandMethod<T> = T extends CommandDef<string, infer TArgs, infer TResult>
|
|
28
|
+
? (...args: TArgs) => Promise<TResult>
|
|
29
|
+
: never
|
|
30
|
+
|
|
31
|
+
export type CommandMethods<T extends readonly CommandDef<string, any[], any>[]> = {
|
|
32
|
+
[K in T[number] as K['method']]: CommandMethod<K>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function invokeCommand<TArgs extends unknown[], TResult>(
|
|
36
|
+
def: CommandDef<string, TArgs, TResult>,
|
|
37
|
+
runtime: CommandRuntime & ExecuteGuard,
|
|
38
|
+
...args: TArgs
|
|
39
|
+
): Promise<TResult> {
|
|
40
|
+
if (def.guard === 'execute') {
|
|
41
|
+
runtime.ensureExecuteEnabled()
|
|
42
|
+
}
|
|
43
|
+
const params = def.params(...args)
|
|
44
|
+
const raw = await runtime.send(def.wire, params)
|
|
45
|
+
return v.parse(def.result, raw)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function buildClientMethods<
|
|
49
|
+
const T extends readonly CommandDef<string, any[], any>[],
|
|
50
|
+
>(runtime: CommandRuntime & ExecuteGuard, defs: T): CommandMethods<T> {
|
|
51
|
+
const methods: Record<string, (...args: unknown[]) => Promise<unknown>> = {}
|
|
52
|
+
for (const def of defs) {
|
|
53
|
+
methods[def.method] = (...args: unknown[]) => invokeCommand(def, runtime, ...args)
|
|
54
|
+
}
|
|
55
|
+
return methods as CommandMethods<T>
|
|
56
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import * as v from 'valibot'
|
|
2
|
+
import { defineCommand, type InferResult } from '../define.ts'
|
|
3
|
+
|
|
4
|
+
export const DomainReloadResultSchema = v.object({
|
|
5
|
+
triggered: v.boolean(),
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
export const domainReloadCommand = defineCommand({
|
|
9
|
+
method: 'domainReload',
|
|
10
|
+
wire: 'domain.reload',
|
|
11
|
+
params: () => ({}),
|
|
12
|
+
result: DomainReloadResultSchema,
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
export type DomainReloadResult = InferResult<typeof domainReloadCommand>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import * as v from 'valibot'
|
|
2
|
+
import { defineCommand, type InferResult } from '../define.ts'
|
|
3
|
+
|
|
4
|
+
export const executeCommand = defineCommand({
|
|
5
|
+
method: 'execute',
|
|
6
|
+
wire: 'execute',
|
|
7
|
+
params: (code: string) => ({ code }),
|
|
8
|
+
result: v.unknown(),
|
|
9
|
+
guard: 'execute',
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
export type ExecuteResult = InferResult<typeof executeCommand>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { domainReloadCommand } from './domain/contract.ts'
|
|
2
|
+
import { executeCommand } from './execute/contract.ts'
|
|
3
|
+
import { sceneActiveCommand, sceneCreateCommand, sceneOpenCommand } from './scene/contract.ts'
|
|
4
|
+
import { statusCommand } from './status/contract.ts'
|
|
5
|
+
|
|
6
|
+
export const allCommands = [domainReloadCommand, executeCommand, statusCommand, sceneActiveCommand, sceneCreateCommand, sceneOpenCommand] as const
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import * as v from 'valibot'
|
|
2
|
+
import { defineCommand, type InferResult } from '../define.ts'
|
|
3
|
+
|
|
4
|
+
const SceneInfoSchema = v.object({
|
|
5
|
+
name: v.string(),
|
|
6
|
+
path: v.string(),
|
|
7
|
+
isDirty: v.boolean(),
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
export const SceneActiveResultSchema = v.object({
|
|
11
|
+
scene: SceneInfoSchema,
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
export const sceneActiveCommand = defineCommand({
|
|
15
|
+
method: 'sceneActive',
|
|
16
|
+
wire: 'scene.active',
|
|
17
|
+
params: () => ({}),
|
|
18
|
+
result: SceneActiveResultSchema,
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
export const SceneOpenResultSchema = v.object({
|
|
22
|
+
scene: SceneInfoSchema,
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
export const sceneOpenCommand = defineCommand({
|
|
26
|
+
method: 'sceneOpen',
|
|
27
|
+
wire: 'scene.open',
|
|
28
|
+
params: (path: string) => ({ path }),
|
|
29
|
+
result: SceneOpenResultSchema,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
export const SceneCreateResultSchema = v.object({
|
|
33
|
+
scene: SceneInfoSchema,
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
export const sceneCreateCommand = defineCommand({
|
|
37
|
+
method: 'sceneCreate',
|
|
38
|
+
wire: 'scene.create',
|
|
39
|
+
params: (path: string) => ({ path }),
|
|
40
|
+
result: SceneCreateResultSchema,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
export type SceneInfo = v.InferOutput<typeof SceneInfoSchema>
|
|
44
|
+
export type SceneActiveResult = InferResult<typeof sceneActiveCommand>
|
|
45
|
+
export type SceneOpenResult = InferResult<typeof sceneOpenCommand>
|
|
46
|
+
export type SceneCreateResult = InferResult<typeof sceneCreateCommand>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as v from 'valibot'
|
|
2
|
+
import { defineCommand, type InferResult } from '../define.ts'
|
|
3
|
+
|
|
4
|
+
export const StatusResultSchema = v.object({
|
|
5
|
+
projectPath: v.string(),
|
|
6
|
+
unityVersion: v.string(),
|
|
7
|
+
pluginVersion: v.string(),
|
|
8
|
+
activeScene: v.string(),
|
|
9
|
+
playMode: v.string(),
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
export const statusCommand = defineCommand({
|
|
13
|
+
method: 'status',
|
|
14
|
+
wire: 'status',
|
|
15
|
+
params: () => ({}),
|
|
16
|
+
result: StatusResultSchema,
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
export type StatusResult = InferResult<typeof statusCommand>
|
|
@@ -0,0 +1,330 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,382 @@
|
|
|
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
|
+
}
|
package/src/hash.test.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { pipePath, projectHash, stateDir } from './hash.ts'
|
|
4
|
+
|
|
5
|
+
describe('projectHash', () => {
|
|
6
|
+
it('returns a 12-char hex string', () => {
|
|
7
|
+
const hash = projectHash('/Users/me/MyGame')
|
|
8
|
+
assert.equal(hash.length, 12)
|
|
9
|
+
assert.match(hash, /^[a-f0-9]{12}$/)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('is deterministic', () => {
|
|
13
|
+
const a = projectHash('/Users/me/MyGame')
|
|
14
|
+
const b = projectHash('/Users/me/MyGame')
|
|
15
|
+
assert.equal(a, b)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('differs for different paths', () => {
|
|
19
|
+
const a = projectHash('/Users/me/GameA')
|
|
20
|
+
const b = projectHash('/Users/me/GameB')
|
|
21
|
+
assert.notEqual(a, b)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('normalizes trailing slash differences', () => {
|
|
25
|
+
const a = projectHash('/Users/me/MyGame')
|
|
26
|
+
const b = projectHash('/Users/me/MyGame/')
|
|
27
|
+
assert.equal(a, b)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('handles paths with spaces', () => {
|
|
31
|
+
const hash = projectHash('/Users/me/My Game')
|
|
32
|
+
assert.match(hash, /^[a-f0-9]{12}$/)
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('pipePath', () => {
|
|
37
|
+
it('returns a unix socket path on non-Windows', () => {
|
|
38
|
+
const path = pipePath('/Users/me/MyGame')
|
|
39
|
+
assert.match(path, /^\/tmp\/unibridge\/[a-f0-9]{12}\/bridge\.sock$/)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('stateDir', () => {
|
|
44
|
+
it('returns the temp directory for the project', () => {
|
|
45
|
+
const dir = stateDir('/Users/me/MyGame')
|
|
46
|
+
assert.match(dir, /^\/tmp\/unibridge\/[a-f0-9]{12}$/)
|
|
47
|
+
})
|
|
48
|
+
})
|
package/src/hash.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { realpathSync } from 'node:fs'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
function canonicalizeProjectPath(projectPath: string): string {
|
|
7
|
+
const resolved = path.resolve(projectPath)
|
|
8
|
+
let canonical = resolved
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
canonical = realpathSync.native(resolved)
|
|
12
|
+
} catch {
|
|
13
|
+
// Fall back to the resolved path when it does not exist yet.
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
canonical = canonical.replace(/\\/g, '/')
|
|
17
|
+
canonical = canonical.replace(/\/+$/g, '')
|
|
18
|
+
|
|
19
|
+
if (process.platform === 'win32') {
|
|
20
|
+
canonical = canonical.toLowerCase()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return canonical
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function projectHash(projectPath: string): string {
|
|
27
|
+
const canonical = canonicalizeProjectPath(projectPath)
|
|
28
|
+
return createHash('sha256').update(canonical).digest('hex').slice(0, 12)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function stateBaseDir(): string {
|
|
32
|
+
if (process.platform === 'win32') {
|
|
33
|
+
return path.join(os.tmpdir(), 'unibridge')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return '/tmp/unibridge'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function stateDir(projectPath: string): string {
|
|
40
|
+
return path.join(stateBaseDir(), projectHash(projectPath))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function pipePath(projectPath: string): string {
|
|
44
|
+
const hash = projectHash(projectPath)
|
|
45
|
+
if (process.platform === 'win32') {
|
|
46
|
+
return `\\\\.\\pipe\\unibridge-${hash}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return path.join(stateBaseDir(), hash, 'bridge.sock')
|
|
50
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { init, findUnityProject, isPluginInstalled } from './project.ts'
|
|
2
|
+
export { createClient } from './client.ts'
|
|
3
|
+
export type * from './commands/contracts.ts'
|
|
4
|
+
export type {
|
|
5
|
+
InitOptions,
|
|
6
|
+
InitResult,
|
|
7
|
+
CommandResponse,
|
|
8
|
+
ClientOptions,
|
|
9
|
+
UniBridgeClient,
|
|
10
|
+
} from './types.ts'
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { findUnityProject, init, isPluginInstalled, parseUnityVersion } from './project.ts'
|
|
5
|
+
|
|
6
|
+
const baseDir = '/tmp/unibridge-sdk-project-tests'
|
|
7
|
+
const testDir = `${baseDir}/test-unity-project`
|
|
8
|
+
const testDirWithSpaces = `${baseDir}/test unity project`
|
|
9
|
+
|
|
10
|
+
function createFakeProject(path: string, version = '2022.3.10f1') {
|
|
11
|
+
mkdirSync(`${path}/Assets`, { recursive: true })
|
|
12
|
+
mkdirSync(`${path}/ProjectSettings`, { recursive: true })
|
|
13
|
+
mkdirSync(`${path}/Packages`, { recursive: true })
|
|
14
|
+
writeFileSync(`${path}/ProjectSettings/ProjectVersion.txt`, `m_EditorVersion: ${version}\n`)
|
|
15
|
+
writeFileSync(`${path}/Packages/manifest.json`, JSON.stringify({ dependencies: {} }, null, 2))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('findUnityProject', () => {
|
|
19
|
+
beforeEach(() => createFakeProject(testDir))
|
|
20
|
+
afterEach(() => rmSync(baseDir, { recursive: true, force: true }))
|
|
21
|
+
|
|
22
|
+
it('finds project at the given path', () => {
|
|
23
|
+
assert.equal(findUnityProject(testDir), testDir)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('finds project from a subdirectory', () => {
|
|
27
|
+
mkdirSync(`${testDir}/Assets/Scripts`, { recursive: true })
|
|
28
|
+
assert.equal(findUnityProject(`${testDir}/Assets/Scripts`), testDir)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('supports project paths with spaces', () => {
|
|
32
|
+
createFakeProject(testDirWithSpaces)
|
|
33
|
+
assert.equal(findUnityProject(testDirWithSpaces), testDirWithSpaces)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('throws when no Unity project is found', () => {
|
|
37
|
+
assert.throws(() => findUnityProject('/tmp'), /Unity project not found/)
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('parseUnityVersion', () => {
|
|
42
|
+
beforeEach(() => createFakeProject(testDir, '6000.0.23f1'))
|
|
43
|
+
afterEach(() => rmSync(baseDir, { recursive: true, force: true }))
|
|
44
|
+
|
|
45
|
+
it('extracts version string', () => {
|
|
46
|
+
assert.equal(parseUnityVersion(testDir), '6000.0.23f1')
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('init', () => {
|
|
51
|
+
beforeEach(() => createFakeProject(testDir))
|
|
52
|
+
afterEach(() => rmSync(baseDir, { recursive: true, force: true }))
|
|
53
|
+
|
|
54
|
+
it('adds plugin to manifest.json with git source', async () => {
|
|
55
|
+
const result = await init({ projectPath: testDir })
|
|
56
|
+
assert.equal(result.unityVersion, '2022.3.10f1')
|
|
57
|
+
assert.equal(result.pluginSource, 'git')
|
|
58
|
+
|
|
59
|
+
const manifest = JSON.parse(readFileSync(`${testDir}/Packages/manifest.json`, 'utf-8'))
|
|
60
|
+
assert.ok(manifest.dependencies['com.msanatan.unibridge'])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('adds plugin with local source', async () => {
|
|
64
|
+
const result = await init({
|
|
65
|
+
projectPath: testDir,
|
|
66
|
+
source: { type: 'local', path: '../unibridge/unity' },
|
|
67
|
+
})
|
|
68
|
+
assert.equal(result.pluginSource, 'local')
|
|
69
|
+
|
|
70
|
+
const manifest = JSON.parse(readFileSync(`${testDir}/Packages/manifest.json`, 'utf-8'))
|
|
71
|
+
assert.equal(manifest.dependencies['com.msanatan.unibridge'], 'file:../unibridge/unity')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('no-ops when plugin is already at correct version', async () => {
|
|
75
|
+
await init({ projectPath: testDir })
|
|
76
|
+
const result = await init({ projectPath: testDir })
|
|
77
|
+
assert.equal(result.pluginSource, 'git')
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('isPluginInstalled', () => {
|
|
82
|
+
beforeEach(() => createFakeProject(testDir))
|
|
83
|
+
afterEach(() => rmSync(baseDir, { recursive: true, force: true }))
|
|
84
|
+
|
|
85
|
+
it('returns false when dependency is absent', () => {
|
|
86
|
+
assert.equal(isPluginInstalled(testDir), false)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('returns true after init installs the plugin', async () => {
|
|
90
|
+
await init({ projectPath: testDir })
|
|
91
|
+
assert.equal(isPluginInstalled(testDir), true)
|
|
92
|
+
})
|
|
93
|
+
})
|
package/src/project.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { cwd } from 'node:process'
|
|
4
|
+
import type { InitOptions, InitResult } from './types.ts'
|
|
5
|
+
|
|
6
|
+
const PLUGIN_NAME = 'com.msanatan.unibridge'
|
|
7
|
+
|
|
8
|
+
const SDK_VERSION = (
|
|
9
|
+
JSON.parse(
|
|
10
|
+
readFileSync(path.join(import.meta.dirname, '..', 'package.json'), 'utf-8'),
|
|
11
|
+
) as { version: string }
|
|
12
|
+
).version
|
|
13
|
+
|
|
14
|
+
const DEFAULT_GIT_SOURCE =
|
|
15
|
+
`https://github.com/msanatan/unibridge.git?path=unity#v${SDK_VERSION}`
|
|
16
|
+
|
|
17
|
+
interface Manifest {
|
|
18
|
+
dependencies?: Record<string, string>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readManifest(projectPath: string): Manifest {
|
|
22
|
+
const manifestPath = path.join(projectPath, 'Packages', 'manifest.json')
|
|
23
|
+
return JSON.parse(readFileSync(manifestPath, 'utf-8')) as Manifest
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function writeManifestAtomic(projectPath: string, manifest: Manifest): void {
|
|
27
|
+
const manifestPath = path.join(projectPath, 'Packages', 'manifest.json')
|
|
28
|
+
const tempPath = `${manifestPath}.tmp`
|
|
29
|
+
const content = `${JSON.stringify(manifest, null, 2)}\n`
|
|
30
|
+
writeFileSync(tempPath, content, 'utf-8')
|
|
31
|
+
renameSync(tempPath, manifestPath)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function findUnityProject(startPath: string = cwd()): string {
|
|
35
|
+
let current = path.resolve(startPath)
|
|
36
|
+
|
|
37
|
+
while (true) {
|
|
38
|
+
const assetsPath = path.join(current, 'Assets')
|
|
39
|
+
const versionPath = path.join(current, 'ProjectSettings', 'ProjectVersion.txt')
|
|
40
|
+
|
|
41
|
+
if (existsSync(assetsPath) && existsSync(versionPath)) {
|
|
42
|
+
return current
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const parent = path.dirname(current)
|
|
46
|
+
if (parent === current) {
|
|
47
|
+
throw new Error('Unity project not found. Run inside a Unity project or pass --project <path>.')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
current = parent
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function parseUnityVersion(projectPath: string): string {
|
|
55
|
+
const versionPath = path.join(projectPath, 'ProjectSettings', 'ProjectVersion.txt')
|
|
56
|
+
const content = readFileSync(versionPath, 'utf-8')
|
|
57
|
+
const match = content.match(/m_EditorVersion:\s*(\S+)/)
|
|
58
|
+
if (!match) {
|
|
59
|
+
throw new Error(`Could not parse m_EditorVersion from ${versionPath}`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return match[1]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function isPluginInstalled(projectPath: string): boolean {
|
|
66
|
+
const manifest = readManifest(projectPath)
|
|
67
|
+
return Boolean(manifest.dependencies?.[PLUGIN_NAME])
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function init(options: InitOptions = {}): Promise<InitResult> {
|
|
71
|
+
const projectPath = findUnityProject(options.projectPath)
|
|
72
|
+
const unityVersion = parseUnityVersion(projectPath)
|
|
73
|
+
|
|
74
|
+
const manifest = readManifest(projectPath)
|
|
75
|
+
const dependencies = { ...(manifest.dependencies ?? {}) }
|
|
76
|
+
|
|
77
|
+
const pluginSource = options.source?.type ?? 'git'
|
|
78
|
+
const pluginReference =
|
|
79
|
+
options.source?.type === 'local'
|
|
80
|
+
? `file:${options.source.path}`
|
|
81
|
+
: options.source?.type === 'git'
|
|
82
|
+
? options.source.url
|
|
83
|
+
: DEFAULT_GIT_SOURCE
|
|
84
|
+
|
|
85
|
+
if (dependencies[PLUGIN_NAME] !== pluginReference) {
|
|
86
|
+
dependencies[PLUGIN_NAME] = pluginReference
|
|
87
|
+
writeManifestAtomic(projectPath, {
|
|
88
|
+
...manifest,
|
|
89
|
+
dependencies,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
projectPath,
|
|
95
|
+
unityVersion,
|
|
96
|
+
pluginVersion: SDK_VERSION,
|
|
97
|
+
pluginSource,
|
|
98
|
+
}
|
|
99
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { CommandMethods } from './commands/define.ts'
|
|
2
|
+
import type { allCommands } from './commands/registry.ts'
|
|
3
|
+
|
|
4
|
+
export interface InitOptions {
|
|
5
|
+
projectPath?: string
|
|
6
|
+
source?: GitSource | LocalSource
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface GitSource {
|
|
10
|
+
type: 'git'
|
|
11
|
+
url: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface LocalSource {
|
|
15
|
+
type: 'local'
|
|
16
|
+
path: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface InitResult {
|
|
20
|
+
projectPath: string
|
|
21
|
+
unityVersion: string
|
|
22
|
+
pluginVersion: string
|
|
23
|
+
pluginSource: 'git' | 'local'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CommandRequest {
|
|
27
|
+
id: string
|
|
28
|
+
command: string
|
|
29
|
+
params: Record<string, unknown>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CommandResponse {
|
|
33
|
+
id: string
|
|
34
|
+
success: boolean
|
|
35
|
+
result?: unknown
|
|
36
|
+
error?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ServerMetadata {
|
|
40
|
+
pid: number
|
|
41
|
+
unityVersion: string
|
|
42
|
+
pluginVersion: string
|
|
43
|
+
protocolVersion: number
|
|
44
|
+
capabilities?: {
|
|
45
|
+
executeEnabled?: boolean
|
|
46
|
+
}
|
|
47
|
+
projectPath?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface TimeoutOptions {
|
|
51
|
+
connectTimeout?: number
|
|
52
|
+
commandTimeout?: number
|
|
53
|
+
reconnectTimeout?: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ClientOptions extends TimeoutOptions {
|
|
57
|
+
projectPath?: string
|
|
58
|
+
enableExecute?: boolean
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type UniBridgeClient = CommandMethods<typeof allCommands> & {
|
|
62
|
+
readonly projectPath: string
|
|
63
|
+
close(): void
|
|
64
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"rootDir": "src",
|
|
5
|
+
"outDir": "dist",
|
|
6
|
+
"declaration": true,
|
|
7
|
+
"declarationMap": true,
|
|
8
|
+
"sourceMap": true
|
|
9
|
+
},
|
|
10
|
+
"include": [
|
|
11
|
+
"src/**/*.ts"
|
|
12
|
+
],
|
|
13
|
+
"exclude": [
|
|
14
|
+
"src/**/*.test.ts"
|
|
15
|
+
]
|
|
16
|
+
}
|