@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,821 @@
1
+ /**
2
+ * A headless Text.js app session for `text mcp`: the `app/` directory mounted
3
+ * in-process (same route manifest and in-process route handlers as `text run`,
4
+ * memory history), rendered into an offscreen cell grid and driven with keys.
5
+ *
6
+ * Frame pacing is deterministic for agents: every input waits for the app to
7
+ * go idle — no scheduled frame, no in-flight `fetch`, and no commit or frame
8
+ * for `quietMs` — before the frame is read.
9
+ */
10
+
11
+ import { AsyncLocalStorage } from "node:async_hooks";
12
+ import { existsSync, realpathSync } from "node:fs";
13
+ import { dirname, relative, resolve, sep } from "node:path";
14
+ import { fileURLToPath, pathToFileURL } from "node:url";
15
+ import { createElement } from "react";
16
+ import { ATTR_INVERSE, ATTR_WIDE_CONT, initYoga, InputEngine, type ConsoleTarget, type KeyInputEvent, type RendererScheduler, type WheelInputEvent } from "@textjs/core";
17
+ import { createConsoleRootSync, type ConsoleRoot, type ThemeName } from "@textjs/react";
18
+ import { createMemoryHistory, type MemoryHistory } from "../history";
19
+ import { isNavigationError } from "../navigation-errors";
20
+ import { AppRouter } from "../router";
21
+ import { resolveMetadata, resolveRoute, searchParamsToRecord, type FlatRoute, type RouteManifest } from "../routing";
22
+ import { listApiRoutes, listPages, matchApiRoute, scanApp, toApiManifest, type ApiRoute, type AppScan } from "../server/scan";
23
+ import type { RouteHandlerModule } from "../server/http";
24
+ import { runRouteHandlerWithCache } from "../server/cache-route";
25
+ import { loadRuntimeManifest } from "../cli/run";
26
+ import { installServerJsxRuntime } from "../server/bun-jsx";
27
+ import { prepareServerManifest } from "../server/live-app";
28
+ import { ServerComponentCache, ServerComponentsProvider } from "../server/server-components";
29
+ import { loadTextConfig } from "../cli/config";
30
+ import { clampRegion, frameContains, frameText, styledRows, type Region, type StyledRow } from "./frame";
31
+ import { FiberProbe, inspect, type Bounds, type InspectOptions, type InspectResult, type SceneGrid } from "./inspect";
32
+ import { parseKeyInput, textToKeyEvents } from "./keys";
33
+ import { LogBuffer, txCodeOf } from "./logs";
34
+
35
+ export interface SessionOptions {
36
+ /** Project root (the directory containing `app/`). */
37
+ root: string;
38
+ /** Initial pathname (default `/`). */
39
+ path?: string;
40
+ /** Grid size (default: `text.config.ts` `viewport`, else 100×32). */
41
+ cols?: number;
42
+ rows?: number;
43
+ theme?: ThemeName;
44
+ /** Skip `text.config.ts`. */
45
+ skipConfig?: boolean;
46
+ /** Shared log buffer (console capture writes into the same buffer). */
47
+ logs?: LogBuffer;
48
+ /** Origin same-origin `fetch()` paths resolve against (default `http://localhost:3000`). */
49
+ origin?: string;
50
+ /** Quiet period that counts as idle (default 50ms). */
51
+ quietMs?: number;
52
+ }
53
+
54
+ export interface SettleResult {
55
+ idle: boolean;
56
+ waitedMs: number;
57
+ }
58
+
59
+ export interface FocusSummary {
60
+ id: string;
61
+ component?: string;
62
+ bounds?: Bounds;
63
+ }
64
+
65
+ export interface FrameSnapshot {
66
+ text: string;
67
+ cols: number;
68
+ rows: number;
69
+ /** Current location (`pathname + search + hash`). */
70
+ href: string;
71
+ /** Matched route pattern (`/deployments/[id]`, or `/_not-found`). */
72
+ route: string;
73
+ title?: string;
74
+ /** Count of distinct frames painted since the session started. */
75
+ frame: number;
76
+ idle: boolean;
77
+ focus: FocusSummary | null;
78
+ /** Block-cursor position inside the focused component, when one is visible. */
79
+ cursor: { x: number; y: number } | null;
80
+ /** Errors logged since the previous frame read (see the `logs` tool). */
81
+ newErrors: number;
82
+ /** Set when the app is not mounted (it called `exit()`, crashed, or failed to load). */
83
+ status: "running" | "exited" | "crashed" | "failed";
84
+ styles?: StyledRow[];
85
+ /** Effective (clamped) rectangle for region reads. */
86
+ region?: Region;
87
+ }
88
+
89
+ export interface FetchResult {
90
+ status: number;
91
+ statusText: string;
92
+ headers: Record<string, string>;
93
+ body: string;
94
+ /** Body was cut at `maxBytes`, or an event stream was cut after `streamMs`. */
95
+ truncated: boolean;
96
+ /** The response was still streaming when reading stopped (event streams). */
97
+ streaming?: boolean;
98
+ /** Matched `app/**\/route.ts` pattern. */
99
+ route: string | null;
100
+ }
101
+
102
+ /** A tool-facing error with an optional `TX###` code. */
103
+ export class SessionError extends Error {
104
+ readonly code: string | undefined;
105
+ constructor(message: string, code?: string) {
106
+ super(message);
107
+ this.name = "SessionError";
108
+ this.code = code;
109
+ }
110
+ }
111
+
112
+ const nullConsole: ConsoleTarget = { clear() {}, log() {} };
113
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
114
+ const MIN_FRAME_GAP_MS = 8;
115
+ /**
116
+ * React throttles revealing resolved Suspense boundaries (300ms after the
117
+ * last fallback commit), so a settled server-component promise may commit
118
+ * that much later: stay busy until it can have.
119
+ */
120
+ const SUSPENSE_REVEAL_MS = 350;
121
+
122
+ /** `setTimeout`-driven frames, with the pending set visible for idle detection. */
123
+ class TrackedScheduler implements RendererScheduler {
124
+ readonly pending = new Set<ReturnType<typeof setTimeout>>();
125
+ private lastRun = 0;
126
+ constructor(private readonly onActivity: () => void) {}
127
+ request(cb: () => void): unknown {
128
+ this.onActivity();
129
+ // A small gap caps runaway render loops at ~120fps without slowing input.
130
+ const delay = Math.max(0, this.lastRun + MIN_FRAME_GAP_MS - Date.now());
131
+ const h = setTimeout(() => {
132
+ this.pending.delete(h);
133
+ this.lastRun = Date.now();
134
+ cb();
135
+ }, delay);
136
+ this.pending.add(h);
137
+ return h;
138
+ }
139
+ cancel(handle: unknown): void {
140
+ clearTimeout(handle as ReturnType<typeof setTimeout>);
141
+ this.pending.delete(handle as ReturnType<typeof setTimeout>);
142
+ }
143
+ dispose(): void {
144
+ for (const h of this.pending) clearTimeout(h);
145
+ this.pending.clear();
146
+ }
147
+ }
148
+
149
+ function errorMessage(error: unknown): string {
150
+ if (error instanceof Error) return error.message;
151
+ return String(error);
152
+ }
153
+
154
+ /** `@textjs/app` sources (`src/` or `dist/`): never purged on reload. */
155
+ const PACKAGE_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
156
+
157
+ export class HeadlessSession {
158
+ readonly logs: LogBuffer;
159
+ readonly rootDir: string;
160
+ private readonly options: SessionOptions;
161
+ private scan!: AppScan;
162
+ private manifest: RouteManifest | null = null;
163
+ private appRoot: ConsoleRoot | null = null;
164
+ private scheduler: TrackedScheduler | null = null;
165
+ private probe: FiberProbe | null = null;
166
+ private readonly history: MemoryHistory;
167
+ private theme: ThemeName | undefined;
168
+ private cols = 100;
169
+ private rows = 32;
170
+ private resized = false;
171
+ private generation = 0;
172
+ private frameSeq = 0;
173
+ private lastActivity = Date.now();
174
+ /** In-flight `fetch` calls per mount generation (a stale app's hung request never blocks idleness). */
175
+ private readonly inflight = new Map<number, number>();
176
+ private errorCursor = 0;
177
+ /** Normalized `options.origin` (no trailing slash). */
178
+ private readonly origin: string;
179
+ private status: FrameSnapshot["status"] = "failed";
180
+ private loadError: unknown = null;
181
+ private disposed = false;
182
+ /** Async Server Components' promises (pending ones keep the session busy). */
183
+ private readonly serverCache: ServerComponentCache;
184
+ readonly quietMs: number;
185
+
186
+ private constructor(options: SessionOptions) {
187
+ this.options = options;
188
+ this.rootDir = resolve(options.root);
189
+ this.logs = options.logs ?? new LogBuffer();
190
+ this.history = createMemoryHistory(options.path ?? "/");
191
+ this.quietMs = options.quietMs ?? 50;
192
+ this.origin = new URL(options.origin ?? "http://localhost:3000").origin;
193
+ this.serverCache = new ServerComponentCache({ onActivity: () => this.touch() });
194
+ this.history.listen(() => this.touch());
195
+ }
196
+
197
+ /** Load `text.config.ts`, import every route module and mount the app. */
198
+ static async start(options: SessionOptions): Promise<HeadlessSession> {
199
+ const session = new HeadlessSession(options);
200
+ await initYoga();
201
+ await session.loadConfig();
202
+ await session.mount();
203
+ if (session.status === "failed") {
204
+ const err = session.loadError;
205
+ await session.dispose();
206
+ throw err instanceof Error ? err : new SessionError(errorMessage(err));
207
+ }
208
+ await session.settle();
209
+ session.errorCursor = session.logs.cursor;
210
+ return session;
211
+ }
212
+
213
+ private touch(): void {
214
+ this.lastActivity = Date.now();
215
+ }
216
+
217
+ private async loadConfig(): Promise<void> {
218
+ let cfgCols: number | undefined;
219
+ let cfgRows: number | undefined;
220
+ let appDir = "app";
221
+ if (!this.options.skipConfig) {
222
+ const { config } = await loadTextConfig(this.rootDir, "serve");
223
+ const vp = (config as { viewport?: { width?: unknown; height?: unknown; theme?: unknown } }).viewport;
224
+ if (typeof vp?.width === "number") cfgCols = vp.width;
225
+ if (typeof vp?.height === "number") cfgRows = vp.height;
226
+ if (typeof vp?.theme === "string" && this.options.theme === undefined) this.theme = vp.theme as ThemeName;
227
+ if (typeof config.appDir === "string") appDir = config.appDir;
228
+ }
229
+ if (this.options.theme !== undefined) this.theme = this.options.theme;
230
+ if (!this.resized) {
231
+ this.cols = clampCols(this.options.cols ?? cfgCols ?? 100);
232
+ this.rows = clampRows(this.options.rows ?? cfgRows ?? 32);
233
+ }
234
+ this.scan = scanApp(this.rootDir, appDir);
235
+ if (!existsSync(this.scan.appDir)) throw new SessionError(`No app/ directory found in ${this.rootDir}.`, "TX018");
236
+ }
237
+
238
+ private trackFetch(gen: number, delta: number): void {
239
+ const n = (this.inflight.get(gen) ?? 0) + delta;
240
+ if (n > 0) this.inflight.set(gen, n);
241
+ else this.inflight.delete(gen);
242
+ this.touch();
243
+ }
244
+
245
+ /**
246
+ * The app's `fetch`: same-origin URLs (`"/api/health"`,
247
+ * `"http://localhost:3000/api/x"`) run `app/**\/route.ts` handlers in-process
248
+ * exactly like `text dev`; everything else goes to the network.
249
+ */
250
+ async handleFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
251
+ const gen = this.generation;
252
+ this.trackFetch(gen, 1);
253
+ try {
254
+ const raw = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
255
+ const url = sameOriginUrl(raw, this.origin);
256
+ if (!url) return await networkFetch()(input, init);
257
+ const hit = matchApiRoute(toApiManifest(this.scan), url.pathname);
258
+ if (!hit) return new Response("Not Found", { status: 404 });
259
+ const mod = (await import(pathToFileURL(hit.file).href)) as RouteHandlerModule;
260
+ const request = input instanceof Request ? new Request(url, init ? new Request(input, init) : input) : new Request(url, init);
261
+ return await runRouteHandlerWithCache(mod, request, { params: hit.params, pattern: hit.pattern, pathname: url.pathname, cache: false });
262
+ } finally {
263
+ this.trackFetch(gen, -1);
264
+ }
265
+ }
266
+
267
+ private recordError(source: "react", error: unknown, componentStack?: string | null): void {
268
+ if (isNavigationError(error)) return;
269
+ const args: unknown[] = [error];
270
+ if (componentStack) args.push(`\nComponent stack:${componentStack}`);
271
+ this.logs.push("error", source, args);
272
+ }
273
+
274
+ private async mount(): Promise<void> {
275
+ attachFetch(this);
276
+ const gen = ++this.generation;
277
+ // `async function` components (e.g. `runtime = "server"` pages) render like in `text dev`.
278
+ installServerJsxRuntime(this.rootDir);
279
+ try {
280
+ this.manifest = await loadRuntimeManifest(this.scan);
281
+ } catch (error) {
282
+ this.status = "failed";
283
+ this.loadError = error;
284
+ this.logs.push("error", "server", ["Failed to load app/ modules:", error]);
285
+ return;
286
+ }
287
+ const scheduler = new TrackedScheduler(() => this.touch());
288
+ this.scheduler = scheduler;
289
+ const root = createConsoleRootSync({
290
+ width: this.cols,
291
+ height: this.rows,
292
+ console: nullConsole,
293
+ input: new InputEngine({ document: null }),
294
+ registerGlobal: false,
295
+ scheduler,
296
+ minFrameInterval: 0,
297
+ theme: this.theme,
298
+ onFrame: (f) => {
299
+ if (f.skipped) return;
300
+ this.frameSeq++;
301
+ this.touch();
302
+ },
303
+ onUncaughtError: (error) => {
304
+ this.recordError("react", error);
305
+ if (gen === this.generation) this.status = "crashed";
306
+ },
307
+ onCaughtError: (error, info) => this.recordError("react", error, info.componentStack),
308
+ onRecoverableError: (error) => this.logs.push("warn", "react", [error]),
309
+ });
310
+ this.appRoot = root;
311
+ void root.waitUntilExit().then(() => {
312
+ if (gen === this.generation && !this.disposed && this.status === "running") this.status = "exited";
313
+ });
314
+ this.status = "running";
315
+ this.loadError = null;
316
+ const probeRef = (p: FiberProbe | null): void => {
317
+ if (gen === this.generation) this.probe = p;
318
+ };
319
+ const tree = (
320
+ createElement(
321
+ ServerComponentsProvider,
322
+ { cache: this.serverCache },
323
+ createElement(
324
+ FiberProbe,
325
+ { ref: probeRef },
326
+ createElement(AppRouter, { routes: prepareServerManifest(this.manifest), history: this.history, theme: this.theme, documentTitle: false, errorStore: null }),
327
+ ),
328
+ )
329
+ );
330
+ fetchScope.run(this, () => {
331
+ root.render(tree);
332
+ root.flushSyncRender();
333
+ });
334
+ }
335
+
336
+ private unmountApp(): void {
337
+ this.generation++;
338
+ this.scheduler?.dispose();
339
+ this.scheduler = null;
340
+ this.serverCache.clear();
341
+ const root = this.appRoot;
342
+ this.appRoot = null;
343
+ this.probe = null;
344
+ if (root && !root.unmounted) {
345
+ try {
346
+ root.unmount();
347
+ } catch (error) {
348
+ this.logs.push("error", "server", ["Error while unmounting:", error]);
349
+ }
350
+ }
351
+ }
352
+
353
+ private requireRoot(): ConsoleRoot {
354
+ if (this.disposed) throw new SessionError("The session is closed.");
355
+ const root = this.appRoot;
356
+ if (!root || this.status === "failed") {
357
+ throw new SessionError(`The app failed to load: ${errorMessage(this.loadError)}\nFix the error and call \`reload\`.`, txCodeOf(this.loadError));
358
+ }
359
+ if (root.unmounted || this.status === "exited") throw new SessionError("The app exited (it called exit()). Call `reload` to start it again.");
360
+ return root;
361
+ }
362
+
363
+ /** Throw a tool error unless the app is mounted and running (not exited, crashed or failed to load). */
364
+ assertRunning(): void {
365
+ this.requireRoot();
366
+ if (this.status === "crashed") throw new SessionError("The app crashed with an uncaught error (see `logs`). Fix it and call `reload`.");
367
+ }
368
+
369
+ /** Whether the app is idle right now (see the module comment). */
370
+ isIdle(quietMs = this.quietMs): boolean {
371
+ if (!this.appRoot) return true;
372
+ const pending = this.scheduler?.pending.size ?? 0;
373
+ const now = Date.now();
374
+ const server = this.serverCache.pending === 0 && now - this.serverCache.lastSettled >= SUSPENSE_REVEAL_MS;
375
+ return pending === 0 && (this.inflight.get(this.generation) ?? 0) === 0 && server && now - this.lastActivity >= quietMs;
376
+ }
377
+
378
+ /**
379
+ * Wait until the app is idle or `timeout` elapses. On timeout (a spinner or
380
+ * live chart that never stops), the pending frame is flushed so reads still
381
+ * see the latest committed tree.
382
+ */
383
+ async settle(options: { quietMs?: number; timeout?: number; signal?: AbortSignal } = {}): Promise<SettleResult> {
384
+ const quietMs = options.quietMs ?? this.quietMs;
385
+ const timeout = options.timeout ?? 1500;
386
+ const start = Date.now();
387
+ for (;;) {
388
+ throwIfAborted(options.signal);
389
+ if (this.isIdle(quietMs)) return { idle: true, waitedMs: Date.now() - start };
390
+ if (Date.now() - start >= timeout) break;
391
+ await sleep(Math.min(10, Math.max(1, quietMs / 4)));
392
+ }
393
+ const root = this.appRoot;
394
+ if (root && !root.unmounted && (this.scheduler?.pending.size ?? 0) > 0) root.flushSyncRender();
395
+ return { idle: false, waitedMs: Date.now() - start };
396
+ }
397
+
398
+ /**
399
+ * Send events in order, letting each one's effects and follow-up commits
400
+ * land before the next. Stops if the app exits or `signal` aborts (a timed
401
+ * out tool call must not keep typing into the next call's frame).
402
+ */
403
+ private async dispatch(events: ReadonlyArray<{ kind: "key"; event: KeyInputEvent } | { kind: "paste"; text: string } | { kind: "wheel"; event: WheelInputEvent }>, signal?: AbortSignal): Promise<void> {
404
+ const root = this.requireRoot();
405
+ for (const e of events) {
406
+ throwIfAborted(signal);
407
+ if (root.unmounted || root !== this.appRoot) break;
408
+ this.touch();
409
+ fetchScope.run(this, () => {
410
+ try {
411
+ if (e.kind === "key") root.input.emit("key", e.event);
412
+ else if (e.kind === "wheel") root.input.emit("wheel", e.event);
413
+ else root.input.handlePaste(e.text);
414
+ } catch (error) {
415
+ this.logs.push("error", "react", ["Error in input handler:", error]);
416
+ }
417
+ });
418
+ await sleep(0);
419
+ }
420
+ }
421
+
422
+ async press(keys: string | readonly string[], signal?: AbortSignal): Promise<number> {
423
+ const events = parseKeyInput(keys);
424
+ await this.dispatch(
425
+ events.map((event) => ({ kind: "key" as const, event })),
426
+ signal,
427
+ );
428
+ return events.length;
429
+ }
430
+
431
+ async type(text: string, signal?: AbortSignal): Promise<number> {
432
+ const events = textToKeyEvents(text);
433
+ await this.dispatch(
434
+ events.map((event) => ({ kind: "key" as const, event })),
435
+ signal,
436
+ );
437
+ return events.length;
438
+ }
439
+
440
+ async paste(text: string, signal?: AbortSignal): Promise<void> {
441
+ await this.dispatch([{ kind: "paste", text }], signal);
442
+ }
443
+
444
+ /**
445
+ * Mouse-wheel scroll by `rows` (positive scrolls down) and `cols`, sent in
446
+ * one-row steps like a wheel. Wheel input is not positional: it reaches the
447
+ * focused `<ScrollBox>` first, else every wheel-enabled one.
448
+ */
449
+ async scroll(rows: number, cols = 0, signal?: AbortSignal): Promise<number> {
450
+ const steps = Math.max(Math.abs(Math.trunc(rows)), Math.abs(Math.trunc(cols)), 1);
451
+ const event = (dy: number, dx: number): WheelInputEvent => ({ type: "wheel", deltaX: dx, deltaY: dy, rows: dy, cols: dx, ctrl: false, meta: false, shift: false, alt: false });
452
+ const events = Array.from({ length: steps }, (_, i) => {
453
+ const dy = i < Math.abs(Math.trunc(rows)) ? Math.sign(rows) : 0;
454
+ const dx = i < Math.abs(Math.trunc(cols)) ? Math.sign(cols) : 0;
455
+ return { kind: "wheel" as const, event: event(dy, dx) };
456
+ }).filter((e) => e.event.rows !== 0 || e.event.cols !== 0);
457
+ await this.dispatch(events, signal);
458
+ return events.length;
459
+ }
460
+
461
+ resize(cols: number, rows: number): { cols: number; rows: number } {
462
+ const root = this.requireRoot();
463
+ this.resized = true;
464
+ this.cols = clampCols(cols);
465
+ this.rows = clampRows(rows);
466
+ this.touch();
467
+ root.renderer.resize(this.cols, this.rows);
468
+ return { cols: this.cols, rows: this.rows };
469
+ }
470
+
471
+ navigate(target: { path: string; replace?: boolean } | "back" | "forward"): void {
472
+ this.requireRoot();
473
+ this.touch();
474
+ if (target === "back") this.history.back();
475
+ else if (target === "forward") this.history.forward();
476
+ else if (target.replace) this.history.replace(target.path);
477
+ else this.history.push(target.path);
478
+ }
479
+
480
+ get href(): string {
481
+ return this.history.location.href;
482
+ }
483
+
484
+ routes(): { pages: FlatRoute[]; api: ApiRoute[]; current: string } {
485
+ return {
486
+ pages: listPages(this.scan),
487
+ api: listApiRoutes(this.scan).map((r) => ({ pattern: r.pattern, file: toProjectPath(this.rootDir, r.file) })),
488
+ current: this.href,
489
+ };
490
+ }
491
+
492
+ /** Read the current frame. Errors logged since the previous read are counted in `newErrors`. */
493
+ frame(options: { styles?: boolean; region?: Partial<Region>; trim?: boolean; /** Don't reset the `newErrors` counter. */ peek?: boolean } = {}): FrameSnapshot {
494
+ const root = this.appRoot;
495
+ const loc = this.history.location;
496
+ let route = loc.pathname;
497
+ let title: string | undefined;
498
+ if (this.manifest) {
499
+ const match = resolveRoute(this.manifest, loc.pathname);
500
+ route = match.pattern;
501
+ title = resolveMetadata(match, searchParamsToRecord(loc.search)).title;
502
+ }
503
+ const newErrors = this.logs.errorsSince(this.errorCursor);
504
+ if (!options.peek) this.errorCursor = this.logs.cursor;
505
+ const base = { href: loc.href, route, ...(title !== undefined ? { title } : {}), frame: this.frameSeq, newErrors, status: this.status };
506
+ if (!root) return { ...base, text: "", cols: this.cols, rows: this.rows, idle: true, focus: null, cursor: null };
507
+ const buffer = root.renderer.buffer;
508
+ const focus = this.focusSummary();
509
+ return {
510
+ ...base,
511
+ text: frameText(buffer, options.region, options.trim),
512
+ ...(options.region ? { region: clampRegion(buffer, options.region) } : {}),
513
+ cols: buffer.width,
514
+ rows: buffer.height,
515
+ idle: this.isIdle(),
516
+ focus,
517
+ cursor: focus?.bounds ? findCursor(root, focus.bounds) : null,
518
+ ...(options.styles ? { styles: styledRows(buffer, options.region) } : {}),
519
+ };
520
+ }
521
+
522
+ contains(text: string): boolean {
523
+ const root = this.appRoot;
524
+ return root ? frameContains(root.renderer.buffer, text) : false;
525
+ }
526
+
527
+ get buffer(): ConsoleRoot["renderer"]["buffer"] | null {
528
+ return this.appRoot?.renderer.buffer ?? null;
529
+ }
530
+
531
+ get fiberProbe(): FiberProbe | null {
532
+ return this.probe;
533
+ }
534
+
535
+ /** The mounted scene and grid size, for paint-order hit testing. */
536
+ get sceneGrid(): SceneGrid | null {
537
+ const root = this.appRoot;
538
+ if (!root || root.unmounted) return null;
539
+ return { root: root.rootNode, cols: root.renderer.buffer.width, rows: root.renderer.buffer.height };
540
+ }
541
+
542
+ inspect(options: InspectOptions = {}): InspectResult {
543
+ const root = this.requireRoot();
544
+ return inspect(this.probe, root.focus, options);
545
+ }
546
+
547
+ private focusSummary(): FocusSummary | null {
548
+ const root = this.appRoot;
549
+ if (!root) return null;
550
+ const id = root.focus.getActiveId();
551
+ if (id === null) return null;
552
+ const info = inspect(this.probe, root.focus, { hosts: false }).focusables.find((f) => f.id === id);
553
+ return { id, ...(info?.component ? { component: info.component } : {}), ...(info?.bounds ? { bounds: info.bounds } : {}) };
554
+ }
555
+
556
+ /**
557
+ * Call an `app/**\/route.ts` handler in-process. The body is read up to
558
+ * `maxBytes` (then cancelled); event streams are read for `streamMs` and
559
+ * cancelled, so an SSE route returns its first events instead of hanging.
560
+ */
561
+ async request(
562
+ path: string,
563
+ init: { method?: string; headers?: Record<string, string>; body?: string; maxBytes?: number; streamMs?: number } = {},
564
+ signal?: AbortSignal,
565
+ ): Promise<FetchResult> {
566
+ const url = path.startsWith("/") ? sameOriginUrl(path, this.origin) : null;
567
+ if (!url) throw new SessionError(`fetch only calls the app's own route handlers; pass a path like "/api/health" (got ${JSON.stringify(path)}).`);
568
+ attachFetch(this);
569
+ const hit = matchApiRoute(toApiManifest(this.scan), url.pathname);
570
+ const method = (init.method ?? "GET").toUpperCase();
571
+ const response = await abortable(
572
+ fetchScope.run(this, () => this.handleFetch(url.href, { method, headers: init.headers, body: method === "GET" || method === "HEAD" ? undefined : init.body, signal })),
573
+ signal,
574
+ );
575
+ const maxBytes = init.maxBytes ?? 64 * 1024;
576
+ const headers: Record<string, string> = {};
577
+ response.headers.forEach((v, k) => {
578
+ headers[k] = v;
579
+ });
580
+ const type = response.headers.get("content-type") ?? "";
581
+ const stream = /event-stream/i.test(type);
582
+ const read = await readBody(response, maxBytes, stream ? (init.streamMs ?? 1000) : Number.POSITIVE_INFINITY, signal);
583
+ const textual = type === "" || /^text\/|json|xml|javascript|ndjson|x-www-form-urlencoded|event-stream/i.test(type);
584
+ const body = textual ? new TextDecoder().decode(read.bytes) : `<${read.bytes.length}${read.truncated ? "+" : ""} bytes of ${type}>`;
585
+ return {
586
+ status: response.status,
587
+ statusText: response.statusText,
588
+ headers,
589
+ body,
590
+ truncated: read.truncated,
591
+ ...(stream && read.open ? { streaming: true } : {}),
592
+ route: hit ? hit.pattern : null,
593
+ };
594
+ }
595
+
596
+ /**
597
+ * Re-import the app after file changes: unmount, purge every module under
598
+ * the project root from the module cache, re-scan `app/`, re-import and
599
+ * remount at the current location (history is kept).
600
+ */
601
+ async reload(): Promise<{ purged: number }> {
602
+ if (this.disposed) throw new SessionError("The session is closed.");
603
+ this.unmountApp();
604
+ const purged = purgeModuleCache(this.rootDir);
605
+ try {
606
+ await this.loadConfig();
607
+ } catch (error) {
608
+ this.status = "failed";
609
+ this.loadError = error;
610
+ this.logs.push("error", "server", ["Failed to load the app:", error]);
611
+ throw new SessionError(`Reload failed: ${errorMessage(error)}\nFix the error and call \`reload\`.`, txCodeOf(error));
612
+ }
613
+ await this.mount();
614
+ if (this.status === "failed") {
615
+ throw new SessionError(`Reload failed: ${errorMessage(this.loadError)}`, txCodeOf(this.loadError));
616
+ }
617
+ this.touch();
618
+ return { purged };
619
+ }
620
+
621
+ async dispose(): Promise<void> {
622
+ if (this.disposed) return;
623
+ this.disposed = true;
624
+ this.unmountApp();
625
+ detachFetch(this);
626
+ this.history.dispose?.();
627
+ }
628
+ }
629
+
630
+ function clampCols(n: number): number {
631
+ return Math.max(10, Math.min(1000, Math.floor(Number.isFinite(n) ? n : 100)));
632
+ }
633
+
634
+ function clampRows(n: number): number {
635
+ return Math.max(3, Math.min(500, Math.floor(Number.isFinite(n) ? n : 32)));
636
+ }
637
+
638
+ function toProjectPath(root: string, file: string): string {
639
+ return relative(root, file).split(sep).join("/");
640
+ }
641
+
642
+ /** A single inverse-video cell inside the focused component is the block cursor of an `<Input>` / `<Textarea>`. */
643
+ function findCursor(root: ConsoleRoot, b: Bounds): { x: number; y: number } | null {
644
+ const buf = root.renderer.buffer;
645
+ let hit: { x: number; y: number } | null = null;
646
+ for (let y = b.y; y < b.y + b.height; y++) {
647
+ for (let x = b.x; x < b.x + b.width; x++) {
648
+ const c = buf.getCell(x, y);
649
+ // The second cell of a wide grapheme under the cursor is the same cursor.
650
+ if (!c || (c.attrs & ATTR_INVERSE) === 0 || (c.attrs & ATTR_WIDE_CONT) !== 0) continue;
651
+ if (hit) return null;
652
+ hit = { x, y };
653
+ }
654
+ }
655
+ return hit;
656
+ }
657
+
658
+ function realpath(p: string): string {
659
+ try {
660
+ return realpathSync(p);
661
+ } catch {
662
+ return p;
663
+ }
664
+ }
665
+
666
+ /**
667
+ * Delete every cached module under `root` (except dependencies and Text.js
668
+ * itself) so the next `import()` re-evaluates it. Bun's `require.cache`
669
+ * covers ES modules too.
670
+ */
671
+ export function purgeModuleCache(root: string): number {
672
+ const cache = (typeof require !== "undefined" ? require.cache : undefined) as Record<string, unknown> | undefined;
673
+ if (!cache) return 0;
674
+ const roots = [resolve(root), realpath(resolve(root))].map((r) => (r.endsWith(sep) ? r : r + sep));
675
+ const pkg = [PACKAGE_DIR, realpath(PACKAGE_DIR)].map((r) => r + sep);
676
+ let n = 0;
677
+ for (const key of Object.keys(cache)) {
678
+ const file = key.startsWith("file://") ? fileURLToPath(key) : key;
679
+ if (!roots.some((r) => file.startsWith(r))) continue;
680
+ if (file.includes(`${sep}node_modules${sep}`) || pkg.some((p) => file.startsWith(p))) continue;
681
+ delete cache[key];
682
+ n++;
683
+ }
684
+ return n;
685
+ }
686
+
687
+
688
+ /** Which session an app `fetch()` belongs to, when async context propagates (input dispatch, renders, `request`). */
689
+ const fetchScope = new AsyncLocalStorage<HeadlessSession>();
690
+ /** Sessions routing `globalThis.fetch`, oldest first (the newest handles calls without a scope). */
691
+ const fetchSessions: HeadlessSession[] = [];
692
+ let nativeFetch: typeof fetch | null = null;
693
+ let routedFetch: typeof fetch | null = null;
694
+
695
+ /** The `fetch` that existed before any session installed its router. */
696
+ function networkFetch(): typeof fetch {
697
+ return nativeFetch ?? (globalThis as { fetch: typeof fetch }).fetch;
698
+ }
699
+
700
+ /**
701
+ * Route `globalThis.fetch` through the session registry. One router serves
702
+ * every in-process session, and the original `fetch` comes back only when
703
+ * the last session detaches, whatever the dispose order.
704
+ */
705
+ function attachFetch(session: HeadlessSession): void {
706
+ const g = globalThis as { fetch: typeof fetch };
707
+ if (!routedFetch || g.fetch !== routedFetch) {
708
+ nativeFetch = g.fetch;
709
+ const router = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
710
+ const owner = fetchScope.getStore() ?? fetchSessions[fetchSessions.length - 1];
711
+ return owner ? owner.handleFetch(input, init) : (nativeFetch as typeof fetch)(input, init);
712
+ };
713
+ routedFetch = Object.assign(router, { preconnect: () => undefined }) as unknown as typeof fetch;
714
+ g.fetch = routedFetch;
715
+ }
716
+ if (!fetchSessions.includes(session)) fetchSessions.push(session);
717
+ }
718
+
719
+ function detachFetch(session: HeadlessSession): void {
720
+ const i = fetchSessions.indexOf(session);
721
+ if (i >= 0) fetchSessions.splice(i, 1);
722
+ if (fetchSessions.length > 0) return;
723
+ const g = globalThis as { fetch: typeof fetch };
724
+ if (routedFetch && g.fetch === routedFetch && nativeFetch) g.fetch = nativeFetch;
725
+ routedFetch = null;
726
+ nativeFetch = null;
727
+ }
728
+
729
+ /**
730
+ * `raw` as a URL on `origin`, or `null` when it targets anything else.
731
+ * Paths like `//evil.com` and `/\evil.com` (a backslash is a slash in
732
+ * special-scheme URLs) are protocol-relative, not same-origin.
733
+ */
734
+ export function sameOriginUrl(raw: string, origin: string): URL | null {
735
+ if (raw.startsWith("/") && /^\/[\\/]/.test(raw)) return null;
736
+ let url: URL;
737
+ try {
738
+ url = new URL(raw, origin);
739
+ } catch {
740
+ return null;
741
+ }
742
+ return url.origin === origin ? url : null;
743
+ }
744
+
745
+ function abortReason(signal: AbortSignal): Error {
746
+ return signal.reason instanceof Error ? signal.reason : new SessionError("The operation was cancelled.");
747
+ }
748
+
749
+ function throwIfAborted(signal: AbortSignal | undefined): void {
750
+ if (signal?.aborted) throw abortReason(signal);
751
+ }
752
+
753
+ /** `promise`, rejecting as soon as `signal` aborts. */
754
+ function abortable<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
755
+ if (!signal) return promise;
756
+ if (signal.aborted) return Promise.reject(abortReason(signal));
757
+ return new Promise<T>((resolve, reject) => {
758
+ const onAbort = (): void => reject(abortReason(signal));
759
+ signal.addEventListener("abort", onAbort, { once: true });
760
+ promise.then(
761
+ (v) => {
762
+ signal.removeEventListener("abort", onAbort);
763
+ resolve(v);
764
+ },
765
+ (e: unknown) => {
766
+ signal.removeEventListener("abort", onAbort);
767
+ reject(e);
768
+ },
769
+ );
770
+ });
771
+ }
772
+
773
+ /**
774
+ * Read at most `maxBytes` of a response body, for at most `windowMs`, then
775
+ * cancel it. `open` is set when the body was still streaming.
776
+ */
777
+ export async function readBody(response: Response, maxBytes: number, windowMs: number, signal?: AbortSignal): Promise<{ bytes: Uint8Array; truncated: boolean; open: boolean }> {
778
+ const body = response.body;
779
+ if (!body) return { bytes: new Uint8Array(0), truncated: false, open: false };
780
+ const reader = body.getReader();
781
+ const chunks: Uint8Array[] = [];
782
+ let total = 0;
783
+ let truncated = false;
784
+ let open = true;
785
+ const deadline = Date.now() + windowMs;
786
+ const TIMEOUT = Symbol("timeout");
787
+ try {
788
+ while (total <= maxBytes) {
789
+ const remaining = deadline - Date.now();
790
+ if (remaining <= 0) {
791
+ truncated = true;
792
+ break;
793
+ }
794
+ let timer: ReturnType<typeof setTimeout> | undefined;
795
+ const tick = Number.isFinite(remaining) ? new Promise<typeof TIMEOUT>((r) => (timer = setTimeout(() => r(TIMEOUT), remaining))) : null;
796
+ const next = await abortable(tick ? Promise.race([reader.read(), tick]) : reader.read(), signal).finally(() => clearTimeout(timer));
797
+ if (next === TIMEOUT) {
798
+ truncated = true;
799
+ break;
800
+ }
801
+ if (next.done) {
802
+ open = false;
803
+ break;
804
+ }
805
+ chunks.push(next.value);
806
+ total += next.value.length;
807
+ }
808
+ if (total > maxBytes) truncated = true;
809
+ } finally {
810
+ if (open) await reader.cancel().catch(() => undefined);
811
+ }
812
+ const all = new Uint8Array(Math.min(total, maxBytes));
813
+ let offset = 0;
814
+ for (const c of chunks) {
815
+ if (offset >= all.length) break;
816
+ const part = c.subarray(0, all.length - offset);
817
+ all.set(part, offset);
818
+ offset += part.length;
819
+ }
820
+ return { bytes: all, truncated, open: open && truncated };
821
+ }