@textjs/app 0.0.0-stage → 1.0.2

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 (335) hide show
  1. package/README.md +34 -2
  2. package/bin/text.js +29 -0
  3. package/bin/text.ts +13 -0
  4. package/dist/.tsbuildinfo +1 -0
  5. package/dist/cli/build.d.ts +39 -0
  6. package/dist/cli/build.d.ts.map +1 -0
  7. package/dist/cli/build.js +157 -0
  8. package/dist/cli/build.js.map +1 -0
  9. package/dist/cli/config.d.ts +22 -0
  10. package/dist/cli/config.d.ts.map +1 -0
  11. package/dist/cli/config.js +73 -0
  12. package/dist/cli/config.js.map +1 -0
  13. package/dist/cli/dev.d.ts +12 -0
  14. package/dist/cli/dev.d.ts.map +1 -0
  15. package/dist/cli/dev.js +49 -0
  16. package/dist/cli/dev.js.map +1 -0
  17. package/dist/cli/index.d.ts +26 -0
  18. package/dist/cli/index.d.ts.map +1 -0
  19. package/dist/cli/index.js +192 -0
  20. package/dist/cli/index.js.map +1 -0
  21. package/dist/cli/run.d.ts +31 -0
  22. package/dist/cli/run.d.ts.map +1 -0
  23. package/dist/cli/run.js +111 -0
  24. package/dist/cli/run.js.map +1 -0
  25. package/dist/cli/start.d.ts +14 -0
  26. package/dist/cli/start.d.ts.map +1 -0
  27. package/dist/cli/start.js +183 -0
  28. package/dist/cli/start.js.map +1 -0
  29. package/dist/cli/ui.d.ts +35 -0
  30. package/dist/cli/ui.d.ts.map +1 -0
  31. package/dist/cli/ui.js +73 -0
  32. package/dist/cli/ui.js.map +1 -0
  33. package/dist/client.d.ts +34 -0
  34. package/dist/client.d.ts.map +1 -0
  35. package/dist/client.js +93 -0
  36. package/dist/client.js.map +1 -0
  37. package/dist/config.d.ts +143 -0
  38. package/dist/config.d.ts.map +1 -0
  39. package/dist/config.js +28 -0
  40. package/dist/config.js.map +1 -0
  41. package/dist/context.d.ts +44 -0
  42. package/dist/context.d.ts.map +1 -0
  43. package/dist/context.js +26 -0
  44. package/dist/context.js.map +1 -0
  45. package/dist/dev-errors.d.ts +68 -0
  46. package/dist/dev-errors.d.ts.map +1 -0
  47. package/dist/dev-errors.js +121 -0
  48. package/dist/dev-errors.js.map +1 -0
  49. package/dist/devtools.d.ts +64 -0
  50. package/dist/devtools.d.ts.map +1 -0
  51. package/dist/devtools.js +186 -0
  52. package/dist/devtools.js.map +1 -0
  53. package/dist/frame-mirror.d.ts +19 -0
  54. package/dist/frame-mirror.d.ts.map +1 -0
  55. package/dist/frame-mirror.js +141 -0
  56. package/dist/frame-mirror.js.map +1 -0
  57. package/dist/frame-protocol.d.ts +175 -0
  58. package/dist/frame-protocol.d.ts.map +1 -0
  59. package/dist/frame-protocol.js +369 -0
  60. package/dist/frame-protocol.js.map +1 -0
  61. package/dist/history.d.ts +54 -0
  62. package/dist/history.d.ts.map +1 -0
  63. package/dist/history.js +152 -0
  64. package/dist/history.js.map +1 -0
  65. package/dist/index.d.ts +9 -0
  66. package/dist/index.d.ts.map +1 -0
  67. package/dist/index.js +8 -0
  68. package/dist/index.js.map +1 -0
  69. package/dist/link.d.ts +32 -0
  70. package/dist/link.d.ts.map +1 -0
  71. package/dist/link.js +54 -0
  72. package/dist/link.js.map +1 -0
  73. package/dist/live-player.d.ts +83 -0
  74. package/dist/live-player.d.ts.map +1 -0
  75. package/dist/live-player.js +413 -0
  76. package/dist/live-player.js.map +1 -0
  77. package/dist/live-protocol.d.ts +132 -0
  78. package/dist/live-protocol.d.ts.map +1 -0
  79. package/dist/live-protocol.js +278 -0
  80. package/dist/live-protocol.js.map +1 -0
  81. package/dist/live-stub.d.ts +10 -0
  82. package/dist/live-stub.d.ts.map +1 -0
  83. package/dist/live-stub.js +32 -0
  84. package/dist/live-stub.js.map +1 -0
  85. package/dist/mcp/cli.d.ts +22 -0
  86. package/dist/mcp/cli.d.ts.map +1 -0
  87. package/dist/mcp/cli.js +177 -0
  88. package/dist/mcp/cli.js.map +1 -0
  89. package/dist/mcp/frame.d.ts +58 -0
  90. package/dist/mcp/frame.d.ts.map +1 -0
  91. package/dist/mcp/frame.js +169 -0
  92. package/dist/mcp/frame.js.map +1 -0
  93. package/dist/mcp/index.d.ts +15 -0
  94. package/dist/mcp/index.d.ts.map +1 -0
  95. package/dist/mcp/index.js +15 -0
  96. package/dist/mcp/index.js.map +1 -0
  97. package/dist/mcp/inspect.d.ts +95 -0
  98. package/dist/mcp/inspect.d.ts.map +1 -0
  99. package/dist/mcp/inspect.js +386 -0
  100. package/dist/mcp/inspect.js.map +1 -0
  101. package/dist/mcp/keys.d.ts +20 -0
  102. package/dist/mcp/keys.d.ts.map +1 -0
  103. package/dist/mcp/keys.js +87 -0
  104. package/dist/mcp/keys.js.map +1 -0
  105. package/dist/mcp/logs.d.ts +63 -0
  106. package/dist/mcp/logs.d.ts.map +1 -0
  107. package/dist/mcp/logs.js +217 -0
  108. package/dist/mcp/logs.js.map +1 -0
  109. package/dist/mcp/server.d.ts +64 -0
  110. package/dist/mcp/server.d.ts.map +1 -0
  111. package/dist/mcp/server.js +485 -0
  112. package/dist/mcp/server.js.map +1 -0
  113. package/dist/mcp/session.d.ts +230 -0
  114. package/dist/mcp/session.d.ts.map +1 -0
  115. package/dist/mcp/session.js +742 -0
  116. package/dist/mcp/session.js.map +1 -0
  117. package/dist/navigation-errors.d.ts +28 -0
  118. package/dist/navigation-errors.d.ts.map +1 -0
  119. package/dist/navigation-errors.js +43 -0
  120. package/dist/navigation-errors.js.map +1 -0
  121. package/dist/navigation.d.ts +21 -0
  122. package/dist/navigation.d.ts.map +1 -0
  123. package/dist/navigation.js +33 -0
  124. package/dist/navigation.js.map +1 -0
  125. package/dist/router.d.ts +76 -0
  126. package/dist/router.d.ts.map +1 -0
  127. package/dist/router.js +296 -0
  128. package/dist/router.js.map +1 -0
  129. package/dist/routing.d.ts +101 -0
  130. package/dist/routing.d.ts.map +1 -0
  131. package/dist/routing.js +286 -0
  132. package/dist/routing.js.map +1 -0
  133. package/dist/server/adapters/build-step.d.ts +34 -0
  134. package/dist/server/adapters/build-step.d.ts.map +1 -0
  135. package/dist/server/adapters/build-step.js +112 -0
  136. package/dist/server/adapters/build-step.js.map +1 -0
  137. package/dist/server/adapters/bun.d.ts +8 -0
  138. package/dist/server/adapters/bun.d.ts.map +1 -0
  139. package/dist/server/adapters/bun.js +89 -0
  140. package/dist/server/adapters/bun.js.map +1 -0
  141. package/dist/server/adapters/bundle.d.ts +21 -0
  142. package/dist/server/adapters/bundle.d.ts.map +1 -0
  143. package/dist/server/adapters/bundle.js +86 -0
  144. package/dist/server/adapters/bundle.js.map +1 -0
  145. package/dist/server/adapters/cloudflare.d.ts +29 -0
  146. package/dist/server/adapters/cloudflare.d.ts.map +1 -0
  147. package/dist/server/adapters/cloudflare.js +105 -0
  148. package/dist/server/adapters/cloudflare.js.map +1 -0
  149. package/dist/server/adapters/index.d.ts +10 -0
  150. package/dist/server/adapters/index.d.ts.map +1 -0
  151. package/dist/server/adapters/index.js +24 -0
  152. package/dist/server/adapters/index.js.map +1 -0
  153. package/dist/server/adapters/runtime-node.d.ts +16 -0
  154. package/dist/server/adapters/runtime-node.d.ts.map +1 -0
  155. package/dist/server/adapters/runtime-node.js +29 -0
  156. package/dist/server/adapters/runtime-node.js.map +1 -0
  157. package/dist/server/adapters/runtime.d.ts +42 -0
  158. package/dist/server/adapters/runtime.d.ts.map +1 -0
  159. package/dist/server/adapters/runtime.js +115 -0
  160. package/dist/server/adapters/runtime.js.map +1 -0
  161. package/dist/server/adapters/shared.d.ts +66 -0
  162. package/dist/server/adapters/shared.d.ts.map +1 -0
  163. package/dist/server/adapters/shared.js +124 -0
  164. package/dist/server/adapters/shared.js.map +1 -0
  165. package/dist/server/adapters/static.d.ts +9 -0
  166. package/dist/server/adapters/static.d.ts.map +1 -0
  167. package/dist/server/adapters/static.js +70 -0
  168. package/dist/server/adapters/static.js.map +1 -0
  169. package/dist/server/adapters/vercel.d.ts +43 -0
  170. package/dist/server/adapters/vercel.d.ts.map +1 -0
  171. package/dist/server/adapters/vercel.js +119 -0
  172. package/dist/server/adapters/vercel.js.map +1 -0
  173. package/dist/server/bun-jsx.d.ts +18 -0
  174. package/dist/server/bun-jsx.d.ts.map +1 -0
  175. package/dist/server/bun-jsx.js +56 -0
  176. package/dist/server/bun-jsx.js.map +1 -0
  177. package/dist/server/cache-route.d.ts +38 -0
  178. package/dist/server/cache-route.d.ts.map +1 -0
  179. package/dist/server/cache-route.js +109 -0
  180. package/dist/server/cache-route.js.map +1 -0
  181. package/dist/server/cache.d.ts +131 -0
  182. package/dist/server/cache.d.ts.map +1 -0
  183. package/dist/server/cache.js +409 -0
  184. package/dist/server/cache.js.map +1 -0
  185. package/dist/server/frames.d.ts +45 -0
  186. package/dist/server/frames.d.ts.map +1 -0
  187. package/dist/server/frames.js +294 -0
  188. package/dist/server/frames.js.map +1 -0
  189. package/dist/server/http.d.ts +80 -0
  190. package/dist/server/http.d.ts.map +1 -0
  191. package/dist/server/http.js +453 -0
  192. package/dist/server/http.js.map +1 -0
  193. package/dist/server/jsx-dev-runtime.d.ts +5 -0
  194. package/dist/server/jsx-dev-runtime.d.ts.map +1 -0
  195. package/dist/server/jsx-dev-runtime.js +6 -0
  196. package/dist/server/jsx-dev-runtime.js.map +1 -0
  197. package/dist/server/jsx-runtime.d.ts +10 -0
  198. package/dist/server/jsx-runtime.d.ts.map +1 -0
  199. package/dist/server/jsx-runtime.js +11 -0
  200. package/dist/server/jsx-runtime.js.map +1 -0
  201. package/dist/server/live-app.d.ts +87 -0
  202. package/dist/server/live-app.d.ts.map +1 -0
  203. package/dist/server/live-app.js +307 -0
  204. package/dist/server/live-app.js.map +1 -0
  205. package/dist/server/live-build.d.ts +70 -0
  206. package/dist/server/live-build.d.ts.map +1 -0
  207. package/dist/server/live-build.js +222 -0
  208. package/dist/server/live-build.js.map +1 -0
  209. package/dist/server/live-dev.d.ts +62 -0
  210. package/dist/server/live-dev.d.ts.map +1 -0
  211. package/dist/server/live-dev.js +313 -0
  212. package/dist/server/live-dev.js.map +1 -0
  213. package/dist/server/live-manifest.d.ts +23 -0
  214. package/dist/server/live-manifest.d.ts.map +1 -0
  215. package/dist/server/live-manifest.js +7 -0
  216. package/dist/server/live-manifest.js.map +1 -0
  217. package/dist/server/live-prod.d.ts +30 -0
  218. package/dist/server/live-prod.d.ts.map +1 -0
  219. package/dist/server/live-prod.js +97 -0
  220. package/dist/server/live-prod.js.map +1 -0
  221. package/dist/server/live-routes.d.ts +72 -0
  222. package/dist/server/live-routes.d.ts.map +1 -0
  223. package/dist/server/live-routes.js +274 -0
  224. package/dist/server/live-routes.js.map +1 -0
  225. package/dist/server/live-web.d.ts +16 -0
  226. package/dist/server/live-web.d.ts.map +1 -0
  227. package/dist/server/live-web.js +159 -0
  228. package/dist/server/live-web.js.map +1 -0
  229. package/dist/server/live.d.ts +138 -0
  230. package/dist/server/live.d.ts.map +1 -0
  231. package/dist/server/live.js +542 -0
  232. package/dist/server/live.js.map +1 -0
  233. package/dist/server/middleware-node.d.ts +48 -0
  234. package/dist/server/middleware-node.d.ts.map +1 -0
  235. package/dist/server/middleware-node.js +167 -0
  236. package/dist/server/middleware-node.js.map +1 -0
  237. package/dist/server/middleware-runtime.d.ts +97 -0
  238. package/dist/server/middleware-runtime.d.ts.map +1 -0
  239. package/dist/server/middleware-runtime.js +447 -0
  240. package/dist/server/middleware-runtime.js.map +1 -0
  241. package/dist/server/middleware.d.ts +145 -0
  242. package/dist/server/middleware.d.ts.map +1 -0
  243. package/dist/server/middleware.js +365 -0
  244. package/dist/server/middleware.js.map +1 -0
  245. package/dist/server/ppr.d.ts +22 -0
  246. package/dist/server/ppr.d.ts.map +1 -0
  247. package/dist/server/ppr.js +58 -0
  248. package/dist/server/ppr.js.map +1 -0
  249. package/dist/server/scan.d.ts +48 -0
  250. package/dist/server/scan.d.ts.map +1 -0
  251. package/dist/server/scan.js +161 -0
  252. package/dist/server/scan.js.map +1 -0
  253. package/dist/server/server-components.d.ts +65 -0
  254. package/dist/server/server-components.d.ts.map +1 -0
  255. package/dist/server/server-components.js +215 -0
  256. package/dist/server/server-components.js.map +1 -0
  257. package/dist/terminal.d.ts +5 -0
  258. package/dist/terminal.d.ts.map +1 -0
  259. package/dist/terminal.js +5 -0
  260. package/dist/terminal.js.map +1 -0
  261. package/dist/testing.d.ts +5 -0
  262. package/dist/testing.d.ts.map +1 -0
  263. package/dist/testing.js +5 -0
  264. package/dist/testing.js.map +1 -0
  265. package/dist/vite-plugin.d.ts +51 -0
  266. package/dist/vite-plugin.d.ts.map +1 -0
  267. package/dist/vite-plugin.js +403 -0
  268. package/dist/vite-plugin.js.map +1 -0
  269. package/package.json +130 -4
  270. package/src/cli/build.ts +182 -0
  271. package/src/cli/config.ts +72 -0
  272. package/src/cli/dev.ts +54 -0
  273. package/src/cli/index.ts +185 -0
  274. package/src/cli/run.ts +124 -0
  275. package/src/cli/start.ts +194 -0
  276. package/src/cli/ui.ts +93 -0
  277. package/src/client.tsx +122 -0
  278. package/src/config.ts +155 -0
  279. package/src/context.ts +69 -0
  280. package/src/dev-errors.ts +167 -0
  281. package/src/devtools.tsx +419 -0
  282. package/src/frame-mirror.ts +145 -0
  283. package/src/frame-protocol.ts +435 -0
  284. package/src/history.ts +201 -0
  285. package/src/index.ts +9 -0
  286. package/src/link.tsx +108 -0
  287. package/src/live-player.ts +449 -0
  288. package/src/live-protocol.ts +317 -0
  289. package/src/live-stub.tsx +32 -0
  290. package/src/mcp/cli.ts +188 -0
  291. package/src/mcp/frame.ts +224 -0
  292. package/src/mcp/index.ts +15 -0
  293. package/src/mcp/inspect.ts +445 -0
  294. package/src/mcp/keys.ts +86 -0
  295. package/src/mcp/logs.ts +225 -0
  296. package/src/mcp/server.ts +595 -0
  297. package/src/mcp/session.ts +821 -0
  298. package/src/navigation-errors.ts +60 -0
  299. package/src/navigation.ts +44 -0
  300. package/src/router.tsx +497 -0
  301. package/src/routing.ts +344 -0
  302. package/src/server/adapters/build-step.ts +135 -0
  303. package/src/server/adapters/bun.ts +92 -0
  304. package/src/server/adapters/bundle.ts +97 -0
  305. package/src/server/adapters/cloudflare.ts +111 -0
  306. package/src/server/adapters/index.ts +29 -0
  307. package/src/server/adapters/runtime-node.ts +34 -0
  308. package/src/server/adapters/runtime.ts +145 -0
  309. package/src/server/adapters/shared.ts +159 -0
  310. package/src/server/adapters/static.ts +68 -0
  311. package/src/server/adapters/vercel.ts +149 -0
  312. package/src/server/bun-jsx.ts +65 -0
  313. package/src/server/cache-route.ts +139 -0
  314. package/src/server/cache.ts +498 -0
  315. package/src/server/frames.ts +326 -0
  316. package/src/server/http.ts +467 -0
  317. package/src/server/jsx-dev-runtime.ts +9 -0
  318. package/src/server/jsx-runtime.ts +13 -0
  319. package/src/server/live-app.tsx +374 -0
  320. package/src/server/live-build.ts +258 -0
  321. package/src/server/live-dev.ts +357 -0
  322. package/src/server/live-manifest.ts +25 -0
  323. package/src/server/live-prod.ts +120 -0
  324. package/src/server/live-routes.ts +298 -0
  325. package/src/server/live-web.ts +170 -0
  326. package/src/server/live.ts +551 -0
  327. package/src/server/middleware-node.ts +168 -0
  328. package/src/server/middleware-runtime.ts +478 -0
  329. package/src/server/middleware.ts +451 -0
  330. package/src/server/ppr.ts +62 -0
  331. package/src/server/scan.ts +190 -0
  332. package/src/server/server-components.ts +224 -0
  333. package/src/terminal.ts +5 -0
  334. package/src/testing.ts +5 -0
  335. package/src/vite-plugin.ts +413 -0
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Dev-only frame mirror endpoints for editor previews (the VS Code extension).
3
+ *
4
+ * - `GET /__textjs/frames` — server-sent events: `frame` ({@link WireFrame}) and `status` ({@link DevStatus}).
5
+ * - `POST /__textjs/input` — forward an {@link InputMessage} to the browser tab(s).
6
+ * - `GET /__textjs/status` — {@link DevStatus} JSON.
7
+ *
8
+ * Loopback peers only, addressed by a loopback `Host` (so DNS rebinding can't
9
+ * reach them even when Vite's `allowedHosts` is relaxed). Browsers can't reach
10
+ * them cross-origin: SSE and status send no CORS headers, Fetch Metadata
11
+ * rejects cross-site and `no-cors` requests, and input requires a JSON body
12
+ * plus a custom header, so a foreign page can't send keystrokes with a simple
13
+ * request. Tabs only encode frames while at least one editor is watching.
14
+ */
15
+
16
+ import type { IncomingMessage, ServerResponse } from "node:http";
17
+ import { isIP } from "node:net";
18
+ import type { ViteDevServer } from "vite";
19
+ import { VERSION } from "@textjs/core";
20
+ import {
21
+ encodeFullFrame,
22
+ FrameDecoder,
23
+ FRAMES_PATH,
24
+ HMR_FRAME_EVENT,
25
+ HMR_HELLO_EVENT,
26
+ HMR_INPUT_EVENT,
27
+ HMR_MIRROR_EVENT,
28
+ INPUT_PATH,
29
+ isAppPath,
30
+ isSourceId,
31
+ MIRROR_HEADER,
32
+ parseWireFrame,
33
+ parseInputMessage,
34
+ shouldSwitchSource,
35
+ STATUS_PATH,
36
+ type DevStatus,
37
+ type FrameMeta,
38
+ type WireFrame,
39
+ } from "../frame-protocol";
40
+
41
+ const LOOPBACK = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
42
+ const MAX_INPUT_BYTES = 128 * 1024;
43
+ const HEARTBEAT_MS = 15_000;
44
+
45
+ export function isLoopbackAddress(address: string | undefined): boolean {
46
+ return address !== undefined && (LOOPBACK.has(address) || address.startsWith("127."));
47
+ }
48
+
49
+ /**
50
+ * `Host` names the loopback interface: `localhost`, `*.localhost`, `127.x.x.x`
51
+ * or `[::1]`, with an optional port. Anything else may be a rebound DNS name.
52
+ */
53
+ export function isLoopbackHost(host: string | undefined): boolean {
54
+ if (!host) return false;
55
+ const m = /^(\[[^\]]*\]|[^:[\]]+)(?::\d{1,5})?$/.exec(host.trim().toLowerCase());
56
+ if (!m) return false;
57
+ const name = m[1] as string;
58
+ if (name === "[::1]") return true;
59
+ if (name === "localhost" || name.endsWith(".localhost")) return true;
60
+ return isIP(name) === 4 && name.startsWith("127.");
61
+ }
62
+
63
+ /**
64
+ * Fetch Metadata: browsers label every request. Only same-origin requests and
65
+ * user-initiated ones (`none`) are accepted, and never `no-cors` loads of the
66
+ * stream (`<img>`, `fetch(..., { mode: "no-cors" })` omit `Origin`). Clients
67
+ * that send no metadata (editors, curl) pass.
68
+ */
69
+ export function isFetchMetadataAllowed(req: Pick<IncomingMessage, "headers">, pathname: string): boolean {
70
+ const site = req.headers["sec-fetch-site"];
71
+ if (site !== undefined && site !== "same-origin" && site !== "none") return false;
72
+ if (pathname === FRAMES_PATH && req.headers["sec-fetch-mode"] === "no-cors") return false;
73
+ return true;
74
+ }
75
+
76
+ /** `Origin` is absent (editors, curl) or the dev server's own origin. */
77
+ export function isSameOriginRequest(req: Pick<IncomingMessage, "headers">): boolean {
78
+ const origin = req.headers.origin;
79
+ if (origin === undefined) return true;
80
+ const host = req.headers.host;
81
+ if (!host) return false;
82
+ try {
83
+ return new URL(origin).host === host;
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ interface Source {
90
+ id: string;
91
+ decoder: FrameDecoder;
92
+ client: { send(event: string, payload?: unknown): void };
93
+ updatedAt: number;
94
+ /** Size and path reported by `textjs:hello` (kept current while nobody watches). */
95
+ grid: { width: number; height: number } | null;
96
+ path: string | null;
97
+ }
98
+
99
+ export interface FrameStream {
100
+ /** Connected editors. */
101
+ readonly watchers: number;
102
+ status(): DevStatus;
103
+ close(): void;
104
+ }
105
+
106
+ class PayloadTooLarge extends Error {}
107
+
108
+ /** Read a request body up to `limit` bytes. Past the limit it stops reading and rejects with {@link PayloadTooLarge}. */
109
+ function readBody(req: IncomingMessage, limit: number): Promise<string> {
110
+ return new Promise((resolve, reject) => {
111
+ const declared = Number(req.headers["content-length"]);
112
+ if (Number.isFinite(declared) && declared > limit) {
113
+ reject(new PayloadTooLarge());
114
+ return;
115
+ }
116
+ let size = 0;
117
+ const chunks: Buffer[] = [];
118
+ const onData = (c: Buffer): void => {
119
+ size += c.length;
120
+ if (size > limit) {
121
+ req.off("data", onData);
122
+ req.pause();
123
+ reject(new PayloadTooLarge());
124
+ return;
125
+ }
126
+ chunks.push(c);
127
+ };
128
+ req.on("data", onData);
129
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
130
+ req.on("error", reject);
131
+ });
132
+ }
133
+
134
+ /** Register the frame mirror middleware and HMR channel on a Vite dev server. */
135
+ export interface FrameStreamOptions {
136
+ /** The app's `basePath`: reported in status, and the endpoints also answer under it. */
137
+ basePath?: string;
138
+ }
139
+
140
+ export function attachFrameStream(server: ViteDevServer, root: string, options: FrameStreamOptions = {}): FrameStream {
141
+ const basePath = options.basePath ?? "";
142
+ const sources = new Map<string, Source>();
143
+ const watchers = new Set<ServerResponse>();
144
+ let latest: Source | null = null;
145
+
146
+ const status = (): DevStatus => {
147
+ const f = latest?.decoder.frame ?? null;
148
+ return {
149
+ name: "textjs",
150
+ version: VERSION,
151
+ root,
152
+ clients: server.ws.clients.size,
153
+ sources: sources.size,
154
+ watchers: watchers.size,
155
+ grid: f ? { width: f.width, height: f.height } : (latest?.grid ?? null),
156
+ path: latest?.decoder.meta.path ?? latest?.path ?? null,
157
+ basePath,
158
+ };
159
+ };
160
+
161
+ const write = (res: ServerResponse, event: string, data: unknown): void => {
162
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
163
+ };
164
+ const broadcast = (event: string, data: unknown): void => {
165
+ for (const res of watchers) write(res, event, data);
166
+ };
167
+ const announce = (): void => {
168
+ server.ws.send(HMR_MIRROR_EVENT, { active: watchers.size > 0 });
169
+ };
170
+
171
+ type Client = Source["client"];
172
+ const mostRecent = (): Source | null => [...sources.values()].sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? null;
173
+ const sourceFor = (id: string, client: Client): Source => {
174
+ let src = sources.get(id);
175
+ if (src?.client === client) return src;
176
+ if (src) src.client = client;
177
+ else {
178
+ src = { id, decoder: new FrameDecoder(), client, updatedAt: Date.now(), grid: null, path: null };
179
+ sources.set(id, src);
180
+ }
181
+ // A tab that reconnects keeps its id on a new socket: only the socket that currently owns the source may remove it.
182
+ const socket = (client as { socket?: { once?(e: string, cb: () => void): void } }).socket;
183
+ socket?.once?.("close", () => {
184
+ const gone = sources.get(id);
185
+ if (!gone || gone.client !== client) return;
186
+ sources.delete(id);
187
+ if (latest === gone) latest = mostRecent();
188
+ broadcast("gone", { source: id });
189
+ broadcast("status", status());
190
+ });
191
+ return src;
192
+ };
193
+ const metaOf = (src: Source | null): FrameMeta | null => (src ? src.decoder.meta : null);
194
+
195
+ server.ws.on(HMR_HELLO_EVENT, (data: unknown, client) => {
196
+ client.send(HMR_MIRROR_EVENT, { active: watchers.size > 0 });
197
+ const d = (data ?? {}) as { source?: unknown; grid?: { width?: unknown; height?: unknown }; path?: unknown };
198
+ if (isSourceId(d.source)) {
199
+ const src = sourceFor(d.source, client);
200
+ const w = d.grid?.width;
201
+ const h = d.grid?.height;
202
+ if (Number.isInteger(w) && Number.isInteger(h) && (w as number) > 0 && (h as number) > 0 && (w as number) <= 1000 && (h as number) <= 1000) {
203
+ src.grid = { width: w as number, height: h as number };
204
+ }
205
+ if (isAppPath(d.path)) src.path = d.path;
206
+ src.updatedAt = Date.now();
207
+ if (latest === null || latest === src || shouldSwitchSource(metaOf(latest), {})) latest = src;
208
+ }
209
+ broadcast("status", status());
210
+ });
211
+
212
+ server.ws.on(HMR_FRAME_EVENT, (raw: unknown, client) => {
213
+ const data = parseWireFrame(raw);
214
+ if (!data || data.source === undefined) return;
215
+ const src = sourceFor(data.source, client);
216
+ if (src.decoder.apply(data) === null) return;
217
+ src.updatedAt = Date.now();
218
+ const switched = latest !== src && shouldSwitchSource(metaOf(latest), src.decoder.meta);
219
+ if (switched) latest = src;
220
+ broadcast("frame", data);
221
+ if (data.full || switched) broadcast("status", status());
222
+ });
223
+
224
+ const heartbeat = setInterval(() => {
225
+ for (const res of watchers) res.write(": ♥\n\n");
226
+ }, HEARTBEAT_MS);
227
+ heartbeat.unref?.();
228
+
229
+ const reject = (res: ServerResponse, code: number, message: string): void => {
230
+ res.statusCode = code;
231
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
232
+ res.end(message);
233
+ };
234
+
235
+ server.middlewares.use((req, res, next) => {
236
+ let pathname = (req.url ?? "/").split("?")[0] as string;
237
+ if (basePath && pathname.startsWith(`${basePath}/__textjs/`)) pathname = pathname.slice(basePath.length);
238
+ if (pathname !== FRAMES_PATH && pathname !== INPUT_PATH && pathname !== STATUS_PATH) return next();
239
+ res.setHeader("X-Content-Type-Options", "nosniff");
240
+ if (!isLoopbackAddress(req.socket.remoteAddress) || !isLoopbackHost(req.headers.host) || !isSameOriginRequest(req) || !isFetchMetadataAllowed(req, pathname)) {
241
+ return reject(res, 403, "▼ Text.js: the frame mirror only accepts local requests.");
242
+ }
243
+
244
+ if (pathname === STATUS_PATH) {
245
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
246
+ res.setHeader("Cache-Control", "no-store");
247
+ res.end(JSON.stringify(status()));
248
+ return;
249
+ }
250
+
251
+ if (pathname === FRAMES_PATH) {
252
+ if (req.method !== "GET") return reject(res, 405, "Method Not Allowed");
253
+ res.writeHead(200, {
254
+ "Content-Type": "text/event-stream; charset=utf-8",
255
+ "Cache-Control": "no-store",
256
+ Connection: "keep-alive",
257
+ "X-Accel-Buffering": "no",
258
+ });
259
+ res.write("retry: 1000\n\n");
260
+ watchers.add(res);
261
+ write(res, "status", status());
262
+ // The latest source last: editors mirror whichever tab sent the most recent frame.
263
+ for (const src of [...sources.values()].filter((s) => s !== latest).concat(latest ? [latest] : [])) {
264
+ const f = src.decoder.frame;
265
+ if (f) write(res, "frame", encodeFullFrame(f, { source: src.id, meta: src.decoder.meta, seq: src.decoder.seq }) satisfies WireFrame);
266
+ }
267
+ if (watchers.size === 1) announce();
268
+ req.on("close", () => {
269
+ if (!watchers.delete(res) || watchers.size > 0) return;
270
+ // Tabs stop streaming now, so these frames go stale: keep the size and path for status, and
271
+ // wait for the full frames tabs send when the next editor subscribes.
272
+ for (const src of sources.values()) {
273
+ const f = src.decoder.frame;
274
+ if (f) src.grid = { width: f.width, height: f.height };
275
+ if (src.decoder.meta.path) src.path = src.decoder.meta.path;
276
+ src.decoder.reset();
277
+ }
278
+ announce();
279
+ });
280
+ return;
281
+ }
282
+
283
+ if (req.method !== "POST") return reject(res, 405, "Method Not Allowed");
284
+ if (!(req.headers["content-type"] ?? "").startsWith("application/json") || req.headers[MIRROR_HEADER] !== "1") {
285
+ return reject(res, 400, `▼ Text.js: send JSON with the ${MIRROR_HEADER}: 1 header.`);
286
+ }
287
+ void readBody(req, MAX_INPUT_BYTES).then(
288
+ (body) => {
289
+ let parsed: unknown;
290
+ try {
291
+ parsed = JSON.parse(body);
292
+ } catch {
293
+ return reject(res, 400, "Invalid JSON");
294
+ }
295
+ const target = (parsed as { source?: unknown })?.source;
296
+ const msg = parseInputMessage(parsed);
297
+ if (!msg) return reject(res, 400, "Invalid input message");
298
+ // Never broadcast: input for a tab that just closed (or when no tab is connected) is dropped.
299
+ const src = target === undefined ? latest : typeof target === "string" ? (sources.get(target) ?? null) : null;
300
+ if (!src) return reject(res, 404, "Unknown source");
301
+ src.client.send(HMR_INPUT_EVENT, msg);
302
+ res.statusCode = 204;
303
+ res.end();
304
+ },
305
+ (error: unknown) => {
306
+ if (!(error instanceof PayloadTooLarge)) return void req.destroy();
307
+ // Answer before dropping the connection so the client sees 413 rather than a reset.
308
+ res.setHeader("Connection", "close");
309
+ res.once("finish", () => req.destroy());
310
+ reject(res, 413, "Payload Too Large");
311
+ },
312
+ );
313
+ });
314
+
315
+ return {
316
+ get watchers() {
317
+ return watchers.size;
318
+ },
319
+ status,
320
+ close() {
321
+ clearInterval(heartbeat);
322
+ for (const res of watchers) res.end();
323
+ watchers.clear();
324
+ },
325
+ };
326
+ }