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