@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 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,7 @@
1
+ export interface CommandRuntime {
2
+ send(command: string, params: Record<string, unknown>): Promise<unknown>
3
+ }
4
+
5
+ export interface ExecuteGuard {
6
+ ensureExecuteEnabled(): void
7
+ }
@@ -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
+ }
@@ -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
+ }