@talex-touch/utils 1.0.42 → 1.0.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/.eslintcache +1 -0
  2. package/__tests__/cloud-sync-sdk.test.ts +442 -0
  3. package/__tests__/icons/icons.test.ts +84 -0
  4. package/__tests__/plugin-sdk-lifecycle.test.ts +130 -0
  5. package/__tests__/power-sdk.test.ts +143 -0
  6. package/__tests__/preset-export-types.test.ts +108 -0
  7. package/__tests__/search/fuzzy-match.test.ts +137 -0
  8. package/__tests__/transport/port-policy.test.ts +44 -0
  9. package/__tests__/transport-domain-sdks.test.ts +152 -0
  10. package/__tests__/types/update.test.ts +67 -0
  11. package/account/account-sdk.ts +915 -0
  12. package/account/index.ts +2 -0
  13. package/account/types.ts +321 -0
  14. package/analytics/client.ts +136 -0
  15. package/analytics/index.ts +2 -0
  16. package/analytics/types.ts +156 -0
  17. package/animation/auto-resize.ts +322 -0
  18. package/animation/window-node.ts +26 -19
  19. package/auth/clerk-types.ts +12 -30
  20. package/auth/index.ts +0 -2
  21. package/auth/useAuthState.ts +6 -14
  22. package/base/index.ts +2 -0
  23. package/base/log-level.ts +105 -0
  24. package/channel/index.ts +170 -69
  25. package/cloud-sync/cloud-sync-sdk.ts +450 -0
  26. package/cloud-sync/index.ts +1 -0
  27. package/common/file-scan-utils.ts +17 -9
  28. package/common/index.ts +4 -0
  29. package/common/logger/index.ts +46 -0
  30. package/common/logger/logger-manager.ts +303 -0
  31. package/common/logger/module-logger.ts +270 -0
  32. package/common/logger/transport-logger.ts +234 -0
  33. package/common/logger/types.ts +93 -0
  34. package/common/search/gather.ts +48 -6
  35. package/common/search/index.ts +8 -0
  36. package/common/storage/constants.ts +13 -0
  37. package/common/storage/entity/app-settings.ts +245 -0
  38. package/common/storage/entity/index.ts +3 -0
  39. package/common/storage/entity/layout-atom-types.ts +147 -0
  40. package/common/storage/entity/openers.ts +1 -0
  41. package/common/storage/entity/preset-cloud-api.ts +132 -0
  42. package/common/storage/entity/preset-export-types.ts +256 -0
  43. package/common/storage/entity/shortcut-settings.ts +1 -0
  44. package/common/storage/shortcut-storage.ts +11 -0
  45. package/common/utils/clone-diagnostics.ts +105 -0
  46. package/common/utils/file.ts +16 -8
  47. package/common/utils/index.ts +6 -2
  48. package/common/utils/payload-preview.ts +173 -0
  49. package/common/utils/polling.ts +167 -13
  50. package/common/utils/safe-path.ts +103 -0
  51. package/common/utils/safe-shell.ts +115 -0
  52. package/common/utils/task-queue.ts +4 -1
  53. package/core-box/builder/tuff-builder.ts +0 -1
  54. package/core-box/index.ts +1 -1
  55. package/core-box/recommendation.ts +38 -1
  56. package/core-box/tuff/tuff-dsl.ts +32 -0
  57. package/electron/download-manager.ts +10 -7
  58. package/electron/env-tool.ts +42 -40
  59. package/electron/index.ts +0 -1
  60. package/env/index.ts +156 -0
  61. package/eslint.config.js +55 -0
  62. package/i18n/index.ts +62 -0
  63. package/i18n/locales/en.json +226 -0
  64. package/i18n/locales/zh.json +226 -0
  65. package/i18n/message-keys.ts +236 -0
  66. package/i18n/resolver.ts +181 -0
  67. package/icons/index.ts +257 -0
  68. package/icons/svg.ts +69 -0
  69. package/index.ts +9 -1
  70. package/intelligence/client.ts +72 -42
  71. package/market/constants.ts +9 -5
  72. package/market/index.ts +1 -1
  73. package/market/types.ts +19 -4
  74. package/package.json +15 -5
  75. package/permission/index.ts +143 -46
  76. package/permission/legacy.ts +26 -0
  77. package/permission/registry.ts +304 -0
  78. package/permission/types.ts +164 -0
  79. package/plugin/channel.ts +68 -39
  80. package/plugin/index.ts +80 -7
  81. package/plugin/install.ts +3 -0
  82. package/plugin/log/types.ts +22 -5
  83. package/plugin/node/logger-manager.ts +11 -3
  84. package/plugin/node/logger.ts +24 -17
  85. package/plugin/preload.ts +25 -2
  86. package/plugin/providers/index.ts +4 -4
  87. package/plugin/providers/market-client.ts +6 -3
  88. package/plugin/providers/npm-provider.ts +22 -7
  89. package/plugin/providers/tpex-provider.ts +22 -8
  90. package/plugin/sdk/box-items.ts +14 -0
  91. package/plugin/sdk/box-sdk.ts +64 -0
  92. package/plugin/sdk/channel.ts +119 -4
  93. package/plugin/sdk/clipboard.ts +26 -12
  94. package/plugin/sdk/cloud-sync.ts +113 -0
  95. package/plugin/sdk/common.ts +19 -11
  96. package/plugin/sdk/core-box.ts +6 -15
  97. package/plugin/sdk/division-box.ts +160 -65
  98. package/plugin/sdk/examples/storage-onDidChange-example.js +5 -2
  99. package/plugin/sdk/feature-sdk.ts +111 -76
  100. package/plugin/sdk/flow.ts +146 -45
  101. package/plugin/sdk/hooks/bridge.ts +13 -6
  102. package/plugin/sdk/hooks/life-cycle.ts +35 -16
  103. package/plugin/sdk/index.ts +14 -3
  104. package/plugin/sdk/intelligence.ts +87 -0
  105. package/plugin/sdk/meta/README.md +179 -0
  106. package/plugin/sdk/meta-sdk.ts +244 -0
  107. package/plugin/sdk/notification.ts +9 -0
  108. package/plugin/sdk/plugin-info.ts +64 -0
  109. package/plugin/sdk/power.ts +155 -0
  110. package/plugin/sdk/recommend.ts +21 -0
  111. package/plugin/sdk/service/index.ts +12 -8
  112. package/plugin/sdk/sqlite.ts +141 -0
  113. package/plugin/sdk/storage.ts +2 -6
  114. package/plugin/sdk/system.ts +2 -9
  115. package/plugin/sdk/temp-files.ts +41 -0
  116. package/plugin/sdk/touch-sdk.ts +18 -0
  117. package/plugin/sdk/types.ts +44 -4
  118. package/plugin/sdk/window/index.ts +12 -9
  119. package/plugin/sdk-version.ts +231 -0
  120. package/preload/renderer.ts +3 -2
  121. package/renderer/hooks/arg-mapper.ts +16 -2
  122. package/renderer/hooks/index.ts +13 -0
  123. package/renderer/hooks/initialize.ts +2 -1
  124. package/renderer/hooks/use-agent-market-sdk.ts +7 -0
  125. package/renderer/hooks/use-agent-market.ts +106 -0
  126. package/renderer/hooks/use-agents-sdk.ts +7 -0
  127. package/renderer/hooks/use-app-sdk.ts +7 -0
  128. package/renderer/hooks/use-channel.ts +33 -4
  129. package/renderer/hooks/use-download-sdk.ts +21 -0
  130. package/renderer/hooks/use-intelligence-sdk.ts +7 -0
  131. package/renderer/hooks/use-intelligence-stats.ts +290 -0
  132. package/renderer/hooks/use-intelligence.ts +55 -214
  133. package/renderer/hooks/use-market-sdk.ts +16 -0
  134. package/renderer/hooks/use-notification-sdk.ts +7 -0
  135. package/renderer/hooks/use-permission-sdk.ts +7 -0
  136. package/renderer/hooks/use-permission.ts +325 -0
  137. package/renderer/hooks/use-platform-sdk.ts +7 -0
  138. package/renderer/hooks/use-plugin-sdk.ts +16 -0
  139. package/renderer/hooks/use-settings-sdk.ts +7 -0
  140. package/renderer/hooks/use-update-sdk.ts +21 -0
  141. package/renderer/index.ts +1 -0
  142. package/renderer/ref.ts +19 -10
  143. package/renderer/shared/components/SharedPluginDetailContent.vue +84 -0
  144. package/renderer/shared/components/SharedPluginDetailHeader.vue +116 -0
  145. package/renderer/shared/components/SharedPluginDetailMetaList.vue +39 -0
  146. package/renderer/shared/components/SharedPluginDetailReadme.vue +45 -0
  147. package/renderer/shared/components/SharedPluginDetailVersions.vue +98 -0
  148. package/renderer/shared/components/index.ts +5 -0
  149. package/renderer/shared/components/shims-vue.d.ts +5 -0
  150. package/renderer/shared/index.ts +2 -0
  151. package/renderer/shared/plugin-detail.ts +62 -0
  152. package/renderer/storage/app-settings.ts +3 -1
  153. package/renderer/storage/base-storage.ts +508 -82
  154. package/renderer/storage/intelligence-storage.ts +31 -40
  155. package/renderer/storage/openers.ts +3 -1
  156. package/renderer/storage/storage-subscription.ts +126 -42
  157. package/renderer/touch-sdk/env.ts +10 -10
  158. package/renderer/touch-sdk/index.ts +114 -18
  159. package/renderer/touch-sdk/terminal.ts +24 -13
  160. package/search/feature-matcher.ts +279 -0
  161. package/search/fuzzy-match.ts +64 -34
  162. package/search/index.ts +10 -0
  163. package/search/levenshtein-utils.ts +17 -11
  164. package/transport/errors.ts +310 -0
  165. package/transport/event/builder.ts +378 -0
  166. package/transport/event/index.ts +7 -0
  167. package/transport/event/types.ts +292 -0
  168. package/transport/events/index.ts +2690 -0
  169. package/transport/events/meta-overlay.ts +79 -0
  170. package/transport/events/types/agents.ts +177 -0
  171. package/transport/events/types/app-index.ts +20 -0
  172. package/transport/events/types/app.ts +475 -0
  173. package/transport/events/types/box-item.ts +222 -0
  174. package/transport/events/types/clipboard.ts +80 -0
  175. package/transport/events/types/core-box.ts +534 -0
  176. package/transport/events/types/device-idle.ts +7 -0
  177. package/transport/events/types/division-box.ts +99 -0
  178. package/transport/events/types/download.ts +115 -0
  179. package/transport/events/types/file-index.ts +84 -0
  180. package/transport/events/types/flow.ts +149 -0
  181. package/transport/events/types/index.ts +70 -0
  182. package/transport/events/types/market.ts +39 -0
  183. package/transport/events/types/meta-overlay.ts +184 -0
  184. package/transport/events/types/notification.ts +140 -0
  185. package/transport/events/types/permission.ts +90 -0
  186. package/transport/events/types/platform.ts +8 -0
  187. package/transport/events/types/plugin.ts +631 -0
  188. package/transport/events/types/sentry.ts +20 -0
  189. package/transport/events/types/storage.ts +208 -0
  190. package/transport/events/types/transport.ts +60 -0
  191. package/transport/events/types/tray.ts +16 -0
  192. package/transport/events/types/update.ts +78 -0
  193. package/transport/index.ts +141 -0
  194. package/transport/main.ts +2 -0
  195. package/transport/prelude.ts +208 -0
  196. package/transport/sdk/constants.ts +29 -0
  197. package/transport/sdk/domains/agents-market.ts +47 -0
  198. package/transport/sdk/domains/agents.ts +62 -0
  199. package/transport/sdk/domains/app.ts +48 -0
  200. package/transport/sdk/domains/disposable.ts +35 -0
  201. package/transport/sdk/domains/download.ts +139 -0
  202. package/transport/sdk/domains/index.ts +13 -0
  203. package/transport/sdk/domains/intelligence.ts +616 -0
  204. package/transport/sdk/domains/market.ts +35 -0
  205. package/transport/sdk/domains/notification.ts +62 -0
  206. package/transport/sdk/domains/permission.ts +85 -0
  207. package/transport/sdk/domains/platform.ts +19 -0
  208. package/transport/sdk/domains/plugin.ts +144 -0
  209. package/transport/sdk/domains/settings.ts +102 -0
  210. package/transport/sdk/domains/update.ts +64 -0
  211. package/transport/sdk/index.ts +60 -0
  212. package/transport/sdk/main-transport.ts +710 -0
  213. package/transport/sdk/main.ts +9 -0
  214. package/transport/sdk/plugin-transport.ts +654 -0
  215. package/transport/sdk/port-policy.ts +38 -0
  216. package/transport/sdk/renderer-transport.ts +1165 -0
  217. package/transport/types.ts +605 -0
  218. package/types/agent.ts +399 -0
  219. package/types/cloud-sync.ts +157 -0
  220. package/types/division-box.ts +31 -31
  221. package/types/download.ts +1 -0
  222. package/types/flow.ts +63 -12
  223. package/types/icon.ts +2 -1
  224. package/types/index.ts +5 -0
  225. package/types/intelligence.ts +166 -173
  226. package/types/modules/base.ts +2 -0
  227. package/types/path-browserify.d.ts +5 -0
  228. package/types/platform.ts +12 -0
  229. package/types/startup-info.ts +32 -0
  230. package/types/touch-app-core.ts +8 -8
  231. package/types/update.ts +94 -1
  232. package/vitest.config.ts +25 -0
  233. package/auth/useClerkConfig.ts +0 -40
  234. package/auth/useClerkProvider.ts +0 -52
@@ -0,0 +1,1165 @@
1
+ /**
2
+ * @fileoverview Renderer-side TuffTransport implementation
3
+ * @module @talex-touch/utils/transport/sdk/renderer-transport
4
+ */
5
+
6
+ import type { TuffEvent } from '../event/types'
7
+ import type {
8
+ TransportPortConfirmPayload,
9
+ TransportPortEnvelope,
10
+ TransportPortUpgradeRequest,
11
+ TransportPortUpgradeResponse,
12
+ } from '../events'
13
+ import type {
14
+ ITuffTransport,
15
+ SendOptions,
16
+ StreamController,
17
+ StreamMessage,
18
+ StreamOptions,
19
+ TransportPortHandle,
20
+ TransportPortOpenOptions,
21
+ } from '../types'
22
+ import { useChannel } from '../../renderer/hooks/use-channel'
23
+ import { findCloneIssue, isCloneError, summarizeClonePayload } from '../../common/utils/clone-diagnostics'
24
+ import { assertTuffEvent } from '../event/builder'
25
+ import { TransportEvents } from '../events'
26
+ import { STREAM_SUFFIXES } from './constants'
27
+ import { isPortChannelEnabled } from './port-policy'
28
+
29
+ interface CacheEntry {
30
+ value: unknown
31
+ expiresAt?: number
32
+ }
33
+
34
+ interface PortEventSubscription {
35
+ refCount: number
36
+ handle: TransportPortHandle | null
37
+ cleanup: (() => void) | null
38
+ opening: Promise<TransportPortHandle | null> | null
39
+ closing: boolean
40
+ }
41
+
42
+ interface CacheConfig {
43
+ key?: string
44
+ mode: 'prefer' | 'only'
45
+ ttlMs?: number
46
+ }
47
+
48
+ function normalizeCacheOptions(options?: SendOptions): CacheConfig | null {
49
+ if (!options?.cache) {
50
+ return null
51
+ }
52
+
53
+ if (options.cache === true) {
54
+ return { mode: 'prefer' }
55
+ }
56
+
57
+ const mode = options.cache.mode ?? 'prefer'
58
+ return {
59
+ key: options.cache.key,
60
+ mode,
61
+ ttlMs: options.cache.ttlMs,
62
+ }
63
+ }
64
+
65
+ function buildCacheKey(eventName: string, payload: unknown, overrideKey?: string): string {
66
+ if (overrideKey) {
67
+ return overrideKey
68
+ }
69
+
70
+ if (payload === undefined) {
71
+ return `${eventName}:__void__`
72
+ }
73
+
74
+ try {
75
+ return `${eventName}:${JSON.stringify(payload)}`
76
+ }
77
+ catch {
78
+ return `${eventName}:${Object.prototype.toString.call(payload)}`
79
+ }
80
+ }
81
+
82
+
83
+ function resolveInvokeSender(): ((eventName: string, payload?: unknown) => Promise<unknown>) | null {
84
+ if (typeof globalThis === 'undefined') {
85
+ return null
86
+ }
87
+
88
+ const electron = (globalThis as any).electron ?? (globalThis as any).window?.electron
89
+ const invoke = electron?.ipcRenderer?.invoke
90
+ if (typeof invoke !== 'function') {
91
+ return null
92
+ }
93
+
94
+ return invoke.bind(electron.ipcRenderer)
95
+ }
96
+ interface IpcRendererLike {
97
+ on?: (channel: string, listener: (event: any, ...args: any[]) => void) => void
98
+ removeListener?: (channel: string, listener: (event: any, ...args: any[]) => void) => void
99
+ }
100
+
101
+ interface PortConfirmRecord {
102
+ port: MessagePort
103
+ payload: TransportPortConfirmPayload
104
+ }
105
+
106
+ const PORT_CONFIRM_TIMEOUT_MS = 10000
107
+ const STREAM_PORT_TIMEOUT_MS = 1500
108
+
109
+ function resolveIpcRenderer(): IpcRendererLike | null {
110
+ if (typeof globalThis === 'undefined') {
111
+ return null
112
+ }
113
+
114
+ const electron = (globalThis as any).electron ?? (globalThis as any).window?.electron
115
+ const ipcRenderer = electron?.ipcRenderer
116
+ if (!ipcRenderer) {
117
+ return null
118
+ }
119
+
120
+ return ipcRenderer as IpcRendererLike
121
+ }
122
+
123
+ interface BatchEntry<TRes> {
124
+ key: string
125
+ payload: unknown
126
+ resolvers: Array<{
127
+ resolve: (value: TRes) => void
128
+ reject: (error: unknown) => void
129
+ }>
130
+ }
131
+
132
+ interface BatchQueue<TRes> {
133
+ timer: ReturnType<typeof setTimeout> | null
134
+ mergeStrategy: 'queue' | 'dedupe' | 'latest'
135
+ windowMs: number
136
+ maxSize: number
137
+ queue: BatchEntry<TRes>[]
138
+ dedupe: Map<string, BatchEntry<TRes>>
139
+ latest: BatchEntry<TRes> | null
140
+ }
141
+
142
+ /**
143
+ * Renderer-side transport implementation.
144
+ * Adapts the legacy TouchChannel to the new TuffTransport interface.
145
+ */
146
+ export class TuffRendererTransport implements ITuffTransport {
147
+ private _channel: ReturnType<typeof useChannel> | null = null
148
+ private invokeSender: ((eventName: string, payload?: unknown) => Promise<unknown>) | null = null
149
+ private cache = new Map<string, CacheEntry>()
150
+ private handlers = new Map<string, Set<(payload: any) => any>>()
151
+ private streamControllers = new Map<string, StreamController>()
152
+ private batchQueues = new Map<string, BatchQueue<any>>()
153
+ private portCache = new Map<string, TransportPortHandle>()
154
+ private portEventSubscriptions = new Map<string, PortEventSubscription>()
155
+ private portHandlesById = new Map<string, TransportPortHandle>()
156
+ private pendingPortConfirms = new Map<string, { resolve: (record: PortConfirmRecord) => void, timeout?: ReturnType<typeof setTimeout> }>()
157
+ private queuedPortConfirms = new Map<string, PortConfirmRecord>()
158
+ private abandonedPorts = new Set<string>()
159
+ private portListenerCleanup: (() => void) | null = null
160
+
161
+ /**
162
+ * Get the channel instance, initializing it lazily on first access.
163
+ * This ensures TouchChannel is available when accessed.
164
+ */
165
+ private get channel(): ReturnType<typeof useChannel> {
166
+ if (!this._channel) {
167
+ this._channel = useChannel()
168
+ }
169
+ return this._channel
170
+ }
171
+
172
+ private getInvokeSender(): ((eventName: string, payload?: unknown) => Promise<unknown>) | null {
173
+ if (!this.invokeSender) {
174
+ this.invokeSender = resolveInvokeSender()
175
+ }
176
+ return this.invokeSender
177
+ }
178
+
179
+ private readCache<T>(cacheKey: string): { hit: boolean, value?: T } {
180
+ const entry = this.cache.get(cacheKey)
181
+ if (!entry) {
182
+ return { hit: false }
183
+ }
184
+ if (entry.expiresAt !== undefined && entry.expiresAt <= Date.now()) {
185
+ this.cache.delete(cacheKey)
186
+ return { hit: false }
187
+ }
188
+ return { hit: true, value: entry.value as T }
189
+ }
190
+
191
+ private writeCache(cacheKey: string, value: unknown, ttlMs?: number): void {
192
+ const expiresAt = typeof ttlMs === 'number' && Number.isFinite(ttlMs)
193
+ ? Date.now() + Math.max(0, ttlMs)
194
+ : undefined
195
+ this.cache.set(cacheKey, { value, expiresAt })
196
+ }
197
+
198
+ private async sendRaw<TReq, TRes>(eventName: string, payload?: TReq | void): Promise<TRes> {
199
+ const invoke = this.getInvokeSender()
200
+ if (invoke) {
201
+ if (payload !== undefined) {
202
+ return await invoke(eventName, payload) as TRes
203
+ }
204
+ return await invoke(eventName) as TRes
205
+ }
206
+
207
+ const shouldPassPayload = payload !== undefined
208
+ return await this.channel.send(eventName, shouldPassPayload ? payload : undefined)
209
+ }
210
+
211
+ private unwrapChannelPayload<T>(data: unknown): T {
212
+ if (!data || typeof data !== 'object')
213
+ return data as T
214
+
215
+ const record = data as Record<string, unknown>
216
+ if ('data' in record && 'header' in record)
217
+ return record.data as T
218
+
219
+ return data as T
220
+ }
221
+
222
+ /**
223
+ * Sends a request and waits for response.
224
+ */
225
+ async send<TReq, TRes>(
226
+ event: TuffEvent<TReq, TRes>,
227
+ payload: TReq,
228
+ options?: SendOptions,
229
+ ): Promise<TRes>
230
+ async send<TRes>(
231
+ event: TuffEvent<void, TRes>,
232
+ payload?: void,
233
+ options?: SendOptions,
234
+ ): Promise<TRes>
235
+ async send<TReq, TRes>(
236
+ event: TuffEvent<TReq, TRes> | TuffEvent<void, TRes>,
237
+ payload?: TReq | void,
238
+ options?: SendOptions,
239
+ ): Promise<TRes> {
240
+ assertTuffEvent(event, 'TuffRendererTransport.send')
241
+
242
+ const eventName = event.toEventName()
243
+ const cacheConfig = normalizeCacheOptions(options)
244
+ const cacheKey = cacheConfig ? buildCacheKey(eventName, payload, cacheConfig.key) : ''
245
+ if (cacheConfig) {
246
+ const cached = this.readCache<TRes>(cacheKey)
247
+ if (cached.hit) {
248
+ return cached.value as TRes
249
+ }
250
+ if (cacheConfig.mode === 'only') {
251
+ throw new Error(`[TuffTransport] Cache miss for "${eventName}"`)
252
+ }
253
+ }
254
+
255
+ const isImmediate = options?.immediate === true || Boolean(cacheConfig)
256
+
257
+ const batch = event._batch
258
+ if (!isImmediate && batch?.enabled === true) {
259
+ return this.enqueueBatch(eventName, batch, payload)
260
+ }
261
+
262
+ try {
263
+ const shouldPassPayload = payload !== undefined
264
+ const result = await this.sendRaw<TReq, TRes>(eventName, shouldPassPayload ? payload : undefined)
265
+ if (cacheConfig) {
266
+ this.writeCache(cacheKey, result, cacheConfig.ttlMs)
267
+ }
268
+ return result
269
+ }
270
+ catch (error) {
271
+ const errorMessage = error instanceof Error ? error.message : String(error)
272
+ if (isCloneError(error)) {
273
+ const issue = findCloneIssue(payload)
274
+ const summary = summarizeClonePayload(payload)
275
+ console.error(`[TuffTransport] Payload not cloneable for "${eventName}"`, {
276
+ issue,
277
+ summary
278
+ })
279
+ }
280
+ throw new Error(`[TuffTransport] Failed to send \"${eventName}\": ${errorMessage}`)
281
+ }
282
+ }
283
+
284
+ private enqueueBatch<TReq, TRes>(
285
+ eventName: string,
286
+ batch: NonNullable<TuffEvent<TReq, TRes>['_batch']>,
287
+ payload?: TReq | void,
288
+ ): Promise<TRes> {
289
+ const windowMs = Math.max(0, Number(batch.windowMs ?? 50))
290
+ const maxSize = Math.max(1, Number(batch.maxSize ?? 50))
291
+ const mergeStrategy = (batch.mergeStrategy ?? 'queue') as BatchQueue<TRes>['mergeStrategy']
292
+
293
+ let queue = this.batchQueues.get(eventName) as BatchQueue<TRes> | undefined
294
+ if (!queue) {
295
+ queue = {
296
+ timer: null,
297
+ mergeStrategy,
298
+ windowMs,
299
+ maxSize,
300
+ queue: [],
301
+ dedupe: new Map(),
302
+ latest: null,
303
+ }
304
+ this.batchQueues.set(eventName, queue)
305
+ }
306
+ else {
307
+ queue.mergeStrategy = mergeStrategy
308
+ queue.windowMs = windowMs
309
+ queue.maxSize = maxSize
310
+ }
311
+
312
+ const key = this.buildBatchKey(payload)
313
+
314
+ const promise = new Promise<TRes>((resolve, reject) => {
315
+ const entryResolver = { resolve, reject }
316
+
317
+ if (queue!.mergeStrategy === 'latest') {
318
+ if (!queue!.latest) {
319
+ queue!.latest = { key: '__latest__', payload, resolvers: [entryResolver] }
320
+ }
321
+ else {
322
+ queue!.latest.payload = payload
323
+ queue!.latest.resolvers.push(entryResolver)
324
+ }
325
+ }
326
+ else if (queue!.mergeStrategy === 'dedupe') {
327
+ const existing = queue!.dedupe.get(key)
328
+ if (existing) {
329
+ existing.resolvers.push(entryResolver)
330
+ }
331
+ else {
332
+ queue!.dedupe.set(key, { key, payload, resolvers: [entryResolver] })
333
+ }
334
+ }
335
+ else {
336
+ queue!.queue.push({ key, payload, resolvers: [entryResolver] })
337
+ }
338
+
339
+ const size = queue!.mergeStrategy === 'latest'
340
+ ? (queue!.latest ? 1 : 0)
341
+ : queue!.mergeStrategy === 'dedupe'
342
+ ? queue!.dedupe.size
343
+ : queue!.queue.length
344
+
345
+ if (size >= queue!.maxSize) {
346
+ void this.flushEvent(eventName)
347
+ return
348
+ }
349
+
350
+ if (!queue!.timer) {
351
+ queue!.timer = setTimeout(() => {
352
+ queue!.timer = null
353
+ void this.flushEvent(eventName)
354
+ }, queue!.windowMs)
355
+ }
356
+ })
357
+
358
+ return promise
359
+ }
360
+
361
+ private buildBatchKey(payload: unknown): string {
362
+ if (payload === undefined)
363
+ return '__void__'
364
+ if (payload === null)
365
+ return '__null__'
366
+ if (typeof payload === 'string')
367
+ return `str:${payload}`
368
+ try {
369
+ return `json:${JSON.stringify(payload)}`
370
+ }
371
+ catch {
372
+ return `ref:${Object.prototype.toString.call(payload)}`
373
+ }
374
+ }
375
+
376
+ private async flushEvent(eventName: string): Promise<void> {
377
+ const queue = this.batchQueues.get(eventName)
378
+ if (!queue)
379
+ return
380
+
381
+ if (queue.timer) {
382
+ clearTimeout(queue.timer)
383
+ queue.timer = null
384
+ }
385
+
386
+ this.batchQueues.delete(eventName)
387
+
388
+ if (queue.mergeStrategy === 'latest') {
389
+ const entry = queue.latest
390
+ if (!entry)
391
+ return
392
+ await this.flushEntry(eventName, entry)
393
+ return
394
+ }
395
+
396
+ if (queue.mergeStrategy === 'dedupe') {
397
+ const entries = Array.from(queue.dedupe.values())
398
+ await Promise.all(entries.map(entry => this.flushEntry(eventName, entry)))
399
+ return
400
+ }
401
+
402
+ await queue.queue.reduce<Promise<void>>(
403
+ (promise, entry) => promise.then(() => this.flushEntry(eventName, entry)),
404
+ Promise.resolve(),
405
+ )
406
+ }
407
+
408
+ private formatPortErrorMessage(error?: unknown): string | null {
409
+ if (!error)
410
+ return null
411
+ const raw = error instanceof Error ? error.message : String(error)
412
+ if (!raw)
413
+ return null
414
+ const normalized = raw.replace(/\s+/g, ' ').trim()
415
+ if (!normalized)
416
+ return null
417
+ return normalized.length > 200 ? `${normalized.slice(0, 200)}...` : normalized
418
+ }
419
+
420
+ private logPortIssue(channel: string, reason: string, error?: unknown): void {
421
+ const channelName = channel.trim() || 'unknown'
422
+ const detail = this.formatPortErrorMessage(error)
423
+ const suffix = detail ? `: ${detail}` : ''
424
+ console.warn(`[TuffTransport] Port issue for "${channelName}": ${reason}${suffix}`)
425
+ }
426
+
427
+ private logPortFallback(channel: string, reason: string, error?: unknown): void {
428
+ const channelName = channel.trim() || 'unknown'
429
+ const detail = this.formatPortErrorMessage(error)
430
+ const suffix = detail ? `: ${detail}` : ''
431
+ console.warn(`[TuffTransport] Port fallback for "${channelName}": ${reason}${suffix}`)
432
+ }
433
+
434
+ private ensurePortListener(): boolean {
435
+ if (this.portListenerCleanup) {
436
+ return true
437
+ }
438
+
439
+ const ipcRenderer = resolveIpcRenderer()
440
+ if (!ipcRenderer?.on || !ipcRenderer.removeListener) {
441
+ return false
442
+ }
443
+
444
+ const eventName = TransportEvents.port.confirm.toEventName()
445
+ const handler = (event: any, payload: TransportPortConfirmPayload) => {
446
+ this.handlePortConfirm(event, payload)
447
+ }
448
+
449
+ ipcRenderer.on(eventName, handler)
450
+ this.portListenerCleanup = () => {
451
+ ipcRenderer.removeListener?.(eventName, handler)
452
+ }
453
+
454
+ return true
455
+ }
456
+
457
+ private handlePortConfirm(event: any, payload: TransportPortConfirmPayload): void {
458
+ const portId = payload?.portId
459
+ const channel = payload?.channel
460
+ const port = event?.ports?.[0] as MessagePort | undefined
461
+ if (!portId || !channel || !port) {
462
+ this.logPortIssue(channel ?? 'unknown', 'confirm_payload_invalid')
463
+ return
464
+ }
465
+
466
+ if (this.abandonedPorts.has(portId)) {
467
+ this.abandonedPorts.delete(portId)
468
+ try {
469
+ port.close()
470
+ }
471
+ catch {}
472
+ void this.send(TransportEvents.port.close, { channel, portId, reason: 'confirm_timeout' }).catch(() => {})
473
+ return
474
+ }
475
+
476
+ const record: PortConfirmRecord = { port, payload }
477
+ const pending = this.pendingPortConfirms.get(portId)
478
+ if (pending) {
479
+ this.pendingPortConfirms.delete(portId)
480
+ if (pending.timeout) {
481
+ clearTimeout(pending.timeout)
482
+ }
483
+ pending.resolve(record)
484
+ }
485
+ else {
486
+ this.queuedPortConfirms.set(portId, record)
487
+ }
488
+
489
+ void this.send(TransportEvents.port.confirm, payload).catch(() => {})
490
+ }
491
+
492
+ private async waitForPortConfirm(
493
+ portId: string,
494
+ channel: string,
495
+ timeoutMs: number,
496
+ ): Promise<PortConfirmRecord | null> {
497
+ const queued = this.queuedPortConfirms.get(portId)
498
+ if (queued) {
499
+ this.queuedPortConfirms.delete(portId)
500
+ return queued
501
+ }
502
+
503
+ if (timeoutMs <= 0) {
504
+ return null
505
+ }
506
+
507
+ return await new Promise((resolve) => {
508
+ const timeout = setTimeout(() => {
509
+ this.pendingPortConfirms.delete(portId)
510
+ this.abandonedPorts.add(portId)
511
+ void this.send(TransportEvents.port.close, { channel, portId, reason: 'confirm_timeout' }).catch(() => {})
512
+ resolve(null)
513
+ }, timeoutMs)
514
+
515
+ this.pendingPortConfirms.set(portId, { resolve, timeout })
516
+ })
517
+ }
518
+
519
+ private buildPortHandle(record: PortConfirmRecord, cache: boolean): TransportPortHandle {
520
+ const { port, payload } = record
521
+ const portId = payload.portId
522
+ const channel = payload.channel
523
+
524
+ let closing = false
525
+
526
+ const handle: TransportPortHandle = {
527
+ portId,
528
+ channel,
529
+ port,
530
+ close: async (reason?: string) => {
531
+ closing = true
532
+ this.evictPortHandle(portId, channel)
533
+ try {
534
+ port.close()
535
+ }
536
+ catch {}
537
+ await this.send(TransportEvents.port.close, {
538
+ channel,
539
+ portId,
540
+ reason,
541
+ }).catch(() => {})
542
+ },
543
+ }
544
+
545
+ this.portHandlesById.set(portId, handle)
546
+ if (cache) {
547
+ this.portCache.set(channel, handle)
548
+ }
549
+
550
+ const onClose = () => {
551
+ this.evictPortHandle(portId, channel)
552
+ if (!closing) {
553
+ this.logPortFallback(channel, 'port_closed')
554
+ }
555
+ }
556
+
557
+ const onMessageError = () => {
558
+ this.logPortFallback(channel, 'message_error')
559
+ this.evictPortHandle(portId, channel)
560
+ void this.send(TransportEvents.port.error, {
561
+ channel,
562
+ portId,
563
+ error: { code: 'message_error', message: 'MessagePort messageerror' },
564
+ }).catch(() => {})
565
+ }
566
+
567
+ if (typeof port.addEventListener === 'function') {
568
+ port.addEventListener('close', onClose)
569
+ port.addEventListener('messageerror', onMessageError)
570
+ }
571
+ if (typeof port.start === 'function') {
572
+ port.start()
573
+ }
574
+
575
+ return handle
576
+ }
577
+
578
+ private evictPortHandle(portId: string, channel: string): void {
579
+ this.portHandlesById.delete(portId)
580
+ const cached = this.portCache.get(channel)
581
+ if (cached?.portId === portId) {
582
+ this.portCache.delete(channel)
583
+ }
584
+ }
585
+
586
+ private normalizePortStreamMessage<TChunk>(raw: unknown): StreamMessage<TChunk> | null {
587
+ if (!raw || typeof raw !== 'object') {
588
+ return null
589
+ }
590
+
591
+ const record = raw as StreamMessage<TChunk> & TransportPortEnvelope<StreamMessage<TChunk>>
592
+ const rawType = (record as { type?: string }).type
593
+ const rawStreamId = (record as { streamId?: string | number }).streamId
594
+ if (!rawType || rawStreamId === undefined || rawStreamId === null) {
595
+ return null
596
+ }
597
+
598
+ const streamId = String(rawStreamId)
599
+
600
+ if (rawType === 'data') {
601
+ const chunk = (record as { chunk?: TChunk }).chunk ?? (record as { payload?: { chunk?: TChunk } }).payload?.chunk
602
+ return { type: 'data', streamId, chunk }
603
+ }
604
+
605
+ if (rawType === 'error') {
606
+ const errorRecord = record as { error?: { message?: string } | string, payload?: { error?: string } }
607
+ const errorMessage = typeof errorRecord.error === 'string'
608
+ ? errorRecord.error
609
+ : errorRecord.error?.message ?? errorRecord.payload?.error
610
+ return { type: 'error', streamId, error: errorMessage }
611
+ }
612
+
613
+ if (rawType === 'end' || rawType === 'close') {
614
+ return { type: 'end', streamId }
615
+ }
616
+
617
+ return null
618
+ }
619
+
620
+ private normalizePortEventMessage<TReq>(raw: unknown, channel: string): TReq | null {
621
+ if (!raw || typeof raw !== 'object') {
622
+ return null
623
+ }
624
+
625
+ const record = raw as TransportPortEnvelope<TReq> & { payload?: TReq }
626
+ if (record.channel && record.channel !== channel) {
627
+ return null
628
+ }
629
+
630
+ if (record.type && record.type !== 'data') {
631
+ if (record.type === 'error' && record.error?.message) {
632
+ this.logPortFallback(channel, 'message_error', record.error.message)
633
+ }
634
+ return null
635
+ }
636
+
637
+ if (record.payload !== undefined) {
638
+ return record.payload as TReq
639
+ }
640
+
641
+ return null
642
+ }
643
+
644
+ private dropPortEventSubscription(channel: string): void {
645
+ const subscription = this.portEventSubscriptions.get(channel)
646
+ if (!subscription)
647
+ return
648
+ subscription.cleanup?.()
649
+ subscription.cleanup = null
650
+ subscription.handle = null
651
+ subscription.opening = null
652
+ subscription.closing = true
653
+ this.portEventSubscriptions.delete(channel)
654
+ }
655
+
656
+ private ensurePortEventSubscription(channel: string): void {
657
+ if (!isPortChannelEnabled(channel)) {
658
+ return
659
+ }
660
+ const existing = this.portEventSubscriptions.get(channel)
661
+ if (existing) {
662
+ existing.refCount += 1
663
+ return
664
+ }
665
+
666
+ const subscription: PortEventSubscription = {
667
+ refCount: 1,
668
+ handle: null,
669
+ cleanup: null,
670
+ opening: null,
671
+ closing: false,
672
+ }
673
+
674
+ this.portEventSubscriptions.set(channel, subscription)
675
+
676
+ subscription.opening = this.openPort({ channel })
677
+ .then((handle) => {
678
+ if (!handle) {
679
+ this.logPortFallback(channel, 'port_unavailable')
680
+ this.dropPortEventSubscription(channel)
681
+ return null
682
+ }
683
+
684
+ if (subscription.closing) {
685
+ void handle.close('subscription_closed')
686
+ this.dropPortEventSubscription(channel)
687
+ return null
688
+ }
689
+
690
+ subscription.handle = handle
691
+ const port = handle.port
692
+
693
+ const messageHandler = (event: MessageEvent) => {
694
+ const payload = this.normalizePortEventMessage<any>(event?.data, channel)
695
+ if (payload === null)
696
+ return
697
+ const handlers = this.handlers.get(channel)
698
+ if (!handlers || handlers.size === 0)
699
+ return
700
+ handlers.forEach((handler) => {
701
+ Promise.resolve(handler(payload)).catch((error) => {
702
+ const errorMessage = error instanceof Error ? error.message : String(error)
703
+ console.error(`[TuffTransport] Handler error for \"${channel}\":`, errorMessage)
704
+ })
705
+ })
706
+ }
707
+
708
+ const closeHandler = () => {
709
+ this.logPortFallback(channel, 'port_closed')
710
+ this.dropPortEventSubscription(channel)
711
+ }
712
+
713
+ const errorHandler = () => {
714
+ this.logPortFallback(channel, 'message_error')
715
+ this.dropPortEventSubscription(channel)
716
+ }
717
+
718
+ if (typeof port.addEventListener === 'function') {
719
+ port.addEventListener('message', messageHandler)
720
+ port.addEventListener('messageerror', errorHandler)
721
+ port.addEventListener('close', closeHandler)
722
+ port.start?.()
723
+ subscription.cleanup = () => {
724
+ port.removeEventListener('message', messageHandler)
725
+ port.removeEventListener('messageerror', errorHandler)
726
+ port.removeEventListener('close', closeHandler)
727
+ }
728
+ }
729
+ else {
730
+ port.onmessage = messageHandler as any
731
+ subscription.cleanup = () => {
732
+ port.onmessage = null
733
+ }
734
+ }
735
+
736
+ return handle
737
+ })
738
+ .catch((error) => {
739
+ this.logPortFallback(channel, 'open_failed', error)
740
+ this.dropPortEventSubscription(channel)
741
+ return null
742
+ })
743
+ }
744
+
745
+ private releasePortEventSubscription(channel: string): void {
746
+ const subscription = this.portEventSubscriptions.get(channel)
747
+ if (!subscription)
748
+ return
749
+ subscription.refCount -= 1
750
+ if (subscription.refCount > 0)
751
+ return
752
+
753
+ subscription.closing = true
754
+ subscription.cleanup?.()
755
+ subscription.cleanup = null
756
+ if (subscription.handle) {
757
+ void subscription.handle.close('no_handlers')
758
+ }
759
+ this.portEventSubscriptions.delete(channel)
760
+ }
761
+
762
+ async upgrade(options: TransportPortUpgradeRequest): Promise<TransportPortUpgradeResponse> {
763
+ const channel = options?.channel?.trim()
764
+ if (!channel) {
765
+ return {
766
+ accepted: false,
767
+ channel: '',
768
+ error: { code: 'invalid_request', message: 'Channel is required' },
769
+ }
770
+ }
771
+
772
+ return await this.send(TransportEvents.port.upgrade, {
773
+ channel,
774
+ scope: options.scope,
775
+ windowId: options.windowId,
776
+ plugin: options.plugin,
777
+ permissions: options.permissions,
778
+ })
779
+ }
780
+
781
+ async openPort(options: TransportPortOpenOptions): Promise<TransportPortHandle | null> {
782
+ const channel = options?.channel?.trim()
783
+ if (!channel) {
784
+ this.logPortIssue('unknown', 'missing_channel')
785
+ return null
786
+ }
787
+
788
+ try {
789
+ const useCache = options.force !== true
790
+ if (useCache) {
791
+ const cached = this.portCache.get(channel)
792
+ if (cached) {
793
+ return cached
794
+ }
795
+ }
796
+
797
+ if (!this.ensurePortListener()) {
798
+ this.logPortIssue(channel, 'ipc_unavailable')
799
+ return null
800
+ }
801
+
802
+ const response = await this.upgrade({
803
+ channel,
804
+ scope: options.scope,
805
+ windowId: options.windowId,
806
+ plugin: options.plugin,
807
+ permissions: options.permissions,
808
+ })
809
+
810
+ if (!response.accepted || !response.portId) {
811
+ if (response.error) {
812
+ const reason = response.error.code ? `upgrade_rejected:${response.error.code}` : 'upgrade_rejected'
813
+ this.logPortIssue(channel, reason, response.error.message)
814
+ }
815
+ else {
816
+ this.logPortIssue(channel, 'upgrade_rejected')
817
+ }
818
+ return null
819
+ }
820
+
821
+ const timeoutMs = typeof options.timeoutMs === 'number' && Number.isFinite(options.timeoutMs)
822
+ ? Math.max(0, options.timeoutMs)
823
+ : PORT_CONFIRM_TIMEOUT_MS
824
+ const record = await this.waitForPortConfirm(response.portId, channel, timeoutMs)
825
+ if (!record) {
826
+ this.logPortIssue(channel, 'confirm_timeout')
827
+ return null
828
+ }
829
+
830
+ return this.buildPortHandle(record, useCache)
831
+ }
832
+ catch (error) {
833
+ this.logPortIssue(channel, 'open_failed', error)
834
+ return null
835
+ }
836
+ }
837
+
838
+ private async flushEntry<TRes>(eventName: string, entry: BatchEntry<TRes>): Promise<void> {
839
+ try {
840
+ const shouldPassPayload = entry.payload !== undefined
841
+ const result = await this.sendRaw(eventName, shouldPassPayload ? entry.payload : undefined)
842
+ for (const { resolve } of entry.resolvers) {
843
+ resolve(result as TRes)
844
+ }
845
+ }
846
+ catch (error) {
847
+ const errorMessage = error instanceof Error ? error.message : String(error)
848
+ const wrapped = new Error(`[TuffTransport] Failed to send \"${eventName}\": ${errorMessage}`)
849
+ for (const { reject } of entry.resolvers) {
850
+ reject(wrapped)
851
+ }
852
+ }
853
+ }
854
+
855
+ /**
856
+ * Initiates a stream request.
857
+ *
858
+ * @remarks
859
+ * Phase 1 implementation uses IPC events to simulate streaming.
860
+ * Future versions will use MessagePort for true streaming.
861
+ */
862
+ async stream<TReq, TChunk>(
863
+ event: TuffEvent<TReq, AsyncIterable<TChunk>>,
864
+ payload: TReq,
865
+ options: StreamOptions<TChunk>,
866
+ ): Promise<StreamController> {
867
+ assertTuffEvent(event, 'TuffRendererTransport.stream')
868
+
869
+ const eventName = event.toEventName()
870
+ const streamId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
871
+ const streamEventName = `${eventName}${STREAM_SUFFIXES.START}`
872
+
873
+ let cancelled = false
874
+ let cleaned = false
875
+ const cleanupCallbacks: Array<() => void> = []
876
+ const cleanup = () => {
877
+ if (cleaned)
878
+ return
879
+ cleaned = true
880
+ cleanupCallbacks.forEach(callback => callback())
881
+ this.streamControllers.delete(streamId)
882
+ }
883
+
884
+ const portOptions = options.port === false || !isPortChannelEnabled(eventName)
885
+ ? null
886
+ : {
887
+ channel: eventName,
888
+ ...options.port,
889
+ timeoutMs: options.port?.timeoutMs ?? STREAM_PORT_TIMEOUT_MS,
890
+ }
891
+
892
+ let portHandle: TransportPortHandle | null = null
893
+ let portActive = false
894
+
895
+ if (portOptions) {
896
+ try {
897
+ portHandle = await this.openPort(portOptions)
898
+ if (!portHandle) {
899
+ this.logPortFallback(eventName, 'port_unavailable')
900
+ }
901
+ }
902
+ catch (error) {
903
+ this.logPortFallback(eventName, 'open_failed', error)
904
+ }
905
+ }
906
+
907
+ const fallbackToChannel = (reason: string) => {
908
+ if (!portHandle)
909
+ return
910
+ if (portActive) {
911
+ portActive = false
912
+ }
913
+ this.logPortFallback(eventName, reason)
914
+ void portHandle.close(reason)
915
+ }
916
+
917
+ if (portHandle) {
918
+ const port = portHandle.port
919
+
920
+ const portMessageHandler = (event: MessageEvent) => {
921
+ if (cancelled)
922
+ return
923
+
924
+ const message = this.normalizePortStreamMessage<TChunk>(event?.data)
925
+ if (!message || message.streamId !== streamId) {
926
+ return
927
+ }
928
+
929
+ portActive = true
930
+
931
+ if (message.type === 'data' && message.chunk !== undefined) {
932
+ options.onData(message.chunk)
933
+ return
934
+ }
935
+
936
+ if (message.type === 'error') {
937
+ options.onError?.(new Error(message.error ?? 'Stream error'))
938
+ cleanup()
939
+ return
940
+ }
941
+
942
+ if (message.type === 'end') {
943
+ options.onEnd?.()
944
+ cleanup()
945
+ }
946
+ }
947
+
948
+ const portCloseHandler = () => {
949
+ if (cancelled)
950
+ return
951
+ fallbackToChannel('port_closed')
952
+ }
953
+
954
+ const portErrorHandler = () => {
955
+ if (cancelled)
956
+ return
957
+ fallbackToChannel('message_error')
958
+ }
959
+
960
+ if (typeof port.addEventListener === 'function') {
961
+ port.addEventListener('message', portMessageHandler)
962
+ port.addEventListener('messageerror', portErrorHandler)
963
+ port.addEventListener('close', portCloseHandler)
964
+ port.start?.()
965
+ cleanupCallbacks.push(() => {
966
+ port.removeEventListener('message', portMessageHandler)
967
+ port.removeEventListener('messageerror', portErrorHandler)
968
+ port.removeEventListener('close', portCloseHandler)
969
+ })
970
+ }
971
+ else {
972
+ port.onmessage = portMessageHandler as any
973
+ cleanupCallbacks.push(() => {
974
+ port.onmessage = null
975
+ })
976
+ }
977
+
978
+ cleanupCallbacks.push(() => {
979
+ if (portOptions?.force === true) {
980
+ void portHandle?.close('stream_cleanup')
981
+ }
982
+ })
983
+ }
984
+
985
+ // Register stream data handler (channel fallback)
986
+ if (this.channel.regChannel) {
987
+ const dataEventName = `${eventName}${STREAM_SUFFIXES.DATA}:${streamId}`
988
+ const endEventName = `${eventName}${STREAM_SUFFIXES.END}:${streamId}`
989
+ const errorEventName = `${eventName}${STREAM_SUFFIXES.ERROR}:${streamId}`
990
+
991
+ const dataHandler = (raw: unknown) => {
992
+ if (cancelled || portActive)
993
+ return
994
+
995
+ const data = this.unwrapChannelPayload<{ chunk?: TChunk, error?: string }>(raw)
996
+
997
+ if (data?.error) {
998
+ options.onError?.(new Error(data.error))
999
+ return
1000
+ }
1001
+
1002
+ if (data?.chunk !== undefined) {
1003
+ options.onData(data.chunk)
1004
+ }
1005
+ }
1006
+
1007
+ const endHandler = () => {
1008
+ if (cancelled || portActive)
1009
+ return
1010
+ options.onEnd?.()
1011
+ cleanup()
1012
+ }
1013
+
1014
+ const errorHandler = (raw: unknown) => {
1015
+ if (cancelled || portActive)
1016
+ return
1017
+ const data = this.unwrapChannelPayload<{ error: string }>(raw)
1018
+ options.onError?.(new Error(data?.error))
1019
+ cleanup()
1020
+ }
1021
+
1022
+ const dataCleanup = this.channel.regChannel(dataEventName, dataHandler)
1023
+ const endCleanup = this.channel.regChannel(endEventName, endHandler)
1024
+ const errorCleanup = this.channel.regChannel(errorEventName, errorHandler)
1025
+
1026
+ cleanupCallbacks.push(() => {
1027
+ dataCleanup()
1028
+ endCleanup()
1029
+ errorCleanup()
1030
+ })
1031
+ }
1032
+
1033
+ // Create controller
1034
+ const controller: StreamController = {
1035
+ cancel: () => {
1036
+ if (cancelled)
1037
+ return
1038
+ cancelled = true
1039
+ this.channel.send(`${eventName}${STREAM_SUFFIXES.CANCEL}`, { streamId }).catch(() => {
1040
+ // Ignore cancel errors
1041
+ })
1042
+ cleanup()
1043
+ },
1044
+ get cancelled() {
1045
+ return cancelled
1046
+ },
1047
+ streamId,
1048
+ }
1049
+
1050
+ this.streamControllers.set(streamId, controller)
1051
+
1052
+ // Start the stream
1053
+ try {
1054
+ // Handle void payload (undefined)
1055
+ const streamPayload = payload !== undefined
1056
+ ? { streamId, ...payload }
1057
+ : { streamId }
1058
+ await this.channel.send(streamEventName, streamPayload)
1059
+ }
1060
+ catch (error) {
1061
+ controller.cancel()
1062
+ const errorMessage = error instanceof Error ? error.message : String(error)
1063
+ throw new Error(
1064
+ `[TuffTransport] Failed to start stream "${eventName}": ${errorMessage}`,
1065
+ )
1066
+ }
1067
+
1068
+ return controller
1069
+ }
1070
+
1071
+ /**
1072
+ * Registers an event handler (for receiving messages from main process).
1073
+ */
1074
+ on<TReq, TRes>(
1075
+ event: TuffEvent<TReq, TRes>,
1076
+ handler: (payload: TReq) => TRes | Promise<TRes>,
1077
+ ): () => void {
1078
+ assertTuffEvent(event, 'TuffRendererTransport.on')
1079
+
1080
+ if (!this.channel.regChannel) {
1081
+ throw new Error(
1082
+ '[TuffTransport] Channel does not support event registration',
1083
+ )
1084
+ }
1085
+
1086
+ const eventName = event.toEventName()
1087
+ const handlerSet = this.handlers.get(eventName) || new Set()
1088
+ const isFirstHandler = handlerSet.size === 0
1089
+ handlerSet.add(handler)
1090
+ this.handlers.set(eventName, handlerSet)
1091
+ if (isFirstHandler) {
1092
+ this.ensurePortEventSubscription(eventName)
1093
+ }
1094
+
1095
+ // Register with channel
1096
+ const cleanup = this.channel.regChannel(eventName, async (data: TReq) => {
1097
+ try {
1098
+ return await handler(this.unwrapChannelPayload<TReq>(data))
1099
+ }
1100
+ catch (error) {
1101
+ const errorMessage = error instanceof Error ? error.message : String(error)
1102
+ console.error(`[TuffTransport] Handler error for "${eventName}":`, errorMessage)
1103
+ throw error
1104
+ }
1105
+ })
1106
+
1107
+ // Return combined cleanup
1108
+ return () => {
1109
+ handlerSet.delete(handler)
1110
+ if (handlerSet.size === 0) {
1111
+ this.handlers.delete(eventName)
1112
+ this.releasePortEventSubscription(eventName)
1113
+ }
1114
+ cleanup()
1115
+ }
1116
+ }
1117
+
1118
+ /**
1119
+ * Forces immediate flush of all pending batch requests.
1120
+ *
1121
+ * @remarks
1122
+ * Phase 1: No-op. Batching will be implemented in Phase 2.
1123
+ */
1124
+ async flush(): Promise<void> {
1125
+ const eventNames = Array.from(this.batchQueues.keys())
1126
+ await Promise.all(eventNames.map(eventName => this.flushEvent(eventName)))
1127
+ }
1128
+
1129
+ /**
1130
+ * Destroys the transport instance and cleans up resources.
1131
+ */
1132
+ destroy(): void {
1133
+ // Cancel all active streams
1134
+ for (const controller of this.streamControllers.values()) {
1135
+ controller.cancel()
1136
+ }
1137
+ this.streamControllers.clear()
1138
+
1139
+ // Clear all handlers
1140
+ this.handlers.clear()
1141
+
1142
+ if (this.portListenerCleanup) {
1143
+ this.portListenerCleanup()
1144
+ this.portListenerCleanup = null
1145
+ }
1146
+
1147
+ for (const subscription of this.portEventSubscriptions.values()) {
1148
+ subscription.cleanup?.()
1149
+ subscription.cleanup = null
1150
+ if (subscription.handle) {
1151
+ void subscription.handle.close('transport_destroy').catch(() => {})
1152
+ }
1153
+ }
1154
+ this.portEventSubscriptions.clear()
1155
+
1156
+ for (const handle of this.portHandlesById.values()) {
1157
+ void handle.close('transport_destroy').catch(() => {})
1158
+ }
1159
+ this.portHandlesById.clear()
1160
+ this.portCache.clear()
1161
+ this.pendingPortConfirms.clear()
1162
+ this.queuedPortConfirms.clear()
1163
+ this.abandonedPorts.clear()
1164
+ }
1165
+ }