@xiaobuyu/nodesign 0.0.1 → 0.0.3
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/README.md +156 -106
- package/bin/nodesign.js +0 -0
- package/package.json +1 -1
- package/server/api/README.md +45 -45
- package/server/api/_guard.js +75 -75
- package/server/api/admin.js +187 -187
- package/server/api/assets/docx-page.js +103 -103
- package/server/api/assets/helpers.js +42 -22
- package/server/api/assets/notes.js +194 -144
- package/server/api/assets.js +891 -896
- package/server/api/board.js +134 -57
- package/server/api/browse.js +138 -138
- package/server/api/canvas.js +444 -444
- package/server/api/chatai.js +324 -324
- package/server/api/exports/build-standalone.js +980 -980
- package/server/api/exports/cards.js +118 -118
- package/server/api/exports/docx-pdf.js +46 -46
- package/server/api/exports/export-page.js +142 -142
- package/server/api/exports/handoff.js +147 -147
- package/server/api/exports.js +690 -690
- package/server/api/instruction.js +75 -75
- package/server/api/me.js +116 -116
- package/server/api/memory.js +185 -185
- package/server/api/pending-changes.js +366 -366
- package/server/api/plugins.js +164 -164
- package/server/api/projects.js +220 -220
- package/server/api/publish.js +64 -64
- package/server/api/recent.js +73 -73
- package/server/api/sessions.js +589 -589
- package/server/api/skills.js +77 -77
- package/server/api/standalone-fit.js +168 -168
- package/server/api/turn-compose.js +180 -180
- package/server/api/turn-inflight.js +44 -44
- package/server/auth/README.md +17 -17
- package/server/auth/middleware.js +138 -138
- package/server/auth/origin-guard.js +71 -71
- package/server/auth/session.js +108 -108
- package/server/auth/tier.js +113 -113
- package/server/auth/users-store.js +348 -348
- package/server/edit/README.md +29 -29
- package/server/engine/README.md +113 -113
- package/server/engine/agent/agent-shared.js +550 -550
- package/server/engine/agent/auto-mode-default-hard-deny.txt +4 -4
- package/server/engine/agent/auto-mode-rules.js +78 -78
- package/server/engine/agent/context.js +418 -418
- package/server/engine/agent/events.js +465 -464
- package/server/engine/agent/hooks/canvas-validate.js +199 -199
- package/server/engine/agent/hooks/failure.js +120 -120
- package/server/engine/agent/hooks/file-events.js +75 -75
- package/server/engine/agent/hooks/lifecycle.js +233 -233
- package/server/engine/agent/hooks/post-canvas-focus.js +80 -80
- package/server/engine/agent/hooks/post-guidance.js +206 -206
- package/server/engine/agent/hooks/post-subagent-report.js +104 -104
- package/server/engine/agent/hooks/post-trim.js +58 -58
- package/server/engine/agent/hooks/pre-board-neighborhood.js +34 -34
- package/server/engine/agent/hooks/pre-defaults.js +84 -84
- package/server/engine/agent/hooks/pre-injectors.js +277 -277
- package/server/engine/agent/hooks/pre-performance-log-guard.js +78 -78
- package/server/engine/agent/hooks/pre-starter-files.js +61 -61
- package/server/engine/agent/hooks/pre-workspace-scope-guard.js +78 -78
- package/server/engine/agent/hooks/site-validate.js +94 -94
- package/server/engine/agent/hooks/tool-prompts.js +31 -31
- package/server/engine/agent/hooks/turn-state-memory.js +56 -56
- package/server/engine/agent/hooks/user-prompt-submit.js +258 -215
- package/server/engine/agent/hooks.js +354 -354
- package/server/engine/agent/init-contract.js +69 -69
- package/server/engine/agent/isolation.js +159 -159
- package/server/engine/agent/model-table.js +292 -292
- package/server/engine/agent/plugin-loader.js +266 -266
- package/server/engine/agent/prompts/nodesign-prelude.md +388 -336
- package/server/engine/agent/prompts/tools/ask-user-question-protocol.md +71 -71
- package/server/engine/agent/prompts/tools/direct-edit-protocol.md +98 -98
- package/server/engine/agent/prompts/tools/generate-image-cookbook.gemini-gateway.md +583 -583
- package/server/engine/agent/prompts/tools/generate-image-cookbook.md +483 -483
- package/server/engine/agent/prompts/tools/hybrid-reference.md +100 -100
- package/server/engine/agent/prompts/tools/paint-still-cookbook.md +270 -270
- package/server/engine/agent/prompts/tools/roll-film-cookbook.md +70 -70
- package/server/engine/agent/prompts/tools/site-reference.md +106 -106
- package/server/engine/agent/prompts/tools/tweaks-syntax.md +125 -125
- package/server/engine/agent/prompts/tools/vision-checker-dispatch.md +48 -48
- package/server/engine/agent/session-loop.js +1035 -1024
- package/server/engine/agent/session-model.js +164 -164
- package/server/engine/agent/skill.js +204 -204
- package/server/engine/agent/task-events.js +78 -78
- package/server/engine/agents/ds-extractor.md +132 -132
- package/server/engine/agents/explorer.md +190 -190
- package/server/engine/agents/index.js +230 -230
- package/server/engine/agents/schemas/design-system.json +156 -156
- package/server/engine/agents/schemas/tweak-schema.json +113 -113
- package/server/engine/agents/tweak-proposer.md +127 -127
- package/server/engine/agents/vision-checker.md +254 -254
- package/server/engine/browse/capture.js +422 -422
- package/server/engine/browse/card.js +137 -137
- package/server/engine/browse/handover.js +59 -59
- package/server/engine/browse/page-digest.js +119 -119
- package/server/engine/browse/refs.js +196 -196
- package/server/engine/browse/registry.js +307 -307
- package/server/engine/browse/screencast.js +170 -170
- package/server/engine/browse/state.js +121 -121
- package/server/engine/chatai/chat-log.js +79 -79
- package/server/engine/chatai/index.js +193 -193
- package/server/engine/chatai/openai-compat.js +152 -152
- package/server/engine/chatai/orchestrate.js +309 -309
- package/server/engine/chatai/perform.js +40 -40
- package/server/engine/chatai/summarize.js +79 -79
- package/server/engine/mcp/capability-gate.js +52 -52
- package/server/engine/mcp/index.js +351 -339
- package/server/engine/mcp/param-sanitizer.js +142 -142
- package/server/engine/mcp/tools/arrange-on-board.js +124 -124
- package/server/engine/mcp/tools/artifact-session.js +278 -278
- package/server/engine/mcp/tools/browse-computer.js +421 -421
- package/server/engine/mcp/tools/browse-find-batch.js +183 -183
- package/server/engine/mcp/tools/browse-screenshot.js +157 -157
- package/server/engine/mcp/tools/browse.js +480 -480
- package/server/engine/mcp/tools/build-docx.js +101 -101
- package/server/engine/mcp/tools/clear-pending-changes.js +99 -99
- package/server/engine/mcp/tools/create-on-board.js +152 -155
- package/server/engine/mcp/tools/crystallize-skill.js +168 -168
- package/server/engine/mcp/tools/edit-sketch.js +167 -0
- package/server/engine/mcp/tools/explain-style.js +237 -237
- package/server/engine/mcp/tools/export-handoff.js +133 -133
- package/server/engine/mcp/tools/expose-tweaks.js +149 -149
- package/server/engine/mcp/tools/generate-image.js +842 -842
- package/server/engine/mcp/tools/get-computed-styles.js +164 -164
- package/server/engine/mcp/tools/get-pending-changes.js +205 -205
- package/server/engine/mcp/tools/h3box-ssh.js +84 -84
- package/server/engine/mcp/tools/helpers/acquire-page.js +82 -82
- package/server/engine/mcp/tools/helpers/motion-lab.js +554 -554
- package/server/engine/mcp/tools/helpers/motion-scroll.js +112 -112
- package/server/engine/mcp/tools/helpers/perception-page.js +215 -215
- package/server/engine/mcp/tools/helpers/reference-download.js +103 -103
- package/server/engine/mcp/tools/helpers/rembg-bridge.py +80 -80
- package/server/engine/mcp/tools/helpers/shot-pipeline.js +280 -280
- package/server/engine/mcp/tools/highlight.js +68 -68
- package/server/engine/mcp/tools/list-pages.js +222 -222
- package/server/engine/mcp/tools/look-at-board.js +124 -0
- package/server/engine/mcp/tools/lookup-tags.js +59 -59
- package/server/engine/mcp/tools/navigate-to-page.js +60 -60
- package/server/engine/mcp/tools/organize-board.js +64 -64
- package/server/engine/mcp/tools/paint-still.js +434 -434
- package/server/engine/mcp/tools/pin-to-board.js +130 -130
- package/server/engine/mcp/tools/preview-deck.js +130 -130
- package/server/engine/mcp/tools/profile-scroll.js +305 -305
- package/server/engine/mcp/tools/publish-site.js +102 -102
- package/server/engine/mcp/tools/query-elements.js +195 -195
- package/server/engine/mcp/tools/read-board.js +190 -103
- package/server/engine/mcp/tools/read-document.js +93 -93
- package/server/engine/mcp/tools/read-page.js +264 -264
- package/server/engine/mcp/tools/read-tavern-json.js +143 -143
- package/server/engine/mcp/tools/read-user-view.js +59 -0
- package/server/engine/mcp/tools/record-decision.js +140 -140
- package/server/engine/mcp/tools/relate-on-board.js +112 -110
- package/server/engine/mcp/tools/report-issue.js +115 -115
- package/server/engine/mcp/tools/roll-film.js +266 -266
- package/server/engine/mcp/tools/screenshot-docx.js +138 -138
- package/server/engine/mcp/tools/screenshot-url.js +195 -195
- package/server/engine/mcp/tools/screenshot.js +583 -583
- package/server/engine/mcp/tools/sketch-on-board.js +337 -0
- package/server/engine/mcp/tools/tier-gate.js +96 -96
- package/server/engine/mcp/tools/trace-motion.js +215 -215
- package/server/engine/mcp/tools/web-search.js +548 -548
- package/server/engine/mcp/tools/write-on-board.js +130 -0
- package/server/engine/motion/inventory.js +349 -349
- package/server/engine/perception/session.js +235 -235
- package/server/engine/plugins/nodesign/.claude-plugin/plugin.json +5 -5
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/SKILL.md +199 -192
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/canvas.template.html +667 -667
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/hybrid-grid.md +22 -22
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/image-led-cover.md +25 -25
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/portrait.md +23 -23
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/quote-backdrop.md +21 -21
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/section-divider.md +23 -23
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/text-led.md +21 -21
- package/server/engine/plugins/nodesign/skills/docx-craft/SKILL.md +169 -162
- package/server/engine/plugins/nodesign/skills/docx-craft/references/token-schema.md +407 -407
- package/server/engine/plugins/nodesign/skills/docx-craft//346/226/207/346/241/243.template.json +80 -80
- package/server/engine/plugins/nodesign/skills/rp-craft/SKILL.md +235 -228
- package/server/engine/plugins/nodesign/skills/rp-craft/patterns//346/274/224/345/207/272/351/241/265-/346/234/200/345/260/217/345/256/236/347/216/260.html +78 -78
- package/server/engine/plugins/nodesign/skills/rp-craft//346/274/224/345/207/272.template.js +268 -268
- package/server/engine/plugins/nodesign/skills/site-craft/SKILL.md +330 -323
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/build-lane.md +134 -134
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/cutout-collage.md +61 -61
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/mock-app.md +163 -163
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/page-transitions.md +86 -86
- package/server/engine/runs/active-runs.js +698 -698
- package/server/engine/runs/board-tasklist.js +130 -0
- package/server/engine/runs/live-turn.js +252 -252
- package/server/engine/runs/store.js +364 -364
- package/server/engine/runs/turn-relay.js +217 -217
- package/server/engine/runtime/workspace.js +164 -164
- package/server/index.js +3 -0
- package/server/lib/artifact-file-path.js +80 -80
- package/server/lib/artifact-target.js +284 -284
- package/server/lib/asset-refs.js +239 -239
- package/server/lib/async-queue.js +104 -104
- package/server/lib/auto-relations.js +130 -130
- package/server/lib/binding-types.js +72 -60
- package/server/lib/board-graph-export.js +191 -0
- package/server/lib/board-groups.js +167 -0
- package/server/lib/board-hero.js +76 -0
- package/server/lib/board-kind-sizes.js +85 -62
- package/server/lib/board-relations.js +133 -133
- package/server/lib/browse-proxy.js +180 -180
- package/server/lib/canvas-id.js +36 -36
- package/server/lib/chalk.js +121 -0
- package/server/lib/cover.js +227 -227
- package/server/lib/danbooru-tags.js +291 -291
- package/server/lib/doc-extract.js +156 -156
- package/server/lib/docx/build-from-source.js +260 -260
- package/server/lib/docx/build.js +490 -490
- package/server/lib/docx/dump-styles.js +287 -287
- package/server/lib/docx/fonts/nodesign-cjk.conf +164 -164
- package/server/lib/docx/merge-runs.js +129 -129
- package/server/lib/docx/numbering.js +218 -218
- package/server/lib/docx/order.js +152 -152
- package/server/lib/docx/rawzip.js +172 -172
- package/server/lib/docx/render.js +92 -92
- package/server/lib/docx/text-lint.js +225 -225
- package/server/lib/docx/tokens.js +335 -335
- package/server/lib/docx/units.js +29 -29
- package/server/lib/docx/xml.js +291 -291
- package/server/lib/docx-pages.js +187 -187
- package/server/lib/export-collect.js +289 -289
- package/server/lib/export-package.js +210 -210
- package/server/lib/html-srcset.js +145 -145
- package/server/lib/image-variant.js +460 -460
- package/server/lib/ingress/forward-openai-chat.js +305 -305
- package/server/lib/ingress/openai-chat.js +491 -491
- package/server/lib/ingress/session-notice.js +66 -66
- package/server/lib/ingress/session-routes.js +68 -68
- package/server/lib/ingress/slot-probe.js +130 -130
- package/server/lib/ingress/upstream-billing.js +52 -52
- package/server/lib/ingress/upstream-fail-streak.js +72 -72
- package/server/lib/ingress/upstream-truncation.js +48 -48
- package/server/lib/issues-store.js +182 -182
- package/server/lib/kinds/deck.js +72 -72
- package/server/lib/kinds/docx.js +283 -283
- package/server/lib/kinds/file-kinds.js +113 -113
- package/server/lib/kinds/index.js +202 -202
- package/server/lib/kinds/site.js +161 -161
- package/server/lib/model-ingress.js +543 -543
- package/server/lib/moderation.js +255 -255
- package/server/lib/notice-store.js +103 -103
- package/server/lib/plugin-install.js +104 -104
- package/server/lib/plugin-validator.js +721 -721
- package/server/lib/publish-store.js +115 -115
- package/server/lib/quick-summary.js +52 -52
- package/server/lib/quota.js +270 -270
- package/server/lib/rate-window.js +29 -29
- package/server/lib/reference-assets.js +92 -92
- package/server/lib/region-shot.js +135 -135
- package/server/lib/safe-path.js +73 -73
- package/server/lib/sdk-session.js +33 -33
- package/server/lib/showcase-store.js +92 -92
- package/server/lib/site-publish.js +465 -465
- package/server/lib/sketch-layout.js +277 -0
- package/server/lib/ssrf-guard.js +432 -432
- package/server/lib/task-scan.js +158 -158
- package/server/lib/tavern-json.js +154 -154
- package/server/lib/video-variant.js +237 -237
- package/server/lib/workspace-path.js +33 -33
- package/server/ops/fix-sdk-musl.mjs +40 -40
- package/server/ops/install-macos-fonts.sh +114 -114
- package/server/ops/macos-fonts.conf +336 -336
- package/server/ops/sandbox-shim/bwrap +57 -57
- package/server/projects/assets-summary.js +119 -119
- package/server/projects/auto-name.js +53 -53
- package/server/projects/board-limits.js +12 -0
- package/server/projects/board-sanitize.js +252 -0
- package/server/projects/board-store.js +507 -640
- package/server/projects/move-entry.js +103 -103
- package/server/projects/store.js +270 -270
- package/server/projects/ui-config.js +60 -54
- package/server/projects/viewpoint-store.js +78 -0
- package/server/projects/workspace-templates.js +53 -53
- package/server/projects/workspace.js +1025 -1025
- package/server/runtime/local-config.js +177 -177
- package/server/runtime/profile.js +72 -72
- package/server/services/rembg-service.py +334 -334
- package/server/shared/README.md +25 -25
- package/server/shared/deck.js +54 -54
- package/server/shared/time.js +49 -49
- package/server/style-pipeline/README.md +53 -53
- package/server/ws/broker.js +39 -36
- package/server/ws/browse-channel.js +177 -177
- package/server/ws/index.js +386 -386
- package/web/README.md +89 -89
- package/web/dist/assets/BrowserWindow-P_vIdh-G.js +11 -0
- package/web/dist/assets/{DeckWindow-4G0xX7P3.js → DeckWindow-BpLv5082.js} +3 -3
- package/web/dist/assets/{DocxWindow-BXQsoPkh.js → DocxWindow-DSAU1-tD.js} +4 -4
- package/web/dist/assets/MermaidBlock-DR1GikCv.js +313 -0
- package/web/dist/assets/SiteWindow-92uBuRRR.js +18 -0
- package/web/dist/assets/abnfDiagram-VCTEODGH-uA8Esix4.js +1 -0
- package/web/dist/assets/arc-D_khH_xN.js +1 -0
- package/web/dist/assets/architectureDiagram-5GKGNRK7-DydJHqOt.js +36 -0
- package/web/dist/assets/blockDiagram-NRAW4CY4-B7wBbKiW.js +129 -0
- package/web/dist/assets/c4Diagram-UCG6FXSJ-6mrH0x7Y.js +38 -0
- package/web/dist/assets/channel-DGkccMO1.js +1 -0
- package/web/dist/assets/chunk-2Q5K7J3B-Cuikho5Q.js +1 -0
- package/web/dist/assets/chunk-5VM5RSS4-3-KZ5AUz.js +15 -0
- package/web/dist/assets/chunk-F27PBJKO-Bkwq2gGU.js +1 -0
- package/web/dist/assets/chunk-G27WJ6UU-BLyQzdGE.js +231 -0
- package/web/dist/assets/chunk-JWPE2WC7-B7HxxQds.js +1 -0
- package/web/dist/assets/chunk-LCL6LL3I-CtXrnUwF.js +206 -0
- package/web/dist/assets/chunk-POPQ4Y6H-dxddZlXt.js +1 -0
- package/web/dist/assets/chunk-SVP7TREG-DpkmrgHn.js +88 -0
- package/web/dist/assets/chunk-XXDRQBXY-BwZ7RbpQ.js +1 -0
- package/web/dist/assets/classDiagram-DTDB5LWJ-BGscw3OD.js +1 -0
- package/web/dist/assets/classDiagram-v2-JRS7N3AN-BGscw3OD.js +1 -0
- package/web/dist/assets/cose-bilkent-JH36ORCC-DGFOsMUt.js +1 -0
- package/web/dist/assets/cynefin-OW5HDTMX-DUwn3ehS.js +178 -0
- package/web/dist/assets/cynefinDiagram-5FMLGOSQ-DlLAOZ9x.js +62 -0
- package/web/dist/assets/cytoscape.esm-BjAbmLdb.js +331 -0
- package/web/dist/assets/dagre-3AP2YEHR-CyourLfz.js +4 -0
- package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/web/dist/assets/diagram-S7CK7UJ4-BTMbx2np.js +30 -0
- package/web/dist/assets/diagram-UQ7AKVKN-UK94_1-P.js +41 -0
- package/web/dist/assets/diagram-VSXAHHWV-C7zS-v9v.js +3 -0
- package/web/dist/assets/diagram-VX7I27RA-DKJRa6CK.js +24 -0
- package/web/dist/assets/diagram-Z3DM3KII-kUbAfGAp.js +24 -0
- package/web/dist/assets/ebnfDiagram-PWID7BFC-CVW16_vb.js +1 -0
- package/web/dist/assets/erDiagram-SSCWMZ5O-BRQMFQ5y.js +99 -0
- package/web/dist/assets/flowDiagram-A5DVABFB-S34LpLtK.js +168 -0
- package/web/dist/assets/ganttDiagram-EL5Y4UJY-BFiqk1-G.js +292 -0
- package/web/dist/assets/gitGraphDiagram-WWUBYQGX-LZsrdyyv.js +106 -0
- package/web/dist/assets/index-GAamh1p4.js +2152 -0
- package/web/dist/assets/infoDiagram-RXCK75RN-0DvN6KIp.js +2 -0
- package/web/dist/assets/init-Gi6I4Gst.js +1 -0
- package/web/dist/assets/ishikawaDiagram-5VMMS53U-CaY3A-Bx.js +70 -0
- package/web/dist/assets/journeyDiagram-EYS64GPL-BBeiaCrj.js +139 -0
- package/web/dist/assets/kanban-definition-3QL26DDD-ux42DRXQ.js +89 -0
- package/web/dist/assets/layout-CnXiF_NB.js +1 -0
- package/web/dist/assets/linear-BMW0u6G6.js +1 -0
- package/web/dist/assets/mindmap-definition-FBJOCRG2-hUxSpdKn.js +96 -0
- package/web/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/web/dist/assets/pegDiagram-XKGWAZYB-aCaQPH0w.js +1 -0
- package/web/dist/assets/{pending-edit-apply-DTjo2geW.js → pending-edit-apply-AcdOQ9Ij.js} +6 -6
- package/web/dist/assets/pieDiagram-E7YTZNPT-CmOEZ8zg.js +39 -0
- package/web/dist/assets/quadrantDiagram-AXDQQJYC-VW6fAhsV.js +7 -0
- package/web/dist/assets/railroadDiagram-O6MQD6OU-CKJ7Ba5K.js +1 -0
- package/web/dist/assets/requirementDiagram-EFPCY7ZU-pNGkq9Vw.js +84 -0
- package/web/dist/assets/{rotate-cw-LMT3x1Zj.js → rotate-cw-KAfChbGV.js} +1 -1
- package/web/dist/assets/sankeyDiagram-P5KCCOFB-BIMmDTfZ.js +40 -0
- package/web/dist/assets/sequenceDiagram-WJ2MYXX4-BBW7wzKY.js +162 -0
- package/web/dist/assets/sizeCapture-X5ZJPWSS-DdkXmLYR.js +1 -0
- package/web/dist/assets/{square-dashed-mouse-pointer-CBoAkfhi.js → square-dashed-mouse-pointer-CDUlhUP7.js} +1 -1
- package/web/dist/assets/stateDiagram-HBIQ2CUA-W313Uz16.js +1 -0
- package/web/dist/assets/stateDiagram-v2-4QOOHH4V-DDhJBZzf.js +1 -0
- package/web/dist/assets/swimlanes-XN3QIQJK-C29TL26K.js +1 -0
- package/web/dist/assets/swimlanesDiagram-VK2B7HYN-BfN-Xdmu.js +8 -0
- package/web/dist/assets/timeline-definition-24CTP7MA-CF0JMObX.js +120 -0
- package/web/dist/assets/vennDiagram-4TSXK5OY-DrDFsaH3.js +34 -0
- package/web/dist/assets/wardleyDiagram-VM6X3IG4-CVrnRIS_.js +78 -0
- package/web/dist/assets/xychartDiagram-S5SC5T6Z-BvkN1zsT.js +7 -0
- package/web/dist/index.html +17 -17
- package/web/dist/assets/BrowserWindow-Dc7J7CYc.js +0 -11
- package/web/dist/assets/SiteWindow-D6eS-2gM.js +0 -18
- package/web/dist/assets/index-Cbl3ATvp.js +0 -2125
package/server/ws/index.js
CHANGED
|
@@ -1,386 +1,386 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* server/ws/index.js — WebSocket 升级入口
|
|
3
|
-
*
|
|
4
|
-
* URL 模式:/ws/projects/:pid
|
|
5
|
-
*
|
|
6
|
-
* 流程:
|
|
7
|
-
* 1. http server 'upgrade' 事件触发
|
|
8
|
-
* 2. parse URL 取 pid
|
|
9
|
-
* 3. validateProjectId + getProject 校验存在
|
|
10
|
-
* 4. wss.handleUpgrade → 拿到 ws 对象
|
|
11
|
-
* 5. 订阅该 project 的 EventBus,事件 JSON.stringify 推 ws.send
|
|
12
|
-
* 6. ping/pong 30s 心跳
|
|
13
|
-
* 7. ws close → unsubscribe + clearInterval
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { WebSocketServer } from 'ws';
|
|
17
|
-
import { createBrowseWS } from './browse-channel.js';
|
|
18
|
-
import { URL } from 'url';
|
|
19
|
-
import { getSessionMessages } from '@anthropic-ai/claude-agent-sdk';
|
|
20
|
-
import { validateProjectId, getProject } from '../projects/store.js';
|
|
21
|
-
import { getSessionWorkspace, validateSessionId } from '../projects/workspace.js';
|
|
22
|
-
import { withConfigDir } from '../lib/sdk-session.js';
|
|
23
|
-
import { platform } from '../runtime/platform.js';
|
|
24
|
-
import { getProjectBus } from './broker.js';
|
|
25
|
-
import { requestUser } from '../auth/session.js';
|
|
26
|
-
import { originAllowed } from '../auth/origin-guard.js';
|
|
27
|
-
import { userOwnsProject } from '../api/_guard.js';
|
|
28
|
-
import {
|
|
29
|
-
getCurrentTurnRunId,
|
|
30
|
-
hasActiveQuerySession,
|
|
31
|
-
closeQuerySession,
|
|
32
|
-
markSessionActivity,
|
|
33
|
-
} from '../engine/runs/active-runs.js';
|
|
34
|
-
import { getLiveTurnSnapshot } from '../engine/runs/live-turn.js';
|
|
35
|
-
|
|
36
|
-
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
37
|
-
const HYDRATE_CHUNK_SIZE = 50;
|
|
38
|
-
const GLOBAL_CLAUDE_CONFIG_DIR = platform.claudeConfigDir;
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* WS 全部断开后再 N ms 仍无重连 → closeQuerySession 让 SDK subprocess 退出,
|
|
42
|
-
* 防止用户关 tab / 刷新后服务器孤儿 SDK process 永久占内存(每个 ~250MB RSS)。
|
|
43
|
-
*
|
|
44
|
-
* 默认 60s 给前端 ws-client.js exponential backoff(1s→2s→...→30s)足够重连窗口。
|
|
45
|
-
* env NODESIGN_WS_GRACE_MS 可调(生产可拉到 5min 给移动端弱网)。
|
|
46
|
-
*/
|
|
47
|
-
const WS_GRACE_MS = Number(process.env.NODESIGN_WS_GRACE_MS) || 60_000;
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* sid → { count: 当前活跃 WS 连接数, graceTimer: 0 引用时启动的关闭定时器 }
|
|
51
|
-
*
|
|
52
|
-
* 每条带 sid 的 WS 连接 ref++,close 时 ref--;归零启 grace timer,N ms 内有新 WS
|
|
53
|
-
* 进同 sid 立即清 timer 续命;timer 到期再确认 0 ref 后 closeQuerySession。
|
|
54
|
-
*
|
|
55
|
-
* /work 路径(无 sid)的 WS 不进 sessionRefs(无 session 可绑)。
|
|
56
|
-
*/
|
|
57
|
-
const sessionRefs = new Map();
|
|
58
|
-
|
|
59
|
-
function refSession(sid) {
|
|
60
|
-
if (!sid) return;
|
|
61
|
-
let entry = sessionRefs.get(sid);
|
|
62
|
-
if (!entry) {
|
|
63
|
-
entry = { count: 0, graceTimer: null };
|
|
64
|
-
sessionRefs.set(sid, entry);
|
|
65
|
-
}
|
|
66
|
-
entry.count += 1;
|
|
67
|
-
if (entry.graceTimer) {
|
|
68
|
-
clearTimeout(entry.graceTimer);
|
|
69
|
-
entry.graceTimer = null;
|
|
70
|
-
}
|
|
71
|
-
// WS 连上算一次活跃信号(idle scan 不会立即把刚连的 session 当僵尸关掉)
|
|
72
|
-
markSessionActivity(sid);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function unrefSession(sid) {
|
|
76
|
-
if (!sid) return;
|
|
77
|
-
const entry = sessionRefs.get(sid);
|
|
78
|
-
if (!entry) return;
|
|
79
|
-
entry.count = Math.max(0, entry.count - 1);
|
|
80
|
-
if (entry.count > 0) return;
|
|
81
|
-
// 0 ref → 启 grace timer。若 grace 期内有新 WS 进同 sid,refSession 会清掉 timer
|
|
82
|
-
if (entry.graceTimer) clearTimeout(entry.graceTimer);
|
|
83
|
-
const onGraceExpired = () => {
|
|
84
|
-
const cur = sessionRefs.get(sid);
|
|
85
|
-
if (!cur || cur.count > 0) return; // 期间有新连上,不关
|
|
86
|
-
// 进行中的 turn 不杀(2026-07-27):用户关 tab / 切走时后端应该把活干完,
|
|
87
|
-
// 前端重连后靠 hydrate + live_turn 快照接回状态。这正是"前端丢失后端工作
|
|
88
|
-
// 状态"要保住的那半 —— 老逻辑 60s 就 cancel 在跑的 turn,是真丢工作。
|
|
89
|
-
// 续一个 grace 周期再查;turn 结束后仍无订阅者才真正关(idle SDK 进程回收)。
|
|
90
|
-
if (getCurrentTurnRunId(sid)) {
|
|
91
|
-
console.info(`[ws] grace expired for sid=${sid.slice(0, 8)} but turn in flight — deferring close`);
|
|
92
|
-
cur.graceTimer = setTimeout(onGraceExpired, WS_GRACE_MS);
|
|
93
|
-
cur.graceTimer.unref?.();
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
sessionRefs.delete(sid);
|
|
97
|
-
if (hasActiveQuerySession(sid)) {
|
|
98
|
-
console.info(`[ws] grace expired for sid=${sid.slice(0, 8)}, closing session (no_active_subscriber)`);
|
|
99
|
-
closeQuerySession(sid, 'no_active_subscriber');
|
|
100
|
-
}
|
|
101
|
-
};
|
|
102
|
-
entry.graceTimer = setTimeout(onGraceExpired, WS_GRACE_MS);
|
|
103
|
-
entry.graceTimer.unref?.();
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export function setupWS(httpServer) {
|
|
107
|
-
// 浏览器画面/输入的专用通道(2026-08-18),独立 WebSocketServer
|
|
108
|
-
const browseWS = createBrowseWS();
|
|
109
|
-
// perMessageDeflate:弱网下 hydrate chunk + ring-buffer replay 帧成本是 raw size
|
|
110
|
-
// 量级(>2MB session 一次首屏 hydrate 估 250KB+ raw)。permessage-deflate 让
|
|
111
|
-
// JSON 文本压到 5-10x。threshold 1KB 跳过小帧(控制帧无收益反增 CPU)。
|
|
112
|
-
// env NODESIGN_WS_DISABLE_DEFLATE=1 应急关闭(CPU 紧的部署环境)。
|
|
113
|
-
const wss = new WebSocketServer({
|
|
114
|
-
noServer: true,
|
|
115
|
-
perMessageDeflate: process.env.NODESIGN_WS_DISABLE_DEFLATE === '1'
|
|
116
|
-
? false
|
|
117
|
-
: { threshold: 1024 },
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
httpServer.on('upgrade', (req, socket, head) => {
|
|
121
|
-
// ⛔ CSRF 闸(2026-08-18):cookie 是 SameSite=Lax,而已发布站点与应用主机
|
|
122
|
-
// 同 eTLD+1 = 同站,Lax 照发(真跑验过,见 auth/origin-guard.js 文件头)。
|
|
123
|
-
// 这里**不学下面那个 4401 的握手后再关**:那套是为了让我们自己的前端看得见
|
|
124
|
-
// 原因好停止重连;外站页面不需要体面的错误,403 直接掐在升级前最省。
|
|
125
|
-
if (!originAllowed(req)) {
|
|
126
|
-
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
127
|
-
return socket.destroy();
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// 登录墙(2026-07-30 多用户):解析身份而非布尔。cookie 无效不再写 HTTP 401
|
|
131
|
-
// 拒 upgrade —— 浏览器拿不到 upgrade 阶段的状态码,只见 close 1006,前端
|
|
132
|
-
// ws-client 会无限指数退避重连。改为完成握手后 close(4401),前端把它列为
|
|
133
|
-
// fatal code → 停止重连并回登录页。
|
|
134
|
-
const user = requestUser(req);
|
|
135
|
-
|
|
136
|
-
let url;
|
|
137
|
-
try {
|
|
138
|
-
url = new URL(req.url || '/', 'http://x');
|
|
139
|
-
} catch {
|
|
140
|
-
return socket.destroy();
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// ⚠️ 浏览器画面通道的分支**必须排在下面那个 404 之前** —— 那句 404 是兜底,
|
|
144
|
-
// 不匹配 `/ws/projects/:pid` 的路径会被它直接毙掉。
|
|
145
|
-
// 为什么另开一条而不是复用这条:这条是纯下行 + EventBus 广播 + 2000 条重放
|
|
146
|
-
// 缓冲,高频画面帧灌进去会冲爆重放缓冲、混进 hydrate 回放,而且 JSON+base64
|
|
147
|
-
// 帧会被 perMessageDeflate 白压一遍。详见 ws/browse-channel.js 文件头。
|
|
148
|
-
{
|
|
149
|
-
const bm = browseWS.matches(url.pathname);
|
|
150
|
-
if (bm) return browseWS.accept(req, socket, head, bm);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const m = url.pathname.match(/^\/ws\/projects\/([^/]+)$/);
|
|
154
|
-
if (!m) {
|
|
155
|
-
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
156
|
-
return socket.destroy();
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const pid = decodeURIComponent(m[1]);
|
|
160
|
-
try {
|
|
161
|
-
validateProjectId(pid);
|
|
162
|
-
} catch {
|
|
163
|
-
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
|
|
164
|
-
return socket.destroy();
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// 身份或归属不对 → 握手后 4401 关闭(同一个 code:外人不该分得清
|
|
168
|
-
// "没登录"和"不是你的项目")
|
|
169
|
-
const project = getProject(pid);
|
|
170
|
-
if (!user || !userOwnsProject(user, project)) {
|
|
171
|
-
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
172
|
-
ws.close(4401, 'unauthorized');
|
|
173
|
-
});
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// ?since=N — 客户端最后看到的 EventBus seq;server 通过 buffer 回放 (since, _seq] 段
|
|
178
|
-
// 第一次连不带 since → since=0 → 不 replay 直接 live。
|
|
179
|
-
const sinceRaw = url.searchParams.get('since');
|
|
180
|
-
const since = sinceRaw != null ? Math.max(0, parseInt(sinceRaw, 10) || 0) : 0;
|
|
181
|
-
|
|
182
|
-
// Phase A.4:?sid=<sessionId> — 客户端当前在哪个 session 上,server 在 first connect /
|
|
183
|
-
// gap 时推 ws.hydrate.* 帧补完整 messages。无 sid 时(/work 路径)跳过 hydrate
|
|
184
|
-
// 直接 replay+live。
|
|
185
|
-
let sid = url.searchParams.get('sid');
|
|
186
|
-
if (sid) {
|
|
187
|
-
try { validateSessionId(sid); }
|
|
188
|
-
catch { sid = null; }
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
192
|
-
handleProjectWS(ws, pid, since, sid);
|
|
193
|
-
});
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
return wss;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* 读 jsonl 历史(异步,几百 ms)。发帧是另一个纯同步函数 —— 读完到发出之间不能
|
|
201
|
-
* 再有 await,否则"读到的历史"与"当时的 live turn 快照"会错位(见 handleProjectWS)。
|
|
202
|
-
*/
|
|
203
|
-
async function loadHydrate(pid, sid) {
|
|
204
|
-
const sessionRoot = getSessionWorkspace(pid, sid);
|
|
205
|
-
return withConfigDir(GLOBAL_CLAUDE_CONFIG_DIR, () =>
|
|
206
|
-
getSessionMessages(sid, { dir: sessionRoot, includeSystemMessages: false }),
|
|
207
|
-
);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* 本轮由快照权威:jsonl 里属于当前 turn 的 assistant 条目全部裁掉。
|
|
212
|
-
*
|
|
213
|
-
* SDK 按 block 逐条写 jsonl(thinking / text / tool_use 各一行),一个 turn 跑到
|
|
214
|
-
* 一半时前面几条已经落盘 —— 那些内容同时也在 live_turn 快照里(快照从 run.start
|
|
215
|
-
* 折叠整轮)。两边 id 体系不同(jsonl 是 `<uuid>:text:<n>`,快照是 `<runId>:mN`),
|
|
216
|
-
* 前端去不了重,正文就渲染两遍。这是用户报的"重复传"的根因,100% 复现。
|
|
217
|
-
*
|
|
218
|
-
* 只裁 assistant:user 条目(用户自己那句话)快照里没有,裁了就真丢了。掉队的
|
|
219
|
-
* tool_result 会因为找不到对应 tool_use 被 sessionMessagesToDisplay 自然跳过。
|
|
220
|
-
*/
|
|
221
|
-
function dropInFlightTurn(messages, startedAt) {
|
|
222
|
-
if (!startedAt) return messages;
|
|
223
|
-
const cut = Date.parse(startedAt);
|
|
224
|
-
if (Number.isNaN(cut)) return messages;
|
|
225
|
-
return messages.filter(m => !(
|
|
226
|
-
m?.type === 'assistant' && m.timestamp && Date.parse(m.timestamp) >= cut
|
|
227
|
-
));
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* Phase A.4:推 ws.hydrate.start/chunk/end 帧把 jsonl 历史 messages 同步给前端。
|
|
232
|
-
* 必须在 subscribeFromSeq 之前发,确保前端先 hydrate 后 apply replay/live events。
|
|
233
|
-
* 纯同步。
|
|
234
|
-
*/
|
|
235
|
-
function sendHydrateFrames(ws, sid, messages, asOfSeq) {
|
|
236
|
-
if (ws.readyState !== ws.OPEN) return;
|
|
237
|
-
const total = messages.length;
|
|
238
|
-
ws.send(JSON.stringify({
|
|
239
|
-
type: 'ws.hydrate.start',
|
|
240
|
-
sessionId: sid,
|
|
241
|
-
total,
|
|
242
|
-
asOfSeq,
|
|
243
|
-
ts: new Date().toISOString(),
|
|
244
|
-
}));
|
|
245
|
-
for (let i = 0; i < total; i += HYDRATE_CHUNK_SIZE) {
|
|
246
|
-
if (ws.readyState !== ws.OPEN) return;
|
|
247
|
-
ws.send(JSON.stringify({
|
|
248
|
-
type: 'ws.hydrate.chunk',
|
|
249
|
-
sessionId: sid,
|
|
250
|
-
chunkIdx: Math.floor(i / HYDRATE_CHUNK_SIZE),
|
|
251
|
-
messages: messages.slice(i, i + HYDRATE_CHUNK_SIZE),
|
|
252
|
-
}));
|
|
253
|
-
}
|
|
254
|
-
if (ws.readyState !== ws.OPEN) return;
|
|
255
|
-
ws.send(JSON.stringify({ type: 'ws.hydrate.end', sessionId: sid, total }));
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/** hydrate 读失败 fail-soft:前端收到后兜底走 HTTP Sessions.read */
|
|
259
|
-
function sendHydrateError(ws, sid, message) {
|
|
260
|
-
if (ws.readyState !== ws.OPEN) return;
|
|
261
|
-
try {
|
|
262
|
-
ws.send(JSON.stringify({
|
|
263
|
-
type: 'ws.hydrate.start', sessionId: sid, kind: 'error', error: message,
|
|
264
|
-
}));
|
|
265
|
-
} catch { /* ignore */ }
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
async function handleProjectWS(ws, pid, since = 0, sid = null) {
|
|
269
|
-
const bus = getProjectBus(pid);
|
|
270
|
-
|
|
271
|
-
// 诊断 log:用户重启服务器后报"前端收不到事件"——这条 log 让 pm2 logs 一眼看出
|
|
272
|
-
// WS 是不是真连上 + 客户端传的 sid 跟 server 端 active session 状态对不对。
|
|
273
|
-
console.info(
|
|
274
|
-
`[ws] connect pid=${pid} sid=${sid ? sid.slice(0, 8) : 'none'} `
|
|
275
|
-
+ `hasActiveSession=${sid ? hasActiveQuerySession(sid) : 'n/a'} since=${since}`,
|
|
276
|
-
);
|
|
277
|
-
|
|
278
|
-
// sid lifecycle ref:带 sid 的 WS 连上即 ref++,close/error 时 unref。0 ref 触发
|
|
279
|
-
// grace timer N ms 后 closeQuerySession 让 SDK subprocess 自然退出。
|
|
280
|
-
refSession(sid);
|
|
281
|
-
|
|
282
|
-
// ── 连接协议(2026-07-27 重构:快照 + 尾随,每次连接都全量重建)──
|
|
283
|
-
//
|
|
284
|
-
// ① jsonl hydrate — 已完成 turn 的历史(turn 边界 flush 的持久层)
|
|
285
|
-
// ② ws.live_turn 快照 — 进行中 turn 的物化重建(live-turn.js 同步折叠,
|
|
286
|
-
// 覆盖"刷新 / 断线期间错过的全部流式内容")
|
|
287
|
-
// ③ 尾随订阅 — 从快照 seq 起 replay ring buffer + 切 live
|
|
288
|
-
//
|
|
289
|
-
// 老协议的 ?since= 游标 + gap 补 hydrate 废弃:ring buffer(2000) 对 token 级
|
|
290
|
-
// streaming 几秒断线就 gap,gap 后 hydrate 帧晚于 replay/live 到达,前端
|
|
291
|
-
// hydrate.end 整体替换 messages 把刚渲染的内容洗掉。新协议 hydrate 永远先发,
|
|
292
|
-
// 快照覆盖 ring 之外的一切,ring 只需覆盖"读 jsonl 的几百 ms"同步窗口。
|
|
293
|
-
// `since` 参数保留解析但忽略(旧前端兼容)。
|
|
294
|
-
//
|
|
295
|
-
// 两份历史的边界(2026-07-28):jsonl 与快照对"进行中的 turn"是重叠的 ——
|
|
296
|
-
// 读 jsonl 与取快照必须在**同一个同步区**里完成并一起发出,中间不能 await,
|
|
297
|
-
// 否则 turn 在缝里结束会让两边错位。规则:本轮由快照权威,hydrate 裁掉本轮。
|
|
298
|
-
const seqAtHydrateStart = bus._seq || 0;
|
|
299
|
-
let loaded = null;
|
|
300
|
-
let loadErr = null;
|
|
301
|
-
const t0 = Date.now();
|
|
302
|
-
if (sid) {
|
|
303
|
-
try { loaded = await loadHydrate(pid, sid); }
|
|
304
|
-
catch (err) { loadErr = err; }
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// ↓↓↓ 同步区开始(不要在这里插 await)↓↓↓
|
|
308
|
-
const snapshot = sid ? getLiveTurnSnapshot(sid) : null;
|
|
309
|
-
if (sid) {
|
|
310
|
-
if (loadErr) {
|
|
311
|
-
console.warn(`[ws] hydrate failed for ${pid}/${sid}:`, loadErr.message);
|
|
312
|
-
sendHydrateError(ws, sid, loadErr.message);
|
|
313
|
-
} else {
|
|
314
|
-
const trimmed = snapshot ? dropInFlightTurn(loaded, snapshot.startedAt) : loaded;
|
|
315
|
-
console.info(
|
|
316
|
-
`[ws] hydrate sid=${sid.slice(0, 8)} loaded ${loaded.length} messages in ${Date.now() - t0}ms`
|
|
317
|
-
+ (snapshot ? ` (本轮 ${loaded.length - trimmed.length} 条交给快照)` : ''),
|
|
318
|
-
);
|
|
319
|
-
sendHydrateFrames(ws, sid, trimmed, seqAtHydrateStart);
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
if (snapshot && ws.readyState === ws.OPEN) {
|
|
323
|
-
try {
|
|
324
|
-
ws.send(JSON.stringify({ type: 'ws.live_turn', sessionId: sid, ...snapshot }));
|
|
325
|
-
} catch (err) {
|
|
326
|
-
console.warn(`[ws] live_turn send failed for ${pid}:`, err.message);
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
// ↑↑↑ 同步区结束 ↑↑↑
|
|
330
|
-
|
|
331
|
-
// sid 过滤:projectBuses per-project 共享,只推本 session 事件 + 无 sessionId
|
|
332
|
-
// 的全局事件。sid=null(/work 路径)时 session-scoped 事件全部不推 ——
|
|
333
|
-
// 老行为"收全部"会把别的 session 的 delta 渲进空 chat(串台);/work 没有
|
|
334
|
-
// session,本来就不该收 run 流,新建 session 后前端会带 sid 重连。
|
|
335
|
-
const subscribeSince = snapshot ? snapshot.seq : seqAtHydrateStart;
|
|
336
|
-
const { unsubscribe, replayed, gap } = bus.subscribeFromSeq(subscribeSince, (event) => {
|
|
337
|
-
if (event.sessionId && event.sessionId !== sid) return;
|
|
338
|
-
if (ws.readyState === ws.OPEN) {
|
|
339
|
-
try {
|
|
340
|
-
ws.send(JSON.stringify(event));
|
|
341
|
-
} catch (err) {
|
|
342
|
-
console.warn(`[ws] send failed for ${pid}:`, err.message);
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
const heartbeat = setInterval(() => {
|
|
348
|
-
if (ws.readyState === ws.OPEN) {
|
|
349
|
-
try { ws.ping(); } catch { /* will close */ }
|
|
350
|
-
}
|
|
351
|
-
}, HEARTBEAT_INTERVAL_MS);
|
|
352
|
-
|
|
353
|
-
let cleanedUp = false;
|
|
354
|
-
const cleanup = () => {
|
|
355
|
-
if (cleanedUp) return;
|
|
356
|
-
cleanedUp = true;
|
|
357
|
-
clearInterval(heartbeat);
|
|
358
|
-
unsubscribe();
|
|
359
|
-
unrefSession(sid);
|
|
360
|
-
};
|
|
361
|
-
|
|
362
|
-
ws.on('close', cleanup);
|
|
363
|
-
ws.on('error', (err) => {
|
|
364
|
-
console.warn(`[ws] error for ${pid}:`, err.message);
|
|
365
|
-
cleanup();
|
|
366
|
-
});
|
|
367
|
-
|
|
368
|
-
// 确认 + replay 元信息(gap=true 客户端可决定全量 hydrate)。
|
|
369
|
-
// 故意放在 replay 之后发:客户端看到 ws.connected 就知道 backlog 已 drain,可切回正常 live 状态。
|
|
370
|
-
//
|
|
371
|
-
// activeRunId:sid 上当前在跑的 turn runId(无则 null)。前端用它重连后恢复
|
|
372
|
-
// isStreaming/currentRunId —— 否则 WS 抖动期间 run.start 已发完且 buffer 没新事件
|
|
373
|
-
// 时,前端永远不知道 run 还活着(stop 按钮消失,UX 表现"流没了")。
|
|
374
|
-
const activeRunId = sid ? getCurrentTurnRunId(sid) : null;
|
|
375
|
-
try {
|
|
376
|
-
ws.send(JSON.stringify({
|
|
377
|
-
type: 'ws.connected',
|
|
378
|
-
projectId: pid,
|
|
379
|
-
ts: new Date().toISOString(),
|
|
380
|
-
since,
|
|
381
|
-
replayed,
|
|
382
|
-
gap,
|
|
383
|
-
activeRunId,
|
|
384
|
-
}));
|
|
385
|
-
} catch { /* immediate close edge */ }
|
|
386
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* server/ws/index.js — WebSocket 升级入口
|
|
3
|
+
*
|
|
4
|
+
* URL 模式:/ws/projects/:pid
|
|
5
|
+
*
|
|
6
|
+
* 流程:
|
|
7
|
+
* 1. http server 'upgrade' 事件触发
|
|
8
|
+
* 2. parse URL 取 pid
|
|
9
|
+
* 3. validateProjectId + getProject 校验存在
|
|
10
|
+
* 4. wss.handleUpgrade → 拿到 ws 对象
|
|
11
|
+
* 5. 订阅该 project 的 EventBus,事件 JSON.stringify 推 ws.send
|
|
12
|
+
* 6. ping/pong 30s 心跳
|
|
13
|
+
* 7. ws close → unsubscribe + clearInterval
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { WebSocketServer } from 'ws';
|
|
17
|
+
import { createBrowseWS } from './browse-channel.js';
|
|
18
|
+
import { URL } from 'url';
|
|
19
|
+
import { getSessionMessages } from '@anthropic-ai/claude-agent-sdk';
|
|
20
|
+
import { validateProjectId, getProject } from '../projects/store.js';
|
|
21
|
+
import { getSessionWorkspace, validateSessionId } from '../projects/workspace.js';
|
|
22
|
+
import { withConfigDir } from '../lib/sdk-session.js';
|
|
23
|
+
import { platform } from '../runtime/platform.js';
|
|
24
|
+
import { getProjectBus } from './broker.js';
|
|
25
|
+
import { requestUser } from '../auth/session.js';
|
|
26
|
+
import { originAllowed } from '../auth/origin-guard.js';
|
|
27
|
+
import { userOwnsProject } from '../api/_guard.js';
|
|
28
|
+
import {
|
|
29
|
+
getCurrentTurnRunId,
|
|
30
|
+
hasActiveQuerySession,
|
|
31
|
+
closeQuerySession,
|
|
32
|
+
markSessionActivity,
|
|
33
|
+
} from '../engine/runs/active-runs.js';
|
|
34
|
+
import { getLiveTurnSnapshot } from '../engine/runs/live-turn.js';
|
|
35
|
+
|
|
36
|
+
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
37
|
+
const HYDRATE_CHUNK_SIZE = 50;
|
|
38
|
+
const GLOBAL_CLAUDE_CONFIG_DIR = platform.claudeConfigDir;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* WS 全部断开后再 N ms 仍无重连 → closeQuerySession 让 SDK subprocess 退出,
|
|
42
|
+
* 防止用户关 tab / 刷新后服务器孤儿 SDK process 永久占内存(每个 ~250MB RSS)。
|
|
43
|
+
*
|
|
44
|
+
* 默认 60s 给前端 ws-client.js exponential backoff(1s→2s→...→30s)足够重连窗口。
|
|
45
|
+
* env NODESIGN_WS_GRACE_MS 可调(生产可拉到 5min 给移动端弱网)。
|
|
46
|
+
*/
|
|
47
|
+
const WS_GRACE_MS = Number(process.env.NODESIGN_WS_GRACE_MS) || 60_000;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* sid → { count: 当前活跃 WS 连接数, graceTimer: 0 引用时启动的关闭定时器 }
|
|
51
|
+
*
|
|
52
|
+
* 每条带 sid 的 WS 连接 ref++,close 时 ref--;归零启 grace timer,N ms 内有新 WS
|
|
53
|
+
* 进同 sid 立即清 timer 续命;timer 到期再确认 0 ref 后 closeQuerySession。
|
|
54
|
+
*
|
|
55
|
+
* /work 路径(无 sid)的 WS 不进 sessionRefs(无 session 可绑)。
|
|
56
|
+
*/
|
|
57
|
+
const sessionRefs = new Map();
|
|
58
|
+
|
|
59
|
+
function refSession(sid) {
|
|
60
|
+
if (!sid) return;
|
|
61
|
+
let entry = sessionRefs.get(sid);
|
|
62
|
+
if (!entry) {
|
|
63
|
+
entry = { count: 0, graceTimer: null };
|
|
64
|
+
sessionRefs.set(sid, entry);
|
|
65
|
+
}
|
|
66
|
+
entry.count += 1;
|
|
67
|
+
if (entry.graceTimer) {
|
|
68
|
+
clearTimeout(entry.graceTimer);
|
|
69
|
+
entry.graceTimer = null;
|
|
70
|
+
}
|
|
71
|
+
// WS 连上算一次活跃信号(idle scan 不会立即把刚连的 session 当僵尸关掉)
|
|
72
|
+
markSessionActivity(sid);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function unrefSession(sid) {
|
|
76
|
+
if (!sid) return;
|
|
77
|
+
const entry = sessionRefs.get(sid);
|
|
78
|
+
if (!entry) return;
|
|
79
|
+
entry.count = Math.max(0, entry.count - 1);
|
|
80
|
+
if (entry.count > 0) return;
|
|
81
|
+
// 0 ref → 启 grace timer。若 grace 期内有新 WS 进同 sid,refSession 会清掉 timer
|
|
82
|
+
if (entry.graceTimer) clearTimeout(entry.graceTimer);
|
|
83
|
+
const onGraceExpired = () => {
|
|
84
|
+
const cur = sessionRefs.get(sid);
|
|
85
|
+
if (!cur || cur.count > 0) return; // 期间有新连上,不关
|
|
86
|
+
// 进行中的 turn 不杀(2026-07-27):用户关 tab / 切走时后端应该把活干完,
|
|
87
|
+
// 前端重连后靠 hydrate + live_turn 快照接回状态。这正是"前端丢失后端工作
|
|
88
|
+
// 状态"要保住的那半 —— 老逻辑 60s 就 cancel 在跑的 turn,是真丢工作。
|
|
89
|
+
// 续一个 grace 周期再查;turn 结束后仍无订阅者才真正关(idle SDK 进程回收)。
|
|
90
|
+
if (getCurrentTurnRunId(sid)) {
|
|
91
|
+
console.info(`[ws] grace expired for sid=${sid.slice(0, 8)} but turn in flight — deferring close`);
|
|
92
|
+
cur.graceTimer = setTimeout(onGraceExpired, WS_GRACE_MS);
|
|
93
|
+
cur.graceTimer.unref?.();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
sessionRefs.delete(sid);
|
|
97
|
+
if (hasActiveQuerySession(sid)) {
|
|
98
|
+
console.info(`[ws] grace expired for sid=${sid.slice(0, 8)}, closing session (no_active_subscriber)`);
|
|
99
|
+
closeQuerySession(sid, 'no_active_subscriber');
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
entry.graceTimer = setTimeout(onGraceExpired, WS_GRACE_MS);
|
|
103
|
+
entry.graceTimer.unref?.();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function setupWS(httpServer) {
|
|
107
|
+
// 浏览器画面/输入的专用通道(2026-08-18),独立 WebSocketServer
|
|
108
|
+
const browseWS = createBrowseWS();
|
|
109
|
+
// perMessageDeflate:弱网下 hydrate chunk + ring-buffer replay 帧成本是 raw size
|
|
110
|
+
// 量级(>2MB session 一次首屏 hydrate 估 250KB+ raw)。permessage-deflate 让
|
|
111
|
+
// JSON 文本压到 5-10x。threshold 1KB 跳过小帧(控制帧无收益反增 CPU)。
|
|
112
|
+
// env NODESIGN_WS_DISABLE_DEFLATE=1 应急关闭(CPU 紧的部署环境)。
|
|
113
|
+
const wss = new WebSocketServer({
|
|
114
|
+
noServer: true,
|
|
115
|
+
perMessageDeflate: process.env.NODESIGN_WS_DISABLE_DEFLATE === '1'
|
|
116
|
+
? false
|
|
117
|
+
: { threshold: 1024 },
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
httpServer.on('upgrade', (req, socket, head) => {
|
|
121
|
+
// ⛔ CSRF 闸(2026-08-18):cookie 是 SameSite=Lax,而已发布站点与应用主机
|
|
122
|
+
// 同 eTLD+1 = 同站,Lax 照发(真跑验过,见 auth/origin-guard.js 文件头)。
|
|
123
|
+
// 这里**不学下面那个 4401 的握手后再关**:那套是为了让我们自己的前端看得见
|
|
124
|
+
// 原因好停止重连;外站页面不需要体面的错误,403 直接掐在升级前最省。
|
|
125
|
+
if (!originAllowed(req)) {
|
|
126
|
+
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
127
|
+
return socket.destroy();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 登录墙(2026-07-30 多用户):解析身份而非布尔。cookie 无效不再写 HTTP 401
|
|
131
|
+
// 拒 upgrade —— 浏览器拿不到 upgrade 阶段的状态码,只见 close 1006,前端
|
|
132
|
+
// ws-client 会无限指数退避重连。改为完成握手后 close(4401),前端把它列为
|
|
133
|
+
// fatal code → 停止重连并回登录页。
|
|
134
|
+
const user = requestUser(req);
|
|
135
|
+
|
|
136
|
+
let url;
|
|
137
|
+
try {
|
|
138
|
+
url = new URL(req.url || '/', 'http://x');
|
|
139
|
+
} catch {
|
|
140
|
+
return socket.destroy();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ⚠️ 浏览器画面通道的分支**必须排在下面那个 404 之前** —— 那句 404 是兜底,
|
|
144
|
+
// 不匹配 `/ws/projects/:pid` 的路径会被它直接毙掉。
|
|
145
|
+
// 为什么另开一条而不是复用这条:这条是纯下行 + EventBus 广播 + 2000 条重放
|
|
146
|
+
// 缓冲,高频画面帧灌进去会冲爆重放缓冲、混进 hydrate 回放,而且 JSON+base64
|
|
147
|
+
// 帧会被 perMessageDeflate 白压一遍。详见 ws/browse-channel.js 文件头。
|
|
148
|
+
{
|
|
149
|
+
const bm = browseWS.matches(url.pathname);
|
|
150
|
+
if (bm) return browseWS.accept(req, socket, head, bm);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const m = url.pathname.match(/^\/ws\/projects\/([^/]+)$/);
|
|
154
|
+
if (!m) {
|
|
155
|
+
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
156
|
+
return socket.destroy();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const pid = decodeURIComponent(m[1]);
|
|
160
|
+
try {
|
|
161
|
+
validateProjectId(pid);
|
|
162
|
+
} catch {
|
|
163
|
+
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
|
|
164
|
+
return socket.destroy();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 身份或归属不对 → 握手后 4401 关闭(同一个 code:外人不该分得清
|
|
168
|
+
// "没登录"和"不是你的项目")
|
|
169
|
+
const project = getProject(pid);
|
|
170
|
+
if (!user || !userOwnsProject(user, project)) {
|
|
171
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
172
|
+
ws.close(4401, 'unauthorized');
|
|
173
|
+
});
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ?since=N — 客户端最后看到的 EventBus seq;server 通过 buffer 回放 (since, _seq] 段
|
|
178
|
+
// 第一次连不带 since → since=0 → 不 replay 直接 live。
|
|
179
|
+
const sinceRaw = url.searchParams.get('since');
|
|
180
|
+
const since = sinceRaw != null ? Math.max(0, parseInt(sinceRaw, 10) || 0) : 0;
|
|
181
|
+
|
|
182
|
+
// Phase A.4:?sid=<sessionId> — 客户端当前在哪个 session 上,server 在 first connect /
|
|
183
|
+
// gap 时推 ws.hydrate.* 帧补完整 messages。无 sid 时(/work 路径)跳过 hydrate
|
|
184
|
+
// 直接 replay+live。
|
|
185
|
+
let sid = url.searchParams.get('sid');
|
|
186
|
+
if (sid) {
|
|
187
|
+
try { validateSessionId(sid); }
|
|
188
|
+
catch { sid = null; }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
192
|
+
handleProjectWS(ws, pid, since, sid);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
return wss;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 读 jsonl 历史(异步,几百 ms)。发帧是另一个纯同步函数 —— 读完到发出之间不能
|
|
201
|
+
* 再有 await,否则"读到的历史"与"当时的 live turn 快照"会错位(见 handleProjectWS)。
|
|
202
|
+
*/
|
|
203
|
+
async function loadHydrate(pid, sid) {
|
|
204
|
+
const sessionRoot = getSessionWorkspace(pid, sid);
|
|
205
|
+
return withConfigDir(GLOBAL_CLAUDE_CONFIG_DIR, () =>
|
|
206
|
+
getSessionMessages(sid, { dir: sessionRoot, includeSystemMessages: false }),
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 本轮由快照权威:jsonl 里属于当前 turn 的 assistant 条目全部裁掉。
|
|
212
|
+
*
|
|
213
|
+
* SDK 按 block 逐条写 jsonl(thinking / text / tool_use 各一行),一个 turn 跑到
|
|
214
|
+
* 一半时前面几条已经落盘 —— 那些内容同时也在 live_turn 快照里(快照从 run.start
|
|
215
|
+
* 折叠整轮)。两边 id 体系不同(jsonl 是 `<uuid>:text:<n>`,快照是 `<runId>:mN`),
|
|
216
|
+
* 前端去不了重,正文就渲染两遍。这是用户报的"重复传"的根因,100% 复现。
|
|
217
|
+
*
|
|
218
|
+
* 只裁 assistant:user 条目(用户自己那句话)快照里没有,裁了就真丢了。掉队的
|
|
219
|
+
* tool_result 会因为找不到对应 tool_use 被 sessionMessagesToDisplay 自然跳过。
|
|
220
|
+
*/
|
|
221
|
+
function dropInFlightTurn(messages, startedAt) {
|
|
222
|
+
if (!startedAt) return messages;
|
|
223
|
+
const cut = Date.parse(startedAt);
|
|
224
|
+
if (Number.isNaN(cut)) return messages;
|
|
225
|
+
return messages.filter(m => !(
|
|
226
|
+
m?.type === 'assistant' && m.timestamp && Date.parse(m.timestamp) >= cut
|
|
227
|
+
));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Phase A.4:推 ws.hydrate.start/chunk/end 帧把 jsonl 历史 messages 同步给前端。
|
|
232
|
+
* 必须在 subscribeFromSeq 之前发,确保前端先 hydrate 后 apply replay/live events。
|
|
233
|
+
* 纯同步。
|
|
234
|
+
*/
|
|
235
|
+
function sendHydrateFrames(ws, sid, messages, asOfSeq) {
|
|
236
|
+
if (ws.readyState !== ws.OPEN) return;
|
|
237
|
+
const total = messages.length;
|
|
238
|
+
ws.send(JSON.stringify({
|
|
239
|
+
type: 'ws.hydrate.start',
|
|
240
|
+
sessionId: sid,
|
|
241
|
+
total,
|
|
242
|
+
asOfSeq,
|
|
243
|
+
ts: new Date().toISOString(),
|
|
244
|
+
}));
|
|
245
|
+
for (let i = 0; i < total; i += HYDRATE_CHUNK_SIZE) {
|
|
246
|
+
if (ws.readyState !== ws.OPEN) return;
|
|
247
|
+
ws.send(JSON.stringify({
|
|
248
|
+
type: 'ws.hydrate.chunk',
|
|
249
|
+
sessionId: sid,
|
|
250
|
+
chunkIdx: Math.floor(i / HYDRATE_CHUNK_SIZE),
|
|
251
|
+
messages: messages.slice(i, i + HYDRATE_CHUNK_SIZE),
|
|
252
|
+
}));
|
|
253
|
+
}
|
|
254
|
+
if (ws.readyState !== ws.OPEN) return;
|
|
255
|
+
ws.send(JSON.stringify({ type: 'ws.hydrate.end', sessionId: sid, total }));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** hydrate 读失败 fail-soft:前端收到后兜底走 HTTP Sessions.read */
|
|
259
|
+
function sendHydrateError(ws, sid, message) {
|
|
260
|
+
if (ws.readyState !== ws.OPEN) return;
|
|
261
|
+
try {
|
|
262
|
+
ws.send(JSON.stringify({
|
|
263
|
+
type: 'ws.hydrate.start', sessionId: sid, kind: 'error', error: message,
|
|
264
|
+
}));
|
|
265
|
+
} catch { /* ignore */ }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function handleProjectWS(ws, pid, since = 0, sid = null) {
|
|
269
|
+
const bus = getProjectBus(pid);
|
|
270
|
+
|
|
271
|
+
// 诊断 log:用户重启服务器后报"前端收不到事件"——这条 log 让 pm2 logs 一眼看出
|
|
272
|
+
// WS 是不是真连上 + 客户端传的 sid 跟 server 端 active session 状态对不对。
|
|
273
|
+
console.info(
|
|
274
|
+
`[ws] connect pid=${pid} sid=${sid ? sid.slice(0, 8) : 'none'} `
|
|
275
|
+
+ `hasActiveSession=${sid ? hasActiveQuerySession(sid) : 'n/a'} since=${since}`,
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
// sid lifecycle ref:带 sid 的 WS 连上即 ref++,close/error 时 unref。0 ref 触发
|
|
279
|
+
// grace timer N ms 后 closeQuerySession 让 SDK subprocess 自然退出。
|
|
280
|
+
refSession(sid);
|
|
281
|
+
|
|
282
|
+
// ── 连接协议(2026-07-27 重构:快照 + 尾随,每次连接都全量重建)──
|
|
283
|
+
//
|
|
284
|
+
// ① jsonl hydrate — 已完成 turn 的历史(turn 边界 flush 的持久层)
|
|
285
|
+
// ② ws.live_turn 快照 — 进行中 turn 的物化重建(live-turn.js 同步折叠,
|
|
286
|
+
// 覆盖"刷新 / 断线期间错过的全部流式内容")
|
|
287
|
+
// ③ 尾随订阅 — 从快照 seq 起 replay ring buffer + 切 live
|
|
288
|
+
//
|
|
289
|
+
// 老协议的 ?since= 游标 + gap 补 hydrate 废弃:ring buffer(2000) 对 token 级
|
|
290
|
+
// streaming 几秒断线就 gap,gap 后 hydrate 帧晚于 replay/live 到达,前端
|
|
291
|
+
// hydrate.end 整体替换 messages 把刚渲染的内容洗掉。新协议 hydrate 永远先发,
|
|
292
|
+
// 快照覆盖 ring 之外的一切,ring 只需覆盖"读 jsonl 的几百 ms"同步窗口。
|
|
293
|
+
// `since` 参数保留解析但忽略(旧前端兼容)。
|
|
294
|
+
//
|
|
295
|
+
// 两份历史的边界(2026-07-28):jsonl 与快照对"进行中的 turn"是重叠的 ——
|
|
296
|
+
// 读 jsonl 与取快照必须在**同一个同步区**里完成并一起发出,中间不能 await,
|
|
297
|
+
// 否则 turn 在缝里结束会让两边错位。规则:本轮由快照权威,hydrate 裁掉本轮。
|
|
298
|
+
const seqAtHydrateStart = bus._seq || 0;
|
|
299
|
+
let loaded = null;
|
|
300
|
+
let loadErr = null;
|
|
301
|
+
const t0 = Date.now();
|
|
302
|
+
if (sid) {
|
|
303
|
+
try { loaded = await loadHydrate(pid, sid); }
|
|
304
|
+
catch (err) { loadErr = err; }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ↓↓↓ 同步区开始(不要在这里插 await)↓↓↓
|
|
308
|
+
const snapshot = sid ? getLiveTurnSnapshot(sid) : null;
|
|
309
|
+
if (sid) {
|
|
310
|
+
if (loadErr) {
|
|
311
|
+
console.warn(`[ws] hydrate failed for ${pid}/${sid}:`, loadErr.message);
|
|
312
|
+
sendHydrateError(ws, sid, loadErr.message);
|
|
313
|
+
} else {
|
|
314
|
+
const trimmed = snapshot ? dropInFlightTurn(loaded, snapshot.startedAt) : loaded;
|
|
315
|
+
console.info(
|
|
316
|
+
`[ws] hydrate sid=${sid.slice(0, 8)} loaded ${loaded.length} messages in ${Date.now() - t0}ms`
|
|
317
|
+
+ (snapshot ? ` (本轮 ${loaded.length - trimmed.length} 条交给快照)` : ''),
|
|
318
|
+
);
|
|
319
|
+
sendHydrateFrames(ws, sid, trimmed, seqAtHydrateStart);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (snapshot && ws.readyState === ws.OPEN) {
|
|
323
|
+
try {
|
|
324
|
+
ws.send(JSON.stringify({ type: 'ws.live_turn', sessionId: sid, ...snapshot }));
|
|
325
|
+
} catch (err) {
|
|
326
|
+
console.warn(`[ws] live_turn send failed for ${pid}:`, err.message);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
// ↑↑↑ 同步区结束 ↑↑↑
|
|
330
|
+
|
|
331
|
+
// sid 过滤:projectBuses per-project 共享,只推本 session 事件 + 无 sessionId
|
|
332
|
+
// 的全局事件。sid=null(/work 路径)时 session-scoped 事件全部不推 ——
|
|
333
|
+
// 老行为"收全部"会把别的 session 的 delta 渲进空 chat(串台);/work 没有
|
|
334
|
+
// session,本来就不该收 run 流,新建 session 后前端会带 sid 重连。
|
|
335
|
+
const subscribeSince = snapshot ? snapshot.seq : seqAtHydrateStart;
|
|
336
|
+
const { unsubscribe, replayed, gap } = bus.subscribeFromSeq(subscribeSince, (event) => {
|
|
337
|
+
if (event.sessionId && event.sessionId !== sid) return;
|
|
338
|
+
if (ws.readyState === ws.OPEN) {
|
|
339
|
+
try {
|
|
340
|
+
ws.send(JSON.stringify(event));
|
|
341
|
+
} catch (err) {
|
|
342
|
+
console.warn(`[ws] send failed for ${pid}:`, err.message);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const heartbeat = setInterval(() => {
|
|
348
|
+
if (ws.readyState === ws.OPEN) {
|
|
349
|
+
try { ws.ping(); } catch { /* will close */ }
|
|
350
|
+
}
|
|
351
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
352
|
+
|
|
353
|
+
let cleanedUp = false;
|
|
354
|
+
const cleanup = () => {
|
|
355
|
+
if (cleanedUp) return;
|
|
356
|
+
cleanedUp = true;
|
|
357
|
+
clearInterval(heartbeat);
|
|
358
|
+
unsubscribe();
|
|
359
|
+
unrefSession(sid);
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
ws.on('close', cleanup);
|
|
363
|
+
ws.on('error', (err) => {
|
|
364
|
+
console.warn(`[ws] error for ${pid}:`, err.message);
|
|
365
|
+
cleanup();
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// 确认 + replay 元信息(gap=true 客户端可决定全量 hydrate)。
|
|
369
|
+
// 故意放在 replay 之后发:客户端看到 ws.connected 就知道 backlog 已 drain,可切回正常 live 状态。
|
|
370
|
+
//
|
|
371
|
+
// activeRunId:sid 上当前在跑的 turn runId(无则 null)。前端用它重连后恢复
|
|
372
|
+
// isStreaming/currentRunId —— 否则 WS 抖动期间 run.start 已发完且 buffer 没新事件
|
|
373
|
+
// 时,前端永远不知道 run 还活着(stop 按钮消失,UX 表现"流没了")。
|
|
374
|
+
const activeRunId = sid ? getCurrentTurnRunId(sid) : null;
|
|
375
|
+
try {
|
|
376
|
+
ws.send(JSON.stringify({
|
|
377
|
+
type: 'ws.connected',
|
|
378
|
+
projectId: pid,
|
|
379
|
+
ts: new Date().toISOString(),
|
|
380
|
+
since,
|
|
381
|
+
replayed,
|
|
382
|
+
gap,
|
|
383
|
+
activeRunId,
|
|
384
|
+
}));
|
|
385
|
+
} catch { /* immediate close edge */ }
|
|
386
|
+
}
|