aihand 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +136 -2
  2. package/dist/chunk-2NTK7H4W.js +10 -0
  3. package/dist/chunk-3X4FTHLC.cjs +369 -0
  4. package/dist/chunk-BXVNR4E2.js +399 -0
  5. package/dist/chunk-C7DGE6MY.cjs +1456 -0
  6. package/dist/chunk-DUUCVLC3.cjs +254 -0
  7. package/dist/chunk-FAHI53KO.cjs +125 -0
  8. package/dist/chunk-G7KVJ7NF.js +369 -0
  9. package/dist/chunk-GNEUSRGP.js +52 -0
  10. package/dist/chunk-IGNEAOLT.cjs +130 -0
  11. package/dist/chunk-IS5XFUDB.js +125 -0
  12. package/dist/chunk-JLYC76XL.js +2448 -0
  13. package/dist/chunk-KQOABC2O.cjs +52 -0
  14. package/dist/chunk-OVMK33AC.cjs +104 -0
  15. package/dist/chunk-OWYK2IGV.js +250 -0
  16. package/dist/chunk-PQSQN4CN.js +126 -0
  17. package/dist/chunk-QF6AG3M5.cjs +410 -0
  18. package/dist/chunk-QSAMLXML.js +1456 -0
  19. package/dist/chunk-VEKYRKPF.cjs +399 -0
  20. package/dist/chunk-Y6H7W7PI.cjs +2451 -0
  21. package/dist/chunk-YKSYW77R.js +410 -0
  22. package/dist/chunk-Z2Y65YOY.cjs +7 -0
  23. package/dist/chunk-ZJQRNIK7.js +104 -0
  24. package/dist/cli-FDS2C2CZ.cjs +651 -0
  25. package/dist/cli-HHRGYPSM.js +649 -0
  26. package/dist/cli-JQEIE7RQ.js +120 -0
  27. package/dist/cli-K3OS2QQH.cjs +122 -0
  28. package/dist/cli-OSYG6LJD.cjs +89 -0
  29. package/dist/cli-TXRW5PG6.js +89 -0
  30. package/dist/cli.cjs +81 -0
  31. package/dist/cli.js +81 -0
  32. package/dist/config-5KEQLN6L.cjs +13 -0
  33. package/dist/config-PJPYKDLQ.js +13 -0
  34. package/dist/graph-IH56SCPK.js +8 -0
  35. package/dist/graph-ZUXXCJ5A.cjs +8 -0
  36. package/dist/index.cjs +481 -0
  37. package/dist/index.d.cts +461 -0
  38. package/dist/index.d.ts +461 -0
  39. package/dist/index.js +479 -0
  40. package/dist/locate-5XFSXJ5J.cjs +15 -0
  41. package/dist/locate-NKSUGL3A.js +15 -0
  42. package/dist/refactor-5FWSZIBN.cjs +19 -0
  43. package/dist/refactor-BOB3SZSA.js +19 -0
  44. package/dist/scan-4R7GQG2W.cjs +9 -0
  45. package/dist/scan-VF54GAAX.js +9 -0
  46. package/dist/ui/probe/server.cjs +505 -0
  47. package/dist/ui/probe/server.js +507 -0
  48. package/dist/vite.cjs +12 -0
  49. package/dist/vite.d.cts +12 -0
  50. package/dist/vite.d.ts +12 -0
  51. package/dist/vite.js +12 -0
  52. package/package.json +82 -9
  53. package/src/cli.ts +107 -0
  54. package/src/index.ts +54 -0
  55. package/src/read/cli.ts +650 -0
  56. package/src/read/compact.ts +286 -0
  57. package/src/read/config.ts +62 -0
  58. package/src/read/graph.ts +182 -0
  59. package/src/read/index.ts +12 -0
  60. package/src/read/inject.ts +121 -0
  61. package/src/read/locate.ts +104 -0
  62. package/src/read/panel.ts +335 -0
  63. package/src/read/pipeline.ts +78 -0
  64. package/src/read/refactor.ts +576 -0
  65. package/src/read/render.ts +1118 -0
  66. package/src/read/scan.ts +61 -0
  67. package/src/read/seam.ts +0 -0
  68. package/src/read/security.ts +171 -0
  69. package/src/read/signals.ts +333 -0
  70. package/src/read/state.ts +71 -0
  71. package/src/read/stategraph.ts +205 -0
  72. package/src/read/types.ts +162 -0
  73. package/src/read/vite.ts +77 -0
  74. package/src/ui/babel/line-profiler.ts +197 -0
  75. package/src/ui/babel/source-loc.ts +68 -0
  76. package/src/ui/bridge/cdp-bridge.ts +138 -0
  77. package/src/ui/bridge/compile-probe.ts +80 -0
  78. package/src/ui/bridge/transport.ts +26 -0
  79. package/src/ui/bridge/vite-bridge.ts +116 -0
  80. package/src/ui/client/client-patch.ts +899 -0
  81. package/src/ui/client/client.ts +2562 -0
  82. package/src/ui/core/action.ts +747 -0
  83. package/src/ui/core/candidates.ts +348 -0
  84. package/src/ui/core/canvas.ts +305 -0
  85. package/src/ui/core/check.ts +34 -0
  86. package/src/ui/core/compact.ts +314 -0
  87. package/src/ui/core/detail.ts +244 -0
  88. package/src/ui/core/diff.ts +253 -0
  89. package/src/ui/core/emit.ts +198 -0
  90. package/src/ui/core/knob-exec.ts +137 -0
  91. package/src/ui/core/perf.ts +254 -0
  92. package/src/ui/core/types.ts +164 -0
  93. package/src/ui/core/util.ts +221 -0
  94. package/src/ui/index.ts +5 -0
  95. package/src/ui/probe/cli.ts +139 -0
  96. package/src/ui/probe/server.ts +468 -0
  97. package/src/ui/self/act.ts +47 -0
  98. package/src/ui/self/discover.ts +101 -0
  99. package/src/ui/self/grow.ts +121 -0
  100. package/src/ui/self/install.ts +100 -0
  101. package/src/ui/self/probe.ts +105 -0
  102. package/src/ui/self/screen-hook.ts +44 -0
  103. package/src/ui/self/self.ts +48 -0
  104. package/src/ui/self/store-refs.ts +123 -0
  105. package/src/ui/self/store-schema.ts +65 -0
  106. package/src/ui/self/synth.ts +37 -0
  107. package/src/ui/server/cli.ts +102 -0
  108. package/src/ui/server/dispatch.ts +276 -0
  109. package/src/ui/server/help-text.ts +237 -0
  110. package/src/ui/server/knob-schema.ts +87 -0
  111. package/src/ui/server/plugin.ts +1151 -0
  112. package/src/vite.ts +39 -0
  113. package/index.js +0 -2
@@ -0,0 +1,68 @@
1
+ // Source NodePath + types from @babel/core so they share one module identity. Mixing
2
+ // @babel/traverse's NodePath with a standalone @babel/types import pins them to
3
+ // different @babel/types copies when versions resolve (JSX node variance error).
4
+ import type { NodePath, PluginObj, types as BabelTypes } from '@babel/core'
5
+ import path from 'path'
6
+
7
+ type JSXOpeningElement = BabelTypes.JSXOpeningElement
8
+
9
+ // Babel plugin: stamp every host JSX element with a `data-insp-path` source-location
10
+ // attribute — the same attribute aihand's client already consumes (client.ts inspPath /
11
+ // scopeRoot) to print `@File.tsx:line` and to power `/dom?scope=`. aihand now produces the
12
+ // marker it reads, so one plugin covers both click-to-source (the overlay in client.ts) and
13
+ // the AI's source-aware DOM — no separate source-location plugin needed.
14
+ //
15
+ // Format is "relative/path.tsx:line:col:Name". aihand only reads
16
+ // segs[0] (file) + segs[1] (line); the trailing name is the file basename, informational only.
17
+ //
18
+ // Only HOST elements (lowercase tag → a real DOM node) get the attribute — a component
19
+ // element (<Foo>) renders to its own host children, which carry their own marks. Fragments
20
+ // and elements that already declare data-insp-path are skipped.
21
+ //
22
+ // Takes the babel `types` builder as a param (the standard plugin-API form) instead of a
23
+ // static `import * as t from '@babel/types'`. That keeps this module free of any runtime
24
+ // babel import, so plugin.ts (which IS bundled into dist) can import it without pulling
25
+ // @babel/types into both output formats and breaking the dual-format contract.
26
+ export function sourceLocPlugin(t: typeof BabelTypes): PluginObj {
27
+ return {
28
+ name: 'aihand-source-loc',
29
+ visitor: {
30
+ JSXOpeningElement(nodePath: NodePath<JSXOpeningElement>, state) {
31
+ const filename: string = state.file?.opts?.filename ?? ''
32
+ if (!filename || filename.includes('node_modules'))
33
+ return
34
+ if (filename.includes('packages/aidev/'))
35
+ return
36
+
37
+ const nameNode = nodePath.node.name
38
+ // host element only: <div>, <span> — a lowercase JSXIdentifier. Skip <Foo>,
39
+ // <Foo.Bar> (member expression), and namespaced names.
40
+ if (!t.isJSXIdentifier(nameNode))
41
+ return
42
+ const tag = nameNode.name
43
+ if (tag[0] === tag[0].toUpperCase())
44
+ return
45
+
46
+ if (hasInspAttr(t, nodePath.node))
47
+ return
48
+
49
+ const loc = nodePath.node.loc
50
+ if (!loc)
51
+ return
52
+
53
+ const root: string = state.file?.opts?.root ?? ''
54
+ const rel = root ? path.relative(root, filename) : filename
55
+ const value = `${rel}:${loc.start.line}:${loc.start.column + 1}:${path.basename(rel, path.extname(rel))}`
56
+
57
+ nodePath.node.attributes.push(
58
+ t.jsxAttribute(t.jsxIdentifier('data-insp-path'), t.stringLiteral(value)),
59
+ )
60
+ },
61
+ },
62
+ }
63
+ }
64
+
65
+ function hasInspAttr(t: typeof BabelTypes, node: JSXOpeningElement): boolean {
66
+ return node.attributes.some(a =>
67
+ t.isJSXAttribute(a) && t.isJSXIdentifier(a.name) && a.name.name === 'data-insp-path')
68
+ }
@@ -0,0 +1,138 @@
1
+ import type { ServerBridge } from './transport'
2
+
3
+ // 出口2 桥:把一个 playwright Page 包成 ServerBridge。与 viteBridge 同构,但单页直连——
4
+ // 没有多 tab、没有 requireVisible、没有 twoPhase 两段投递。是 twoPhase 的退化版「evaluate 派发 + 超时」。
5
+ //
6
+ // server→browser 发请求 = page.evaluate(window.__aihandRecv, reqEvent, {id,...})
7
+ // browser→server 回回复 = 探针 window.__aihandSend(replyEvent, {id,...}),由 page.exposeBinding
8
+ // 注册的 binding 收,按 event 分发到等同一 replyEvent + id 的 request。
9
+ //
10
+ // exposeBinding('__aihandSend') 必须在 addInitScript(探针) 之前注册(见 probe/server.ts)。
11
+
12
+ // playwright Page 的最小依赖面——只用 evaluate(传 [event,payload] 元组进页面),不引整个
13
+ // playwright 类型(出口2 独占)。exposeBinding 接线在 probe/server.ts 做,bridge 不碰。
14
+ // settle:整页导航把旧文档执行上下文销毁,evaluate 撞 "Execution context was destroyed";
15
+ // bridge 调 settle()(server 注入 = waitForLoadState)等新文档就绪后重试一次。
16
+ export interface ProbePage {
17
+ evaluate: (fn: (a: [string, object]) => unknown, arg: [string, object]) => Promise<unknown>
18
+ settle?: () => Promise<void>
19
+ // 主帧「导航意图」订阅(出口2 由 CDP Page.frameRequestedNavigation 接线,出口1 不实现 → 恒不 fire)。
20
+ // 实测:锚点 click 的 reply 在 ~62ms 先回(探针 handler 已跑完),framenavigated(commit)要等
21
+ // ~1500ms 网络往返才到——太晚抓不住。frameRequestedNavigation(intent)在 click handler 同步设
22
+ // location 时即 fire(~61.7ms,先于 reply),带目标 URL。这是「动作触发整页导航」唯一与动作同源、
23
+ // 先于 reply 的地面真值。竞速语义:fire 既置 navigated 标记又 settle request(合成 {ok:true})——
24
+ // 导航场景 reply 从被销毁的旧文档发出必丢,不竞速就死等超时(见 request)。request 飞行期订阅一次,
25
+ // settle 时退订;reply 先到则退订本闩,迟到 intent 落空。返回退订 fn。
26
+ onMainFrameNavIntent?: (cb: () => void) => () => void
27
+ // 主帧「同文档路由切换」订阅(SPA pushState:Turbo/React Router,URL 变但文档不销毁)。与上面的
28
+ // 整页导航闩对偶——整页导航销毁上下文让 reply 丢失故能竞速,同文档导航上下文存活、reply 快速带残缺
29
+ // screen 先回赢竞速,真正的换页 morph 在 reply 后 ~0.2~1.4s 才到(CDP Page.navigatedWithinDocument)。
30
+ // 故这个信号必须 outlive reply:不接进 request 竞速,由 dispatch 在动作前 arm、reply 后等(见
31
+ // NavProbe.awaitSameDocNav)。回调仅在主帧 ∧ URL path 真变(非纯 #hash 锚点滚动)时触发,实测对
32
+ // 普通非导航 click 零误报。出口1 不实现 → 恒不 fire,零回归。返回退订 fn。
33
+ onMainFrameSameDocNav?: (cb: () => void) => () => void
34
+ }
35
+
36
+ type Waiter = { id?: number, resolve: (v: any) => void }
37
+
38
+ // 整页导航瞬态:旧文档上下文销毁 / 页面正导航中。撞上就 settle + 重试一次,不是真失败。
39
+ function isNavTransient(err: unknown): boolean {
40
+ const m = (err as Error)?.message ?? ''
41
+ return m.includes('Execution context was destroyed') || m.includes('because of a navigation')
42
+ }
43
+
44
+ // evaluate 派发,撞导航瞬态(派发撞上一个正在 settle 的页面)则 settle(等新文档) + 重试一次。
45
+ // 一次足够:settle 后探针随 init script 已在新文档重注入,第二次必落在稳定上下文。
46
+ // 此处 onNav 是次要信号(派发本身撞销毁);「动作触发整页导航」的主信号走 request 的 onceMainFrameNav
47
+ // 闩——因为派发消息的那次 evaluate 干净返回(消息已落地),才轮到导航销毁上下文,这里不会抛。
48
+ async function evalWithSettle(page: ProbePage, arg: [string, object], onNav?: () => void): Promise<void> {
49
+ const fn = ([e, p]: [string, object]) => (window as { __aihandRecv?: (e: string, p: unknown) => void }).__aihandRecv?.(e, p)
50
+ try {
51
+ await page.evaluate(fn, arg)
52
+ }
53
+ catch (err) {
54
+ if (!isNavTransient(err) || !page.settle)
55
+ throw err
56
+ onNav?.()
57
+ await page.settle()
58
+ await page.evaluate(fn, arg)
59
+ }
60
+ }
61
+
62
+ // page 与 exposeBinding 的接线在 probe/server.ts 启动时做一次;这里只需 page + 一个
63
+ // 共享的 waiters 表(server 把 __aihandSend 收到的回复喂进 deliver)。
64
+ export function cdpBridge(page: ProbePage): { bridge: ServerBridge, deliver: (event: string, payload: { id?: number, [k: string]: unknown }) => void } {
65
+ // replyEvent → 等待者集合。一次 request 装一个 waiter,deliver 命中即 resolve + 摘除。
66
+ const waiters = new Map<string, Set<Waiter>>()
67
+
68
+ const deliver = (event: string, payload: { id?: number, [k: string]: unknown }) => {
69
+ const set = waiters.get(event)
70
+ if (!set)
71
+ return
72
+ for (const w of set) {
73
+ if (w.id !== undefined && payload.id !== w.id)
74
+ continue
75
+ set.delete(w)
76
+ w.resolve(payload)
77
+ return
78
+ }
79
+ }
80
+
81
+ const request = <T>(
82
+ reqEvent: string,
83
+ replyEvent: string,
84
+ payload: object,
85
+ opts?: { timeoutMs?: number, onNav?: () => void },
86
+ ): Promise<T> => {
87
+ const fullMs = opts?.timeoutMs ?? 3000
88
+ const id = (payload as { id?: number }).id
89
+ return new Promise<T>((resolve, reject) => {
90
+ const waiter: Waiter = { id, resolve }
91
+ let set = waiters.get(replyEvent)
92
+ if (!set) {
93
+ set = new Set()
94
+ waiters.set(replyEvent, set)
95
+ }
96
+ set.add(waiter)
97
+ // reply / 超时 / 派发异常 汇进一个幂等 finish:恰好一个 settle,全路径 clearTimeout + 摘
98
+ // waiter + 退订导航意图闩,无定时器/监听器泄漏。导航不参与 settle(见下,锁存非竞速)。
99
+ let unsubNav: (() => void) | undefined
100
+ let done = false
101
+ const finish = (fn: () => void) => {
102
+ if (done)
103
+ return
104
+ done = true
105
+ clearTimeout(timer)
106
+ set!.delete(waiter)
107
+ unsubNav?.()
108
+ fn()
109
+ }
110
+ const timer = setTimeout(() => finish(() => reject(new Error(`probe: no reply on ${replyEvent} within ${fullMs}ms`))), fullMs)
111
+ waiter.resolve = v => finish(() => resolve(v))
112
+ // 导航意图闩:三方竞速(reply / 导航意图 / 超时),谁先到谁 settle。动作触发整页导航时,
113
+ // 探针的 reply 从旧文档发出而上下文已被导航销毁 → reply 必丢。若闩只置位不 settle,request
114
+ // 就死等丢失的 reply 到 5s 超时(实测 example→iana 5027ms 假超时,而页面其实已导航成功)。
115
+ // 故 intent fire 既调 onNav 置位 navigated、又合成 {ok:true} 走 finish settle:reply 丢了也不死等,
116
+ // dispatch 读 result.ok && navigated → renavScreen 回填新页全貌。reply 先到则透传真 reply 并退订
117
+ // 本闩(intent 后到落空,done 已 true);无导航则 intent 永不 fire,零延迟。合成体仅 {ok:true} 不带
118
+ // detail/screen——renavScreen 的新页全貌已是 changed,加 detail 会与 navigated 行语义重复。
119
+ // 仅 opts.onNav 在时武装(只 dispatchAction 要 nav-backfill;collect-screen/emit/renavScreen
120
+ // 内部采帧都不传 → 不武装,纯 reply 等待,新文档 settle 期采帧不被误合成)。
121
+ unsubNav = opts?.onNav
122
+ ? page.onMainFrameNavIntent?.(() => finish(() => { opts.onNav!(); resolve({ ok: true } as T) }))
123
+ : undefined
124
+ // 派发进页面:探针的 onMsg(reqEvent) handler 收。撞整页导航瞬态(派发撞已在 settle 的页面)
125
+ // 则 settle + 重试一次,并经 onNav 把导航信号交给调用方(与意图闩幂等双置无害)。
126
+ evalWithSettle(page, [reqEvent, payload], opts?.onNav).catch(err => finish(() => reject(err)))
127
+ })
128
+ }
129
+
130
+ const emit = (event: string, payload: object) => {
131
+ page.evaluate(
132
+ ([e, p]: [string, object]) => (window as { __aihandRecv?: (e: string, p: unknown) => void }).__aihandRecv?.(e, p),
133
+ [event, payload],
134
+ ).catch(() => {})
135
+ }
136
+
137
+ return { bridge: { request, emit }, deliver }
138
+ }
@@ -0,0 +1,80 @@
1
+ import { resolve } from 'node:path'
2
+ import { existsSync } from 'node:fs'
3
+ import { buildSync } from 'esbuild'
4
+
5
+ // 出口2 探针字符串:把 client.ts 的 installProbe(连 core/* 一起)bundle 成自包含 IIFE,
6
+ // 注入任意外部站点。对偶出口1(Vite dev server 内联随模块图加载)——出口2 没有 Vite,
7
+ // 没有 HMR,没有 virtual:aihand-knobs,所以:
8
+ // · define import.meta.hot = undefined → client.ts 末尾的 Vite shim(if(import.meta.hot){…})
9
+ // 整段 dead-code 消除(hello 报到、HMR 自愈、accept 字面量、virtual import 全不进 bundle)。
10
+ // · bundle 入口 = 一段 stdin 合成代码,import { installProbe } 后拼 CDP transport bootstrap:
11
+ // send → window.__aihandSend(event, payload)(cdpBridge 的 exposeBinding 收)
12
+ // onMsg → 注册进本地 handler map,window.__aihandRecv(event,payload) 由 server 经
13
+ // page.evaluate 调进来分发。
14
+ // · knobSem 留空——外部站无 app schema,/screen knobs 退回纯 label+坐标(行为不变)。
15
+ // compilePatch(plugin.ts)是 transformSync 单文件;这里必须 buildSync bundle(client.ts 引 core/*)。
16
+
17
+ // client.ts 源码位置随运行形态变:dev(jiti 跑 src,本文件在 src/ui/bridge)= ../client;
18
+ // built(tsup 把本文件打进 dist/ui/probe/server.js)__dirname=dist/ui/probe,而源码经 package.json
19
+ // files:["dist","src"] 一并发布在 <root>/src/ui/client。从 __dirname 向上逐层找 src/ui/client/client.ts。
20
+ function findClientDir(): string {
21
+ const local = [resolve(__dirname, '../client'), resolve(__dirname, 'client')]
22
+ for (const d of local) {
23
+ if (existsSync(resolve(d, 'client.ts')))
24
+ return d
25
+ }
26
+ // 向上找含 src/ui/client/client.ts 的祖先(dist 与 src 同处 package root)。
27
+ let dir = __dirname
28
+ for (let i = 0; i < 8; i++) {
29
+ const cand = resolve(dir, 'src/ui/client')
30
+ if (existsSync(resolve(cand, 'client.ts')))
31
+ return cand
32
+ const parent = resolve(dir, '..')
33
+ if (parent === dir)
34
+ break
35
+ dir = parent
36
+ }
37
+ return resolve(__dirname, '../client')
38
+ }
39
+ const clientDir = findClientDir()
40
+ const clientPath = resolve(clientDir, 'client.ts')
41
+
42
+ const BOOTSTRAP = `
43
+ import { installProbe } from ${JSON.stringify(clientPath)}
44
+ const handlers = new Map()
45
+ const transport = {
46
+ send: (event, payload) => window.__aihandSend(event, payload),
47
+ onMsg: (event, handler) => {
48
+ let list = handlers.get(event)
49
+ if (!list) { list = new Set(); handlers.set(event, list) }
50
+ list.add(handler)
51
+ return () => list.delete(handler)
52
+ },
53
+ }
54
+ window.__aihandRecv = (event, payload) => {
55
+ const list = handlers.get(event)
56
+ if (list) for (const h of list) h(payload)
57
+ }
58
+ installProbe(transport)
59
+ `
60
+
61
+ let cached: string | undefined
62
+
63
+ export function compileProbe(): string {
64
+ if (cached !== undefined)
65
+ return cached
66
+ const result = buildSync({
67
+ stdin: { contents: BOOTSTRAP, resolveDir: clientDir, loader: 'ts' },
68
+ bundle: true,
69
+ format: 'iife',
70
+ target: 'es2020',
71
+ define: { 'import.meta.hot': 'undefined' },
72
+ // minifySyntax 开 DCE/常量折叠(不改名,bundle 仍可读):没它 define 把 import.meta.hot
73
+ // 换成 undefined 后 `if (undefined){…}` 仍原样留在 bundle——Vite shim(HMR 自愈、accept、
74
+ // hello)全被打进外部站点。开了才把整段 if(undefined) 死代码消除。
75
+ minifySyntax: true,
76
+ write: false,
77
+ })
78
+ cached = result.outputFiles[0].text
79
+ return cached
80
+ }
@@ -0,0 +1,26 @@
1
+ // 内核与出口之间的唯一耦合面。两层,只为已确定的两个 transport 抽,不预造第三个:
2
+ // Transport —— 探针端(浏览器内)持有,双向消息的最小公约数,fire-and-forget,id 匹配不在这层。
3
+ // ServerBridge —— server 端持有,发一个 event 等一个 id 匹配的回复。
4
+ //
5
+ // 出口1(Vite)与出口2(CDP)是同一内核的两个适配器:viteBridge 把回复经 HMR WebSocket 广播
6
+ // 按 id 收敛,cdpBridge 把回复经 page.exposeBinding 单页直连按 id 收敛——两者实现 ServerBridge,
7
+ // dispatch 对二者无感知。
8
+
9
+ export interface Transport {
10
+ send: (event: string, payload: unknown) => void
11
+ onMsg: (event: string, handler: (payload: any) => void) => () => void // 返回 off()
12
+ }
13
+
14
+ export interface ServerBridge {
15
+ // 发 reqEvent(带 server 生成的 id),等探针回 replyEvent 中 id 匹配的那条。
16
+ request: <T>(reqEvent: string, replyEvent: string, payload: object, opts?: {
17
+ tab?: string
18
+ timeoutMs?: number
19
+ lastProgress?: () => number | undefined
20
+ // 动作派发撞整页导航(旧文档上下文销毁)时调一次——出口2 的导航信号副产物。
21
+ // 出口1(Vite)SPA 路由无整页导航销毁,viteBridge 永不调,navigated 恒 false。
22
+ onNav?: () => void
23
+ }) => Promise<T>
24
+ // 单向广播,不等回复(perf-reset 等)。
25
+ emit: (event: string, payload: object) => void
26
+ }
@@ -0,0 +1,116 @@
1
+ import type { ViteDevServer } from 'vite'
2
+ import type { TabInfo } from '../core/types'
3
+ import type { ServerBridge } from './transport'
4
+ import { isLive } from '../core/util'
5
+
6
+ // 出口1 桥:把 Vite HMR WebSocket 包成 ServerBridge。dispatch 对它无感知,只调 request/emit。
7
+ //
8
+ // twoPhase 双段投递逐字搬自 plugin.ts(行为不变):
9
+ // 有 tab(?tab= 寻址)→ 跳过 requireVisible 段,只那个 tab 答;live 静默过 fullMs = 真 miss 快拒,
10
+ // absent(server 刚重启/页面自愈中)→ 每 RETRY_MS 重投到 re-register,封顶 ABSENT_CEILING。
11
+ // 无 tab → 先 requireVisible 段(只前台 tab 答),VISIBLE_MS 内没人答 → 第二段问所有人。
12
+ // 回复匹配:有 id 的(action/eval/semantic/knob)按 id 收;无 id 的(collect/dom/screen/state-path)
13
+ // 收第一条匹配 replyEvent 的回复(原 plugin.ts 单 pending 槽 last-write-wins 的等价收敛)。
14
+ // 每次 request 装一次性 server.hot.on(replyEvent) listener,settle 即自摘,不再散落常驻 listener + 命名槽。
15
+
16
+ const VISIBLE_MS = 400
17
+
18
+ export interface ViteBridgeDeps {
19
+ tabs: Map<string, TabInfo>
20
+ diagnose: (tab?: string) => string
21
+ }
22
+
23
+ export function viteBridge(server: ViteDevServer, deps: ViteBridgeDeps): ServerBridge {
24
+ // 异步流(发消息→流式→结束)合法地把动作开很久,客户端每帧 ping aihand:action-progress;
25
+ // 在此盖最新时戳,twoPhase 的 live-tab deadline 从最后生命迹象起算,而非派发起算——真挂的
26
+ // handler 仍会(无 ping → deadline 触发)拒。progress 收敛进桥,dispatch 无需手传 lastProgress。
27
+ const progressAt = new Map<number, number>()
28
+ server.hot.on('aihand:action-progress', (data: { id?: number }) => {
29
+ if (typeof data.id === 'number')
30
+ progressAt.set(data.id, Date.now())
31
+ })
32
+
33
+ const request = <T>(
34
+ reqEvent: string,
35
+ replyEvent: string,
36
+ payload: object,
37
+ opts?: { tab?: string, timeoutMs?: number, lastProgress?: () => number | undefined },
38
+ ): Promise<T> => {
39
+ const tab = opts?.tab
40
+ const fullMs = opts?.timeoutMs ?? 3000
41
+ const id = (payload as { id?: number }).id
42
+ const lastProgress = opts?.lastProgress ?? (id !== undefined ? () => progressAt.get(id) : undefined)
43
+ return new Promise<T>((resolve, reject) => {
44
+ let settled = false
45
+ const onReply = (data: T & { id?: number, tab?: string }) => {
46
+ if (settled)
47
+ return
48
+ // id-bearing replies match by id; id-less replies (collect/dom/screen/state-path)
49
+ // take the first reply on this event — the prior single-slot last-write-wins.
50
+ if (id !== undefined && data.id !== id)
51
+ return
52
+ settled = true
53
+ cleanup()
54
+ resolve(data)
55
+ }
56
+ server.hot.on(replyEvent, onReply)
57
+ const cleanup = () => {
58
+ server.hot.off(replyEvent, onReply)
59
+ if (id !== undefined)
60
+ progressAt.delete(id)
61
+ }
62
+
63
+ // Addressed at one tab: skip the requireVisible round entirely — only that tab answers.
64
+ if (tab) {
65
+ const RETRY_MS = 500
66
+ const ABSENT_CEILING_MS = 10000
67
+ const startedAt = Date.now()
68
+ const deliver = () => server.hot.send(reqEvent, { ...payload, tab })
69
+ deliver()
70
+ const iv = setInterval(() => {
71
+ if (settled) {
72
+ clearInterval(iv)
73
+ return
74
+ }
75
+ const t = deps.tabs.get(tab)
76
+ const live = !!t && isLive(t, Date.now())
77
+ const progress = lastProgress?.()
78
+ const sinceLife = Date.now() - Math.max(startedAt, progress ?? 0)
79
+ const elapsed = Date.now() - startedAt
80
+ if (live) {
81
+ if (sinceLife > fullMs) {
82
+ clearInterval(iv)
83
+ cleanup()
84
+ reject(new Error(deps.diagnose(tab)))
85
+ }
86
+ }
87
+ else if (elapsed > ABSENT_CEILING_MS) {
88
+ clearInterval(iv)
89
+ cleanup()
90
+ reject(new Error(deps.diagnose(tab)))
91
+ }
92
+ else {
93
+ deliver()
94
+ }
95
+ }, RETRY_MS)
96
+ return
97
+ }
98
+ server.hot.send(reqEvent, { ...payload, requireVisible: true })
99
+ setTimeout(() => {
100
+ if (settled)
101
+ return
102
+ server.hot.send(reqEvent, { ...payload, requireVisible: false })
103
+ setTimeout(() => {
104
+ if (settled)
105
+ return
106
+ cleanup()
107
+ reject(new Error(deps.diagnose(tab)))
108
+ }, fullMs)
109
+ }, VISIBLE_MS)
110
+ })
111
+ }
112
+
113
+ const emit = (event: string, payload: object) => server.hot.send(event, payload)
114
+
115
+ return { request, emit }
116
+ }