@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.
- package/README.md +34 -2
- package/bin/text.js +29 -0
- package/bin/text.ts +13 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/cli/build.d.ts +39 -0
- package/dist/cli/build.d.ts.map +1 -0
- package/dist/cli/build.js +157 -0
- package/dist/cli/build.js.map +1 -0
- package/dist/cli/config.d.ts +22 -0
- package/dist/cli/config.d.ts.map +1 -0
- package/dist/cli/config.js +73 -0
- package/dist/cli/config.js.map +1 -0
- package/dist/cli/dev.d.ts +12 -0
- package/dist/cli/dev.d.ts.map +1 -0
- package/dist/cli/dev.js +49 -0
- package/dist/cli/dev.js.map +1 -0
- package/dist/cli/index.d.ts +26 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +192 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/run.d.ts +31 -0
- package/dist/cli/run.d.ts.map +1 -0
- package/dist/cli/run.js +111 -0
- package/dist/cli/run.js.map +1 -0
- package/dist/cli/start.d.ts +14 -0
- package/dist/cli/start.d.ts.map +1 -0
- package/dist/cli/start.js +183 -0
- package/dist/cli/start.js.map +1 -0
- package/dist/cli/ui.d.ts +35 -0
- package/dist/cli/ui.d.ts.map +1 -0
- package/dist/cli/ui.js +73 -0
- package/dist/cli/ui.js.map +1 -0
- package/dist/client.d.ts +34 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +93 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +143 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +28 -0
- package/dist/config.js.map +1 -0
- package/dist/context.d.ts +44 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +26 -0
- package/dist/context.js.map +1 -0
- package/dist/dev-errors.d.ts +68 -0
- package/dist/dev-errors.d.ts.map +1 -0
- package/dist/dev-errors.js +121 -0
- package/dist/dev-errors.js.map +1 -0
- package/dist/devtools.d.ts +64 -0
- package/dist/devtools.d.ts.map +1 -0
- package/dist/devtools.js +186 -0
- package/dist/devtools.js.map +1 -0
- package/dist/frame-mirror.d.ts +19 -0
- package/dist/frame-mirror.d.ts.map +1 -0
- package/dist/frame-mirror.js +141 -0
- package/dist/frame-mirror.js.map +1 -0
- package/dist/frame-protocol.d.ts +175 -0
- package/dist/frame-protocol.d.ts.map +1 -0
- package/dist/frame-protocol.js +369 -0
- package/dist/frame-protocol.js.map +1 -0
- package/dist/history.d.ts +54 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +152 -0
- package/dist/history.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/link.d.ts +32 -0
- package/dist/link.d.ts.map +1 -0
- package/dist/link.js +54 -0
- package/dist/link.js.map +1 -0
- package/dist/live-player.d.ts +83 -0
- package/dist/live-player.d.ts.map +1 -0
- package/dist/live-player.js +413 -0
- package/dist/live-player.js.map +1 -0
- package/dist/live-protocol.d.ts +132 -0
- package/dist/live-protocol.d.ts.map +1 -0
- package/dist/live-protocol.js +278 -0
- package/dist/live-protocol.js.map +1 -0
- package/dist/live-stub.d.ts +10 -0
- package/dist/live-stub.d.ts.map +1 -0
- package/dist/live-stub.js +32 -0
- package/dist/live-stub.js.map +1 -0
- package/dist/mcp/cli.d.ts +22 -0
- package/dist/mcp/cli.d.ts.map +1 -0
- package/dist/mcp/cli.js +177 -0
- package/dist/mcp/cli.js.map +1 -0
- package/dist/mcp/frame.d.ts +58 -0
- package/dist/mcp/frame.d.ts.map +1 -0
- package/dist/mcp/frame.js +169 -0
- package/dist/mcp/frame.js.map +1 -0
- package/dist/mcp/index.d.ts +15 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +15 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/mcp/inspect.d.ts +95 -0
- package/dist/mcp/inspect.d.ts.map +1 -0
- package/dist/mcp/inspect.js +386 -0
- package/dist/mcp/inspect.js.map +1 -0
- package/dist/mcp/keys.d.ts +20 -0
- package/dist/mcp/keys.d.ts.map +1 -0
- package/dist/mcp/keys.js +87 -0
- package/dist/mcp/keys.js.map +1 -0
- package/dist/mcp/logs.d.ts +63 -0
- package/dist/mcp/logs.d.ts.map +1 -0
- package/dist/mcp/logs.js +217 -0
- package/dist/mcp/logs.js.map +1 -0
- package/dist/mcp/server.d.ts +64 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +485 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/session.d.ts +230 -0
- package/dist/mcp/session.d.ts.map +1 -0
- package/dist/mcp/session.js +742 -0
- package/dist/mcp/session.js.map +1 -0
- package/dist/navigation-errors.d.ts +28 -0
- package/dist/navigation-errors.d.ts.map +1 -0
- package/dist/navigation-errors.js +43 -0
- package/dist/navigation-errors.js.map +1 -0
- package/dist/navigation.d.ts +21 -0
- package/dist/navigation.d.ts.map +1 -0
- package/dist/navigation.js +33 -0
- package/dist/navigation.js.map +1 -0
- package/dist/router.d.ts +76 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +296 -0
- package/dist/router.js.map +1 -0
- package/dist/routing.d.ts +101 -0
- package/dist/routing.d.ts.map +1 -0
- package/dist/routing.js +286 -0
- package/dist/routing.js.map +1 -0
- package/dist/server/adapters/build-step.d.ts +34 -0
- package/dist/server/adapters/build-step.d.ts.map +1 -0
- package/dist/server/adapters/build-step.js +112 -0
- package/dist/server/adapters/build-step.js.map +1 -0
- package/dist/server/adapters/bun.d.ts +8 -0
- package/dist/server/adapters/bun.d.ts.map +1 -0
- package/dist/server/adapters/bun.js +89 -0
- package/dist/server/adapters/bun.js.map +1 -0
- package/dist/server/adapters/bundle.d.ts +21 -0
- package/dist/server/adapters/bundle.d.ts.map +1 -0
- package/dist/server/adapters/bundle.js +86 -0
- package/dist/server/adapters/bundle.js.map +1 -0
- package/dist/server/adapters/cloudflare.d.ts +29 -0
- package/dist/server/adapters/cloudflare.d.ts.map +1 -0
- package/dist/server/adapters/cloudflare.js +105 -0
- package/dist/server/adapters/cloudflare.js.map +1 -0
- package/dist/server/adapters/index.d.ts +10 -0
- package/dist/server/adapters/index.d.ts.map +1 -0
- package/dist/server/adapters/index.js +24 -0
- package/dist/server/adapters/index.js.map +1 -0
- package/dist/server/adapters/runtime-node.d.ts +16 -0
- package/dist/server/adapters/runtime-node.d.ts.map +1 -0
- package/dist/server/adapters/runtime-node.js +29 -0
- package/dist/server/adapters/runtime-node.js.map +1 -0
- package/dist/server/adapters/runtime.d.ts +42 -0
- package/dist/server/adapters/runtime.d.ts.map +1 -0
- package/dist/server/adapters/runtime.js +115 -0
- package/dist/server/adapters/runtime.js.map +1 -0
- package/dist/server/adapters/shared.d.ts +66 -0
- package/dist/server/adapters/shared.d.ts.map +1 -0
- package/dist/server/adapters/shared.js +124 -0
- package/dist/server/adapters/shared.js.map +1 -0
- package/dist/server/adapters/static.d.ts +9 -0
- package/dist/server/adapters/static.d.ts.map +1 -0
- package/dist/server/adapters/static.js +70 -0
- package/dist/server/adapters/static.js.map +1 -0
- package/dist/server/adapters/vercel.d.ts +43 -0
- package/dist/server/adapters/vercel.d.ts.map +1 -0
- package/dist/server/adapters/vercel.js +119 -0
- package/dist/server/adapters/vercel.js.map +1 -0
- package/dist/server/bun-jsx.d.ts +18 -0
- package/dist/server/bun-jsx.d.ts.map +1 -0
- package/dist/server/bun-jsx.js +56 -0
- package/dist/server/bun-jsx.js.map +1 -0
- package/dist/server/cache-route.d.ts +38 -0
- package/dist/server/cache-route.d.ts.map +1 -0
- package/dist/server/cache-route.js +109 -0
- package/dist/server/cache-route.js.map +1 -0
- package/dist/server/cache.d.ts +131 -0
- package/dist/server/cache.d.ts.map +1 -0
- package/dist/server/cache.js +409 -0
- package/dist/server/cache.js.map +1 -0
- package/dist/server/frames.d.ts +45 -0
- package/dist/server/frames.d.ts.map +1 -0
- package/dist/server/frames.js +294 -0
- package/dist/server/frames.js.map +1 -0
- package/dist/server/http.d.ts +80 -0
- package/dist/server/http.d.ts.map +1 -0
- package/dist/server/http.js +453 -0
- package/dist/server/http.js.map +1 -0
- package/dist/server/jsx-dev-runtime.d.ts +5 -0
- package/dist/server/jsx-dev-runtime.d.ts.map +1 -0
- package/dist/server/jsx-dev-runtime.js +6 -0
- package/dist/server/jsx-dev-runtime.js.map +1 -0
- package/dist/server/jsx-runtime.d.ts +10 -0
- package/dist/server/jsx-runtime.d.ts.map +1 -0
- package/dist/server/jsx-runtime.js +11 -0
- package/dist/server/jsx-runtime.js.map +1 -0
- package/dist/server/live-app.d.ts +87 -0
- package/dist/server/live-app.d.ts.map +1 -0
- package/dist/server/live-app.js +307 -0
- package/dist/server/live-app.js.map +1 -0
- package/dist/server/live-build.d.ts +70 -0
- package/dist/server/live-build.d.ts.map +1 -0
- package/dist/server/live-build.js +222 -0
- package/dist/server/live-build.js.map +1 -0
- package/dist/server/live-dev.d.ts +62 -0
- package/dist/server/live-dev.d.ts.map +1 -0
- package/dist/server/live-dev.js +313 -0
- package/dist/server/live-dev.js.map +1 -0
- package/dist/server/live-manifest.d.ts +23 -0
- package/dist/server/live-manifest.d.ts.map +1 -0
- package/dist/server/live-manifest.js +7 -0
- package/dist/server/live-manifest.js.map +1 -0
- package/dist/server/live-prod.d.ts +30 -0
- package/dist/server/live-prod.d.ts.map +1 -0
- package/dist/server/live-prod.js +97 -0
- package/dist/server/live-prod.js.map +1 -0
- package/dist/server/live-routes.d.ts +72 -0
- package/dist/server/live-routes.d.ts.map +1 -0
- package/dist/server/live-routes.js +274 -0
- package/dist/server/live-routes.js.map +1 -0
- package/dist/server/live-web.d.ts +16 -0
- package/dist/server/live-web.d.ts.map +1 -0
- package/dist/server/live-web.js +159 -0
- package/dist/server/live-web.js.map +1 -0
- package/dist/server/live.d.ts +138 -0
- package/dist/server/live.d.ts.map +1 -0
- package/dist/server/live.js +542 -0
- package/dist/server/live.js.map +1 -0
- package/dist/server/middleware-node.d.ts +48 -0
- package/dist/server/middleware-node.d.ts.map +1 -0
- package/dist/server/middleware-node.js +167 -0
- package/dist/server/middleware-node.js.map +1 -0
- package/dist/server/middleware-runtime.d.ts +97 -0
- package/dist/server/middleware-runtime.d.ts.map +1 -0
- package/dist/server/middleware-runtime.js +447 -0
- package/dist/server/middleware-runtime.js.map +1 -0
- package/dist/server/middleware.d.ts +145 -0
- package/dist/server/middleware.d.ts.map +1 -0
- package/dist/server/middleware.js +365 -0
- package/dist/server/middleware.js.map +1 -0
- package/dist/server/ppr.d.ts +22 -0
- package/dist/server/ppr.d.ts.map +1 -0
- package/dist/server/ppr.js +58 -0
- package/dist/server/ppr.js.map +1 -0
- package/dist/server/scan.d.ts +48 -0
- package/dist/server/scan.d.ts.map +1 -0
- package/dist/server/scan.js +161 -0
- package/dist/server/scan.js.map +1 -0
- package/dist/server/server-components.d.ts +65 -0
- package/dist/server/server-components.d.ts.map +1 -0
- package/dist/server/server-components.js +215 -0
- package/dist/server/server-components.js.map +1 -0
- package/dist/terminal.d.ts +5 -0
- package/dist/terminal.d.ts.map +1 -0
- package/dist/terminal.js +5 -0
- package/dist/terminal.js.map +1 -0
- package/dist/testing.d.ts +5 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +5 -0
- package/dist/testing.js.map +1 -0
- package/dist/vite-plugin.d.ts +51 -0
- package/dist/vite-plugin.d.ts.map +1 -0
- package/dist/vite-plugin.js +403 -0
- package/dist/vite-plugin.js.map +1 -0
- package/package.json +130 -4
- package/src/cli/build.ts +182 -0
- package/src/cli/config.ts +72 -0
- package/src/cli/dev.ts +54 -0
- package/src/cli/index.ts +185 -0
- package/src/cli/run.ts +124 -0
- package/src/cli/start.ts +194 -0
- package/src/cli/ui.ts +93 -0
- package/src/client.tsx +122 -0
- package/src/config.ts +155 -0
- package/src/context.ts +69 -0
- package/src/dev-errors.ts +167 -0
- package/src/devtools.tsx +419 -0
- package/src/frame-mirror.ts +145 -0
- package/src/frame-protocol.ts +435 -0
- package/src/history.ts +201 -0
- package/src/index.ts +9 -0
- package/src/link.tsx +108 -0
- package/src/live-player.ts +449 -0
- package/src/live-protocol.ts +317 -0
- package/src/live-stub.tsx +32 -0
- package/src/mcp/cli.ts +188 -0
- package/src/mcp/frame.ts +224 -0
- package/src/mcp/index.ts +15 -0
- package/src/mcp/inspect.ts +445 -0
- package/src/mcp/keys.ts +86 -0
- package/src/mcp/logs.ts +225 -0
- package/src/mcp/server.ts +595 -0
- package/src/mcp/session.ts +821 -0
- package/src/navigation-errors.ts +60 -0
- package/src/navigation.ts +44 -0
- package/src/router.tsx +497 -0
- package/src/routing.ts +344 -0
- package/src/server/adapters/build-step.ts +135 -0
- package/src/server/adapters/bun.ts +92 -0
- package/src/server/adapters/bundle.ts +97 -0
- package/src/server/adapters/cloudflare.ts +111 -0
- package/src/server/adapters/index.ts +29 -0
- package/src/server/adapters/runtime-node.ts +34 -0
- package/src/server/adapters/runtime.ts +145 -0
- package/src/server/adapters/shared.ts +159 -0
- package/src/server/adapters/static.ts +68 -0
- package/src/server/adapters/vercel.ts +149 -0
- package/src/server/bun-jsx.ts +65 -0
- package/src/server/cache-route.ts +139 -0
- package/src/server/cache.ts +498 -0
- package/src/server/frames.ts +326 -0
- package/src/server/http.ts +467 -0
- package/src/server/jsx-dev-runtime.ts +9 -0
- package/src/server/jsx-runtime.ts +13 -0
- package/src/server/live-app.tsx +374 -0
- package/src/server/live-build.ts +258 -0
- package/src/server/live-dev.ts +357 -0
- package/src/server/live-manifest.ts +25 -0
- package/src/server/live-prod.ts +120 -0
- package/src/server/live-routes.ts +298 -0
- package/src/server/live-web.ts +170 -0
- package/src/server/live.ts +551 -0
- package/src/server/middleware-node.ts +168 -0
- package/src/server/middleware-runtime.ts +478 -0
- package/src/server/middleware.ts +451 -0
- package/src/server/ppr.ts +62 -0
- package/src/server/scan.ts +190 -0
- package/src/server/server-components.ts +224 -0
- package/src/terminal.ts +5 -0
- package/src/testing.ts +5 -0
- package/src/vite-plugin.ts +413 -0
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live sessions for server-rendered console routes: one React root per
|
|
3
|
+
* browser tab, rendered on the server, streamed to the frame player as cell
|
|
4
|
+
* deltas over server-sent events. Input comes back as JSON POSTs.
|
|
5
|
+
*
|
|
6
|
+
* - `GET /__textjs/live?path=&cols=&rows=` opens (or, with `Last-Event-ID` /
|
|
7
|
+
* `x-textjs-live: <sid>`, resumes) a session and streams {@link LiveServerMessage}s.
|
|
8
|
+
* - `POST /__textjs/live` with `x-textjs-live: <sid>` and a JSON array of
|
|
9
|
+
* {@link LiveInput}s.
|
|
10
|
+
*
|
|
11
|
+
* Frames are paced like the client renderer (≥ `frameIntervalMs` apart) and
|
|
12
|
+
* coalesced: each frame is diffed against what this connection was last sent,
|
|
13
|
+
* so a slow socket (`write()` returning `false`) just skips intermediate
|
|
14
|
+
* states until `drain`. Disconnected sessions survive `reconnectGraceMs`, then
|
|
15
|
+
* unmount. Session ids are 192-bit random capabilities, bound to the
|
|
16
|
+
* credentials (cookies, `Authorization`) they were opened with; requests must
|
|
17
|
+
* be same-origin; only routes in the app manifest can be rendered, and
|
|
18
|
+
* `authorize` (middleware) runs for every server route a session renders.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
22
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
23
|
+
import { isAppPath, LIVE_PATH, LIVE_PROTOCOL_VERSION, LIVE_SESSION_HEADER, LiveFrameEncoder, MAX_COLS, MAX_ROWS, parseLiveInput, sseEvent, type LiveInput, type LiveServerMessage } from "../live-protocol";
|
|
24
|
+
import type { LiveApp, LiveAppOptions, RouteRuntime } from "./live-app";
|
|
25
|
+
import { readRequestBody } from "./http";
|
|
26
|
+
|
|
27
|
+
export type LiveAppInit = Omit<LiveAppOptions, "routes" | "theme" | "defaultTitle">;
|
|
28
|
+
|
|
29
|
+
export interface LiveServerOptions {
|
|
30
|
+
/** Mount a session's app (binds the route manifest, theme and title). */
|
|
31
|
+
createApp(init: LiveAppInit): Promise<LiveApp>;
|
|
32
|
+
/** Which runtime renders `pathname` (client routes are handed back to the browser). */
|
|
33
|
+
runtimeOf(pathname: string): RouteRuntime;
|
|
34
|
+
/** Prefix for hrefs sent to the browser. */
|
|
35
|
+
basePath?: string;
|
|
36
|
+
/** Concurrent sessions (default 256). */
|
|
37
|
+
maxSessions?: number;
|
|
38
|
+
/** How long a disconnected session waits to be resumed (default 30s). */
|
|
39
|
+
reconnectGraceMs?: number;
|
|
40
|
+
/** Minimum ms between frames per connection (default 66, the client renderer's pacing). */
|
|
41
|
+
frameIntervalMs?: number;
|
|
42
|
+
/** SSE comment interval keeping proxies from idling the stream out (default 15s). */
|
|
43
|
+
heartbeatMs?: number;
|
|
44
|
+
/** Forward render errors to the browser console (dev). */
|
|
45
|
+
exposeErrors?: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* May a session render `href`? Called with the visitor's request headers
|
|
48
|
+
* before a session opens, before every server-side route transition, and
|
|
49
|
+
* when a session's credentials change. Wire it to `middleware.ts`.
|
|
50
|
+
*/
|
|
51
|
+
authorize?(href: string, headers: Headers): Promise<boolean>;
|
|
52
|
+
/** Trust `X-Forwarded-Host` / `-Proto` / `-For` (behind a reverse proxy). */
|
|
53
|
+
trustProxy?: boolean;
|
|
54
|
+
/** Concurrent sessions per client address (default 32). */
|
|
55
|
+
maxSessionsPerClient?: number;
|
|
56
|
+
log?(message: string): void;
|
|
57
|
+
onError?(error: unknown): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const MAX_BODY = 512 * 1024;
|
|
61
|
+
export const MAX_EVENTS = 64;
|
|
62
|
+
const BODY_TIMEOUT_MS = 5000;
|
|
63
|
+
/** Input events per second per session (token bucket), with a burst allowance. */
|
|
64
|
+
const INPUT_RATE = 240;
|
|
65
|
+
const INPUT_BURST = 480;
|
|
66
|
+
const SID_RE = /^[A-Za-z0-9_-]{32}$/;
|
|
67
|
+
|
|
68
|
+
function first(value: string | string[] | undefined): string | undefined {
|
|
69
|
+
const v = Array.isArray(value) ? value[0] : value;
|
|
70
|
+
return v?.split(",")[0]?.trim() || undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** `true` unless the request is (or claims to be) from another site. */
|
|
74
|
+
export function isSameOriginRequest(req: Pick<IncomingMessage, "headers">, trustProxy = false): boolean {
|
|
75
|
+
const site = req.headers["sec-fetch-site"];
|
|
76
|
+
if (site !== undefined && site !== "same-origin" && site !== "none") return false;
|
|
77
|
+
const origin = req.headers.origin;
|
|
78
|
+
if (origin === undefined || origin === "null") return origin === undefined;
|
|
79
|
+
try {
|
|
80
|
+
const u = new URL(origin);
|
|
81
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") return false;
|
|
82
|
+
if (u.host === req.headers.host) return true;
|
|
83
|
+
if (!trustProxy) return false;
|
|
84
|
+
// Behind a trusted proxy that rewrites Host.
|
|
85
|
+
const proto = first(req.headers["x-forwarded-proto"]);
|
|
86
|
+
return u.host === first(req.headers["x-forwarded-host"]) && (proto === undefined || `${proto}:` === u.protocol);
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The request headers of a Node request as Web `Headers`. */
|
|
93
|
+
function headersOf(req: Pick<IncomingMessage, "headers">): Headers {
|
|
94
|
+
const out = new Headers();
|
|
95
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
96
|
+
if (v === undefined) continue;
|
|
97
|
+
if (Array.isArray(v)) for (const item of v) out.append(k, item);
|
|
98
|
+
else out.set(k, v);
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** What a session's identity is bound to. */
|
|
104
|
+
function credentialsOf(req: Pick<IncomingMessage, "headers">): string {
|
|
105
|
+
const h = req.headers;
|
|
106
|
+
return createHash("sha256")
|
|
107
|
+
.update(`${h.cookie ?? ""}\u0000${h.authorization ?? ""}`)
|
|
108
|
+
.digest("base64url");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function withTimeout<T>(p: Promise<T>, ms: number): Promise<T | "timeout"> {
|
|
112
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
113
|
+
return Promise.race([p, new Promise<"timeout">((r) => (timer = setTimeout(() => r("timeout"), ms)))]).finally(() => clearTimeout(timer));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function newSessionId(): string {
|
|
117
|
+
return randomBytes(24).toString("base64url");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function intParam(v: string | null, lo: number, hi: number, fallback: number): number {
|
|
121
|
+
const n = v === null ? Number.NaN : Number.parseInt(v, 10);
|
|
122
|
+
return Number.isFinite(n) ? Math.max(lo, Math.min(hi, n)) : fallback;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function safePath(p: string | null): string {
|
|
126
|
+
if (p === null || !p.startsWith("/") || p.startsWith("//") || p.length > 2048 || /[\u0000-\u001f\\]/.test(p)) return "/";
|
|
127
|
+
return p;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function pathOf(href: string): string {
|
|
131
|
+
const q = href.search(/[?#]/);
|
|
132
|
+
return q < 0 ? href : href.slice(0, q);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export class LiveSession {
|
|
136
|
+
readonly id: string;
|
|
137
|
+
app: LiveApp | null = null;
|
|
138
|
+
private stream: ServerResponse | null = null;
|
|
139
|
+
private readonly encoder = new LiveFrameEncoder();
|
|
140
|
+
private dirty = true;
|
|
141
|
+
private blocked = false;
|
|
142
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
143
|
+
private grace: ReturnType<typeof setTimeout> | null = null;
|
|
144
|
+
private lastSent = -Infinity;
|
|
145
|
+
private title: string | null = null;
|
|
146
|
+
/** The last address-bar update and a pending document load, replayed to a reconnecting browser. */
|
|
147
|
+
private lastUrl: LiveServerMessage | null = null;
|
|
148
|
+
private pendingLoad: LiveServerMessage | null = null;
|
|
149
|
+
disposed = false;
|
|
150
|
+
/** Hash of the cookies / `Authorization` the session was authorized with. */
|
|
151
|
+
credentials = "";
|
|
152
|
+
/** Request headers used to authorize route transitions. */
|
|
153
|
+
headers = new Headers();
|
|
154
|
+
/** Client address (per-client session cap). */
|
|
155
|
+
client = "";
|
|
156
|
+
/** When the browser detached (`0` while connected). */
|
|
157
|
+
detachedAt = 0;
|
|
158
|
+
private tokens = INPUT_BURST;
|
|
159
|
+
private refilledAt = performance.now();
|
|
160
|
+
/** Frames and bytes written (telemetry, tests). */
|
|
161
|
+
framesSent = 0;
|
|
162
|
+
bytesSent = 0;
|
|
163
|
+
|
|
164
|
+
constructor(
|
|
165
|
+
private readonly server: LiveServer,
|
|
166
|
+
private readonly interval: number,
|
|
167
|
+
) {
|
|
168
|
+
this.id = newSessionId();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
get connected(): boolean {
|
|
172
|
+
return this.stream !== null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
attach(res: ServerResponse, resumed: boolean): void {
|
|
176
|
+
if (this.grace !== null) clearTimeout(this.grace);
|
|
177
|
+
this.grace = null;
|
|
178
|
+
const prev = this.stream;
|
|
179
|
+
this.stream = res;
|
|
180
|
+
// Newest connection wins (a duplicated stream or a reconnect racing the old socket).
|
|
181
|
+
if (prev !== null && prev !== res) prev.end();
|
|
182
|
+
this.blocked = false;
|
|
183
|
+
this.encoder.reset();
|
|
184
|
+
this.dirty = true;
|
|
185
|
+
this.lastSent = -Infinity;
|
|
186
|
+
this.detachedAt = 0;
|
|
187
|
+
this.write(sseEvent({ t: "hello", v: LIVE_PROTOCOL_VERSION, sid: this.id, resumed }, this.id));
|
|
188
|
+
if (this.title !== null) this.write(sseEvent({ t: "title", title: this.title }));
|
|
189
|
+
// Messages a disconnected browser missed.
|
|
190
|
+
if (resumed && this.lastUrl !== null) this.write(sseEvent({ ...(this.lastUrl as { t: "url"; href: string }), replace: true }));
|
|
191
|
+
if (this.pendingLoad !== null) {
|
|
192
|
+
this.write(sseEvent(this.pendingLoad));
|
|
193
|
+
res.end();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
res.on("close", () => this.detach(res));
|
|
197
|
+
this.app?.setActive?.(true);
|
|
198
|
+
this.schedule();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Take `cost` input events from the rate bucket (`false` when the session is over its rate). */
|
|
202
|
+
takeTokens(cost: number): boolean {
|
|
203
|
+
const now = performance.now();
|
|
204
|
+
this.tokens = Math.min(INPUT_BURST, this.tokens + ((now - this.refilledAt) / 1000) * INPUT_RATE);
|
|
205
|
+
this.refilledAt = now;
|
|
206
|
+
if (this.tokens < cost) return false;
|
|
207
|
+
this.tokens -= cost;
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private detach(res: ServerResponse): void {
|
|
212
|
+
if (this.stream !== res) return;
|
|
213
|
+
this.stream = null;
|
|
214
|
+
this.detachedAt = performance.now();
|
|
215
|
+
if (this.timer !== null) clearTimeout(this.timer);
|
|
216
|
+
this.timer = null;
|
|
217
|
+
// Nobody is watching: stop painting until a browser reattaches.
|
|
218
|
+
this.app?.setActive?.(false);
|
|
219
|
+
if (!this.disposed) this.grace = setTimeout(() => this.server.dispose(this), this.server.reconnectGraceMs);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Send a non-frame message (written immediately, in order with frames). */
|
|
223
|
+
send(message: LiveServerMessage): void {
|
|
224
|
+
if (message.t === "title") this.title = message.title;
|
|
225
|
+
if (message.t === "url") this.lastUrl = message;
|
|
226
|
+
if (message.t === "load") this.pendingLoad = message;
|
|
227
|
+
if (this.stream !== null) this.write(sseEvent(message));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
markDirty(): void {
|
|
231
|
+
this.dirty = true;
|
|
232
|
+
this.schedule();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private schedule(): void {
|
|
236
|
+
if (this.stream === null || this.timer !== null || this.blocked || !this.dirty || this.app === null) return;
|
|
237
|
+
const wait = Math.max(0, this.lastSent + this.interval - performance.now());
|
|
238
|
+
this.timer = setTimeout(this.flush, wait);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Encode the delta since the last frame this connection received and write it. */
|
|
242
|
+
readonly flush = (): void => {
|
|
243
|
+
this.timer = null;
|
|
244
|
+
if (this.stream === null || this.blocked || !this.dirty || this.app === null) return;
|
|
245
|
+
this.dirty = false;
|
|
246
|
+
const frame = this.encoder.encode(this.app.buffer);
|
|
247
|
+
if (frame === null) return;
|
|
248
|
+
this.lastSent = performance.now();
|
|
249
|
+
this.framesSent++;
|
|
250
|
+
this.write(sseEvent(frame));
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
heartbeat(): void {
|
|
254
|
+
if (this.stream !== null && !this.blocked) this.write(": ♥\n\n");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private write(chunk: string): void {
|
|
258
|
+
const res = this.stream;
|
|
259
|
+
if (res === null || res.writableEnded) return;
|
|
260
|
+
this.bytesSent += chunk.length;
|
|
261
|
+
if (res.write(chunk) || this.blocked) return;
|
|
262
|
+
this.blocked = true;
|
|
263
|
+
res.once("drain", () => {
|
|
264
|
+
if (this.stream !== res) return;
|
|
265
|
+
this.blocked = false;
|
|
266
|
+
this.schedule();
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
input(events: readonly LiveInput[]): void {
|
|
271
|
+
const app = this.app;
|
|
272
|
+
if (app === null) return;
|
|
273
|
+
if (app.inputMany) app.inputMany(events);
|
|
274
|
+
else for (const e of events) app.input(e);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
close(): void {
|
|
278
|
+
this.disposed = true;
|
|
279
|
+
if (this.timer !== null) clearTimeout(this.timer);
|
|
280
|
+
if (this.grace !== null) clearTimeout(this.grace);
|
|
281
|
+
this.timer = null;
|
|
282
|
+
this.grace = null;
|
|
283
|
+
const res = this.stream;
|
|
284
|
+
this.stream = null;
|
|
285
|
+
res?.end();
|
|
286
|
+
this.app?.dispose();
|
|
287
|
+
this.app = null;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export class LiveServer {
|
|
292
|
+
readonly sessions = new Map<string, LiveSession>();
|
|
293
|
+
readonly reconnectGraceMs: number;
|
|
294
|
+
private readonly options: LiveServerOptions;
|
|
295
|
+
private readonly maxSessions: number;
|
|
296
|
+
private readonly maxPerClient: number;
|
|
297
|
+
private readonly interval: number;
|
|
298
|
+
private readonly heartbeatTimer: ReturnType<typeof setInterval>;
|
|
299
|
+
private readonly basePath: string;
|
|
300
|
+
|
|
301
|
+
constructor(options: LiveServerOptions) {
|
|
302
|
+
this.options = options;
|
|
303
|
+
this.maxSessions = options.maxSessions ?? 256;
|
|
304
|
+
this.maxPerClient = options.maxSessionsPerClient ?? 32;
|
|
305
|
+
this.reconnectGraceMs = options.reconnectGraceMs ?? 30_000;
|
|
306
|
+
this.interval = options.frameIntervalMs ?? 66;
|
|
307
|
+
this.basePath = options.basePath ?? "";
|
|
308
|
+
this.heartbeatTimer = setInterval(() => {
|
|
309
|
+
for (const s of this.sessions.values()) s.heartbeat();
|
|
310
|
+
}, options.heartbeatMs ?? 15_000);
|
|
311
|
+
(this.heartbeatTimer as { unref?: () => void }).unref?.();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Handle `GET` / `POST` on {@link LIVE_PATH}. `url` is the request URL with
|
|
316
|
+
* `basePath` already stripped. Returns `false` for other requests.
|
|
317
|
+
*/
|
|
318
|
+
async handle(req: IncomingMessage, res: ServerResponse, url: string): Promise<boolean> {
|
|
319
|
+
const q = url.indexOf("?");
|
|
320
|
+
const pathname = q < 0 ? url : url.slice(0, q);
|
|
321
|
+
if (pathname !== LIVE_PATH) return false;
|
|
322
|
+
if (!isSameOriginRequest(req, this.options.trustProxy)) {
|
|
323
|
+
res.statusCode = 403;
|
|
324
|
+
res.end();
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
if (req.method === "GET") await this.openStream(req, res, new URLSearchParams(q < 0 ? "" : url.slice(q + 1)));
|
|
328
|
+
else if (req.method === "POST") await this.receiveInput(req, res);
|
|
329
|
+
else {
|
|
330
|
+
res.statusCode = 405;
|
|
331
|
+
res.setHeader("Allow", "GET, POST");
|
|
332
|
+
res.end();
|
|
333
|
+
}
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private startStream(res: ServerResponse): void {
|
|
338
|
+
res.statusCode = 200;
|
|
339
|
+
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
340
|
+
res.setHeader("Cache-Control", "no-cache, no-transform");
|
|
341
|
+
res.setHeader("Connection", "keep-alive");
|
|
342
|
+
res.setHeader("X-Accel-Buffering", "no");
|
|
343
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
344
|
+
res.flushHeaders?.();
|
|
345
|
+
res.write("retry: 1000\n\n");
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** End a stream with a document load: the browser requests `href`, and middleware answers over HTTP. */
|
|
349
|
+
private sendLoad(res: ServerResponse, href: string): void {
|
|
350
|
+
this.startStream(res);
|
|
351
|
+
res.end(sseEvent({ t: "load", href: this.basePath + href }));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private clientOf(req: IncomingMessage): string {
|
|
355
|
+
const forwarded = this.options.trustProxy ? first(req.headers["x-forwarded-for"]) : undefined;
|
|
356
|
+
return forwarded ?? (req as { socket?: { remoteAddress?: string } }).socket?.remoteAddress ?? "";
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
private async authorized(href: string, headers: Headers): Promise<boolean> {
|
|
360
|
+
if (!this.options.authorize) return true;
|
|
361
|
+
try {
|
|
362
|
+
return await this.options.authorize(href, headers);
|
|
363
|
+
} catch (error) {
|
|
364
|
+
this.options.onError?.(error);
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* A session's cookies or `Authorization` changed since it was authorized:
|
|
371
|
+
* re-run `authorize` for what it currently shows before trusting the request.
|
|
372
|
+
*/
|
|
373
|
+
private async recheck(session: LiveSession, req: IncomingMessage): Promise<boolean> {
|
|
374
|
+
const credentials = credentialsOf(req);
|
|
375
|
+
if (credentials === session.credentials) return true;
|
|
376
|
+
const headers = headersOf(req);
|
|
377
|
+
const href = session.app?.history.location.href ?? "/";
|
|
378
|
+
if (!(await this.authorized(href, headers))) return false;
|
|
379
|
+
session.credentials = credentials;
|
|
380
|
+
session.headers = headers;
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Make room for a new session: drop the longest-disconnected one. */
|
|
385
|
+
private evictDisconnected(): boolean {
|
|
386
|
+
let oldest: LiveSession | null = null;
|
|
387
|
+
for (const s of this.sessions.values()) if (!s.connected && s.detachedAt > 0 && (oldest === null || s.detachedAt < oldest.detachedAt)) oldest = s;
|
|
388
|
+
if (oldest === null) return false;
|
|
389
|
+
this.dispose(oldest);
|
|
390
|
+
return true;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
private async openStream(req: IncomingMessage, res: ServerResponse, params: URLSearchParams): Promise<void> {
|
|
394
|
+
const lastId = first(req.headers["last-event-id"]) ?? first(req.headers[LIVE_SESSION_HEADER]);
|
|
395
|
+
const existing = lastId !== undefined && SID_RE.test(lastId) ? this.sessions.get(lastId) : undefined;
|
|
396
|
+
if (existing && !existing.disposed) {
|
|
397
|
+
if (!(await this.recheck(existing, req))) {
|
|
398
|
+
const href = existing.app?.history.location.href ?? "/";
|
|
399
|
+
this.dispose(existing);
|
|
400
|
+
this.sendLoad(res, href);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
this.startStream(res);
|
|
404
|
+
existing.attach(res, true);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const href = safePath(params.get("path"));
|
|
408
|
+
if (this.options.runtimeOf(pathOf(href)) === "client") {
|
|
409
|
+
// Not a server route: let the browser load it as a document.
|
|
410
|
+
this.sendLoad(res, href);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
const client = this.clientOf(req);
|
|
414
|
+
let perClient = 0;
|
|
415
|
+
for (const s of this.sessions.values()) if (s.client === client) perClient++;
|
|
416
|
+
if ((client !== "" && perClient >= this.maxPerClient) || (this.sessions.size >= this.maxSessions && !this.evictDisconnected())) {
|
|
417
|
+
res.statusCode = 503;
|
|
418
|
+
res.setHeader("Retry-After", "5");
|
|
419
|
+
res.end();
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
const headers = headersOf(req);
|
|
423
|
+
if (!(await this.authorized(href, headers))) {
|
|
424
|
+
// Middleware would redirect / reject this route: let the browser ask for it over HTTP.
|
|
425
|
+
this.sendLoad(res, href);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const session = new LiveSession(this, this.interval);
|
|
429
|
+
session.credentials = credentialsOf(req);
|
|
430
|
+
session.headers = headers;
|
|
431
|
+
session.client = client;
|
|
432
|
+
this.sessions.set(session.id, session);
|
|
433
|
+
let closedEarly = false;
|
|
434
|
+
res.once("close", () => {
|
|
435
|
+
closedEarly = session.app === null;
|
|
436
|
+
});
|
|
437
|
+
try {
|
|
438
|
+
session.app = await this.options.createApp({
|
|
439
|
+
path: href,
|
|
440
|
+
cols: intParam(params.get("cols"), 1, MAX_COLS, 120),
|
|
441
|
+
rows: intParam(params.get("rows"), 1, MAX_ROWS, 36),
|
|
442
|
+
focused: params.get("focused") !== "0",
|
|
443
|
+
onFrame: () => session.markDirty(),
|
|
444
|
+
authorize: this.options.authorize ? (target) => this.authorized(target, session.headers) : undefined,
|
|
445
|
+
onNavigate: (to, action, runtime, delta) => {
|
|
446
|
+
if (!isAppPath(to)) return;
|
|
447
|
+
if (runtime === "client") {
|
|
448
|
+
// The browser loads the route as a new document (client route, or middleware's call); this session is done.
|
|
449
|
+
session.send({ t: "load", href: this.basePath + to });
|
|
450
|
+
setTimeout(() => this.dispose(session), 0);
|
|
451
|
+
} else if (action === "pop" && delta !== undefined && delta !== 0) session.send({ t: "go", delta });
|
|
452
|
+
else session.send({ t: "url", href: this.basePath + to, replace: action === "replace" || action === "pop" });
|
|
453
|
+
},
|
|
454
|
+
onTitle: (title) => session.send({ t: "title", title }),
|
|
455
|
+
onError: (error) => {
|
|
456
|
+
this.options.onError?.(error);
|
|
457
|
+
if (this.options.exposeErrors) session.send({ t: "error", message: error instanceof Error ? error.message : String(error) });
|
|
458
|
+
},
|
|
459
|
+
});
|
|
460
|
+
} catch (error) {
|
|
461
|
+
this.dispose(session);
|
|
462
|
+
this.options.onError?.(error);
|
|
463
|
+
if (!res.headersSent) res.statusCode = 500;
|
|
464
|
+
res.end();
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (closedEarly || session.disposed) {
|
|
468
|
+
this.dispose(session);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
this.startStream(res);
|
|
472
|
+
session.attach(res, false);
|
|
473
|
+
this.options.log?.(` ◇ live session ${session.id.slice(0, 6)}… ${href} (${this.sessions.size} open)`);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
private async receiveInput(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
477
|
+
const sid = req.headers[LIVE_SESSION_HEADER];
|
|
478
|
+
const type = req.headers["content-type"] ?? "";
|
|
479
|
+
if (typeof sid !== "string" || !SID_RE.test(sid) || !type.startsWith("application/json")) {
|
|
480
|
+
res.statusCode = 400;
|
|
481
|
+
res.end();
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
const session = this.sessions.get(sid);
|
|
485
|
+
if (!session || session.disposed) {
|
|
486
|
+
res.statusCode = 410;
|
|
487
|
+
res.end();
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const raw = await withTimeout(readRequestBody(req, MAX_BODY), BODY_TIMEOUT_MS);
|
|
491
|
+
if (raw === null || raw === "timeout") {
|
|
492
|
+
res.statusCode = raw === null ? 413 : 408;
|
|
493
|
+
res.setHeader("Connection", "close");
|
|
494
|
+
res.end();
|
|
495
|
+
req.destroy();
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
let body: unknown;
|
|
499
|
+
try {
|
|
500
|
+
body = JSON.parse(raw);
|
|
501
|
+
} catch {
|
|
502
|
+
body = null;
|
|
503
|
+
}
|
|
504
|
+
if (!Array.isArray(body) || body.length > MAX_EVENTS) {
|
|
505
|
+
res.statusCode = body === null || !Array.isArray(body) ? 400 : 413;
|
|
506
|
+
res.end();
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
if (!session.takeTokens(Math.max(1, body.length))) {
|
|
510
|
+
res.statusCode = 429;
|
|
511
|
+
res.setHeader("Retry-After", "1");
|
|
512
|
+
res.end();
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
if (!(await this.recheck(session, req))) {
|
|
516
|
+
const href = session.app?.history.location.href ?? "/";
|
|
517
|
+
session.send({ t: "load", href: this.basePath + href });
|
|
518
|
+
this.dispose(session);
|
|
519
|
+
res.statusCode = 403;
|
|
520
|
+
res.end();
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
const events: LiveInput[] = [];
|
|
524
|
+
for (const item of body) {
|
|
525
|
+
const e = parseLiveInput(item);
|
|
526
|
+
if (e) events.push(e);
|
|
527
|
+
}
|
|
528
|
+
try {
|
|
529
|
+
session.input(events);
|
|
530
|
+
} catch (error) {
|
|
531
|
+
this.options.onError?.(error);
|
|
532
|
+
}
|
|
533
|
+
res.statusCode = 204;
|
|
534
|
+
res.end();
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
dispose(session: LiveSession): void {
|
|
538
|
+
if (this.sessions.get(session.id) === session) this.sessions.delete(session.id);
|
|
539
|
+
session.close();
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Re-render every session (dev: a route module changed). */
|
|
543
|
+
forEachApp(fn: (app: LiveApp) => void): void {
|
|
544
|
+
for (const s of this.sessions.values()) if (s.app) fn(s.app);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
close(): void {
|
|
548
|
+
clearInterval(this.heartbeatTimer);
|
|
549
|
+
for (const s of [...this.sessions.values()]) this.dispose(s);
|
|
550
|
+
}
|
|
551
|
+
}
|