@aitty/server 0.1.0

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.
@@ -0,0 +1,5 @@
1
+ //#region src/cli-token.d.ts
2
+ declare function createSessionToken(random?: () => Buffer): string;
3
+ declare function isSessionTokenAuthorized(expectedToken: string, providedToken: string | null | undefined): boolean;
4
+ //#endregion
5
+ export { createSessionToken, isSessionTokenAuthorized };
@@ -0,0 +1,14 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
+ //#region src/cli-token.ts
3
+ function createSessionToken(random = () => randomBytes(16)) {
4
+ return random().toString("base64url");
5
+ }
6
+ function isSessionTokenAuthorized(expectedToken, providedToken) {
7
+ const providedValue = typeof providedToken === "string" ? providedToken : "";
8
+ return timingSafeEqual(digestSessionToken(expectedToken), digestSessionToken(providedValue));
9
+ }
10
+ function digestSessionToken(token) {
11
+ return createHash("sha256").update(token, "utf8").digest();
12
+ }
13
+ //#endregion
14
+ export { createSessionToken, isSessionTokenAuthorized };
@@ -0,0 +1,34 @@
1
+ import { NetworkPolicy } from "../network-policy.js";
2
+ import { IncomingMessage, ServerResponse } from "node:http";
3
+ import { AittyRuntimeKind, AittyTheme } from "@aitty/protocol";
4
+
5
+ //#region src/frontend/browser-shell.d.ts
6
+ type BrowserRuntimeKind = AittyRuntimeKind;
7
+ interface BrowserShellOptions {
8
+ assets?: BrowserShellAsset[];
9
+ assetRoutes?: StaticRoute[];
10
+ shellHtml?: string | (() => string | Promise<string>);
11
+ theme?: AittyTheme;
12
+ }
13
+ interface BrowserShellDocumentOptions {
14
+ body: string;
15
+ lang?: string;
16
+ modulePreloads?: string[];
17
+ moduleScripts?: string[];
18
+ stylesheets?: string[];
19
+ theme?: AittyTheme;
20
+ title?: string;
21
+ }
22
+ interface BrowserShellAsset {
23
+ contentType?: string;
24
+ filePath: string;
25
+ pathname: string;
26
+ }
27
+ declare function createBrowserShellHtml(options: BrowserShellDocumentOptions): string;
28
+ interface StaticRoute {
29
+ prefix: string;
30
+ rootPath: string;
31
+ }
32
+ declare function handleBrowserShellRequest(request: IncomingMessage, response: ServerResponse, token: string, runtimeKind?: BrowserRuntimeKind, policy?: NetworkPolicy, shellOptions?: BrowserShellOptions): Promise<void>;
33
+ //#endregion
34
+ export { BrowserRuntimeKind, BrowserShellDocumentOptions, BrowserShellOptions, createBrowserShellHtml, handleBrowserShellRequest };
@@ -0,0 +1,218 @@
1
+ <!doctype html>
2
+ <html lang="en" data-theme="dark">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>aitty</title>
7
+ <link rel="stylesheet" href="/assets/terminal.css">
8
+ <link rel="modulepreload" href="/assets/terminal-app.js">
9
+ <style>
10
+ :root {
11
+ color-scheme: dark;
12
+ --shell-page-padding: clamp(14px, 2vw, 24px);
13
+ --shell-stage-padding: clamp(12px, 2vw, 20px);
14
+ --shell-bg-start: #111724;
15
+ --shell-bg-mid: #0d1117;
16
+ --shell-bg-end: #080b11;
17
+ --shell-bg-radial: rgba(246, 185, 103, 0.14);
18
+ --shell-bg: #0d1117;
19
+ --shell-copy: #edf2ff;
20
+ --shell-muted: #97a5bc;
21
+ --shell-loading-bg: rgba(7, 10, 16, 0.88);
22
+ --shell-loading-border: rgba(246, 185, 103, 0.2);
23
+ --shell-status-live: #d6dfef;
24
+ --shell-status-warning: #ffd9a6;
25
+ --shell-status-error: #ffb4aa;
26
+ --shell-disabled-outline: rgba(255, 255, 255, 0.04);
27
+ --shell-disabled-shadow: rgba(4, 7, 12, 0.28);
28
+ --shell-disabled-overlay-start: rgba(6, 9, 14, 0.1);
29
+ --shell-disabled-overlay-end: rgba(6, 9, 14, 0.34);
30
+ --shell-disabled-hatch: rgba(255, 255, 255, 0.02);
31
+ --theme-term-bg: transparent;
32
+ --theme-term-fg: #edf2ff;
33
+ --theme-term-cursor: #f6b967;
34
+ --theme-term-color-0: #111724;
35
+ --theme-term-color-1: #ff7b72;
36
+ --theme-term-color-2: #8ddb8c;
37
+ --theme-term-color-3: #ffd580;
38
+ --theme-term-color-4: #7cc4ff;
39
+ --theme-term-color-5: #d2a8ff;
40
+ --theme-term-color-6: #77e0d2;
41
+ --theme-term-color-7: #edf2ff;
42
+ --theme-term-color-8: #5f6f86;
43
+ --theme-term-color-9: #ffa198;
44
+ --theme-term-color-10: #b0f2af;
45
+ --theme-term-color-11: #ffe4a8;
46
+ --theme-term-color-12: #a7d7ff;
47
+ --theme-term-color-13: #e1c0ff;
48
+ --theme-term-color-14: #9ff4ea;
49
+ --theme-term-color-15: #ffffff;
50
+ }
51
+
52
+ :root[data-theme="light"] {
53
+ color-scheme: light;
54
+ --shell-bg-start: #fff7eb;
55
+ --shell-bg-mid: #f4efe3;
56
+ --shell-bg-end: #ebe5d8;
57
+ --shell-bg-radial: rgba(223, 155, 74, 0.12);
58
+ --shell-bg: #f4efe3;
59
+ --shell-copy: #1f2a36;
60
+ --shell-muted: #5f6f7e;
61
+ --shell-loading-bg: rgba(255, 251, 244, 0.92);
62
+ --shell-loading-border: rgba(185, 110, 26, 0.16);
63
+ --shell-status-live: #304150;
64
+ --shell-status-warning: #8f5f16;
65
+ --shell-status-error: #b14a3a;
66
+ --shell-disabled-outline: rgba(40, 51, 63, 0.08);
67
+ --shell-disabled-shadow: rgba(145, 119, 82, 0.12);
68
+ --shell-disabled-overlay-start: rgba(255, 255, 255, 0.18);
69
+ --shell-disabled-overlay-end: rgba(221, 211, 196, 0.34);
70
+ --shell-disabled-hatch: rgba(49, 67, 84, 0.05);
71
+ --theme-term-bg: transparent;
72
+ --theme-term-fg: #24323f;
73
+ --theme-term-cursor: #b96e1a;
74
+ --theme-term-color-0: #f5eee1;
75
+ --theme-term-color-1: #ba3f2f;
76
+ --theme-term-color-2: #2f7d4b;
77
+ --theme-term-color-3: #a06a00;
78
+ --theme-term-color-4: #2d5f8d;
79
+ --theme-term-color-5: #8153ab;
80
+ --theme-term-color-6: #1d6c73;
81
+ --theme-term-color-7: #24323f;
82
+ --theme-term-color-8: #7a8894;
83
+ --theme-term-color-9: #d75b4f;
84
+ --theme-term-color-10: #4a9861;
85
+ --theme-term-color-11: #b98512;
86
+ --theme-term-color-12: #4f7eae;
87
+ --theme-term-color-13: #9871bf;
88
+ --theme-term-color-14: #2f8a90;
89
+ --theme-term-color-15: #0f1720;
90
+ }
91
+
92
+ * {
93
+ box-sizing: border-box;
94
+ }
95
+
96
+ body {
97
+ margin: 0;
98
+ min-height: 100dvh;
99
+ overflow-x: hidden;
100
+ overflow-y: auto;
101
+ background:
102
+ radial-gradient(circle at top, var(--shell-bg-radial), transparent 34%),
103
+ linear-gradient(160deg, var(--shell-bg-start) 0%, var(--shell-bg-mid) 48%, var(--shell-bg-end) 100%);
104
+ color: var(--shell-copy);
105
+ font-family: "Iosevka Term", "Menlo", "Consolas", monospace;
106
+ }
107
+
108
+ [hidden] {
109
+ display: none !important;
110
+ }
111
+
112
+ .shell {
113
+ width: 100%;
114
+ min-height: 100dvh;
115
+ }
116
+
117
+ .shell-stage {
118
+ position: relative;
119
+ min-width: 0;
120
+ min-height: 100dvh;
121
+ padding: var(--shell-stage-padding) var(--shell-page-padding) calc(var(--shell-stage-padding) * 2);
122
+ }
123
+
124
+ .terminal-loading,
125
+ .shell-status {
126
+ position: fixed;
127
+ right: var(--shell-page-padding);
128
+ z-index: 2;
129
+ max-width: min(360px, calc(100vw - (var(--shell-page-padding) * 2)));
130
+ padding: 8px 12px;
131
+ border-radius: 999px;
132
+ background: var(--shell-loading-bg);
133
+ border: 1px solid var(--shell-loading-border);
134
+ color: var(--shell-muted);
135
+ font-size: 12px;
136
+ letter-spacing: 0.06em;
137
+ pointer-events: none;
138
+ text-align: right;
139
+ transition:
140
+ opacity 120ms ease,
141
+ transform 120ms ease,
142
+ visibility 120ms ease;
143
+ }
144
+
145
+ .terminal-loading {
146
+ top: var(--shell-stage-padding);
147
+ text-transform: uppercase;
148
+ }
149
+
150
+ .shell-status {
151
+ top: var(--shell-stage-padding);
152
+ }
153
+
154
+ .shell[data-output="pending"] .shell-status {
155
+ top: calc(var(--shell-stage-padding) + 42px);
156
+ }
157
+
158
+ .shell[data-connection="open"][data-session-state="live"] .shell-status {
159
+ opacity: 0;
160
+ transform: translateY(-4px);
161
+ visibility: hidden;
162
+ }
163
+
164
+ .terminal-root {
165
+ min-height: calc(100dvh - (var(--shell-stage-padding) * 2));
166
+ }
167
+
168
+ .terminal-root[data-session-interactive="false"] {
169
+ cursor: not-allowed;
170
+ box-shadow:
171
+ inset 0 0 0 1px var(--shell-disabled-outline),
172
+ inset 0 18px 48px var(--shell-disabled-shadow);
173
+ }
174
+
175
+ .terminal-root[data-session-interactive="false"]::after {
176
+ content: "";
177
+ position: absolute;
178
+ inset: 0;
179
+ border-radius: inherit;
180
+ background:
181
+ linear-gradient(180deg, var(--shell-disabled-overlay-start), var(--shell-disabled-overlay-end)),
182
+ repeating-linear-gradient(
183
+ -45deg,
184
+ var(--shell-disabled-hatch),
185
+ var(--shell-disabled-hatch) 8px,
186
+ transparent 8px,
187
+ transparent 16px
188
+ );
189
+ pointer-events: none;
190
+ }
191
+
192
+ .shell[data-output="ready"] .shell-status {
193
+ color: var(--shell-status-live);
194
+ }
195
+
196
+ .shell[data-connection="reconnecting"] .shell-status,
197
+ .shell[data-session-state="superseded"] .shell-status {
198
+ color: var(--shell-status-warning);
199
+ }
200
+
201
+ .shell[data-session-state="shutdown"] .shell-status,
202
+ .shell[data-session-state="ended"] .shell-status,
203
+ .shell[data-connection="error"] .shell-status {
204
+ color: var(--shell-status-error);
205
+ }
206
+ </style>
207
+ <script type="module" src="/assets/terminal-app.js"></script>
208
+ </head>
209
+ <body>
210
+ <main class="shell" data-shell data-runtime="session" data-connection="connecting" data-output="pending" data-session-state="starting">
211
+ <section class="shell-stage">
212
+ <div class="terminal-loading" data-terminal-loading>Booting terminal...</div>
213
+ <div class="terminal-root" data-terminal-root role="application" aria-label="Terminal"></div>
214
+ <p class="shell-status" data-terminal-status aria-live="polite">Connecting to local session...</p>
215
+ </section>
216
+ </main>
217
+ </body>
218
+ </html>
@@ -0,0 +1,291 @@
1
+ import { isSessionTokenAuthorized } from "../cli-token.js";
2
+ import { isHostAllowed } from "../network-policy.js";
3
+ import path from "node:path";
4
+ import { AITTY_SESSION_PATH_PREFIX, createSessionInfoBody } from "@aitty/protocol";
5
+ import { existsSync } from "node:fs";
6
+ import { readFile } from "node:fs/promises";
7
+ import { fileURLToPath } from "node:url";
8
+ //#region src/frontend/browser-shell.ts
9
+ const APP_ASSET_PREFIX = "/assets/";
10
+ const SESSION_INFO_PATH = "/session-info";
11
+ const APP_ASSET_ROOT = fileURLToPath(new URL("./", import.meta.url));
12
+ const BROWSER_SHELL_TEMPLATE_PATH = fileURLToPath(new URL("./browser-shell.html", import.meta.url));
13
+ let browserShellTemplatePromise;
14
+ function loadBrowserShellTemplate() {
15
+ browserShellTemplatePromise ??= readFile(BROWSER_SHELL_TEMPLATE_PATH, "utf8");
16
+ return browserShellTemplatePromise;
17
+ }
18
+ async function renderBrowserShellTemplate(options = {}) {
19
+ const template = await resolveShellHtml(options);
20
+ const theme = normalizeHtmlAttribute(options.theme);
21
+ if (!theme) return template;
22
+ return template.replace("<html lang=\"en\" data-theme=\"dark\">", `<html lang="en" data-theme="${theme}">`);
23
+ }
24
+ function createBrowserShellHtml(options) {
25
+ const lang = normalizeHtmlAttribute(options.lang) ?? "en";
26
+ const theme = normalizeHtmlAttribute(options.theme) ?? "dark";
27
+ const title = escapeHtmlText(options.title ?? "aitty");
28
+ const stylesheets = (options.stylesheets ?? []).map((href) => {
29
+ return ` <link rel="stylesheet" href="${normalizeUrlAttribute(href)}">`;
30
+ });
31
+ const modulePreloads = (options.modulePreloads ?? []).map((href) => {
32
+ return ` <link rel="modulepreload" href="${normalizeUrlAttribute(href)}">`;
33
+ });
34
+ const moduleScripts = (options.moduleScripts ?? []).map((src) => {
35
+ return ` <script type="module" src="${normalizeUrlAttribute(src)}"><\/script>`;
36
+ });
37
+ return `<!doctype html>
38
+ <html lang="${lang}" data-theme="${theme}">
39
+ <head>
40
+ <meta charset="utf-8">
41
+ <meta name="viewport" content="width=device-width, initial-scale=1">
42
+ ${[
43
+ ` <title>${title}</title>`,
44
+ ...stylesheets,
45
+ ...modulePreloads
46
+ ].join("\n")}
47
+ </head>
48
+ <body>
49
+ ${indentHtml([options.body.trim(), ...moduleScripts].join("\n"), 4)}
50
+ </body>
51
+ </html>
52
+ `;
53
+ }
54
+ function buildUnauthorizedContent() {
55
+ return `<!doctype html>
56
+ <html lang="en">
57
+ <head>
58
+ <meta charset="utf-8">
59
+ <meta name="viewport" content="width=device-width, initial-scale=1">
60
+ <title>aitty · Unauthorized</title>
61
+ <style>
62
+ :root {
63
+ color-scheme: dark;
64
+ --unauthorized-bg: #0d1117;
65
+ --unauthorized-panel: rgba(12, 16, 24, 0.88);
66
+ --unauthorized-border: rgba(157, 176, 198, 0.18);
67
+ --unauthorized-copy: #edf2ff;
68
+ --unauthorized-muted: #97a5bc;
69
+ --unauthorized-accent: #f6b967;
70
+ }
71
+
72
+ * {
73
+ box-sizing: border-box;
74
+ }
75
+
76
+ body {
77
+ margin: 0;
78
+ min-height: 100dvh;
79
+ display: grid;
80
+ place-items: center;
81
+ padding: 24px;
82
+ background:
83
+ radial-gradient(circle at top, rgba(246, 185, 103, 0.14), transparent 34%),
84
+ linear-gradient(160deg, #111724 0%, #0d1117 48%, #080b11 100%);
85
+ color: var(--unauthorized-copy);
86
+ font-family: "Iosevka Term", "Menlo", "Consolas", monospace;
87
+ }
88
+
89
+ main {
90
+ width: min(560px, 100%);
91
+ padding: 28px;
92
+ border: 1px solid var(--unauthorized-border);
93
+ border-radius: 24px;
94
+ background: var(--unauthorized-panel);
95
+ box-shadow: 0 32px 72px rgba(0, 0, 0, 0.45);
96
+ }
97
+
98
+ p {
99
+ margin: 0 0 14px;
100
+ color: var(--unauthorized-muted);
101
+ line-height: 1.6;
102
+ }
103
+
104
+ p:last-child {
105
+ margin-bottom: 0;
106
+ }
107
+
108
+ .eyebrow {
109
+ margin-bottom: 10px;
110
+ color: var(--unauthorized-accent);
111
+ font-size: 12px;
112
+ letter-spacing: 0.18em;
113
+ text-transform: uppercase;
114
+ }
115
+
116
+ h1 {
117
+ margin: 0 0 12px;
118
+ font-size: clamp(28px, 5vw, 40px);
119
+ line-height: 1.1;
120
+ }
121
+
122
+ strong {
123
+ color: var(--unauthorized-copy);
124
+ }
125
+ </style>
126
+ </head>
127
+ <body>
128
+ <main>
129
+ <p class="eyebrow">Local session guard</p>
130
+ <h1>Unauthorized</h1>
131
+ <p>This browser session link is no longer valid.</p>
132
+ <p><strong>Relaunch the aitty session</strong> and open the fresh launch URL printed by the CLI.</p>
133
+ <p>This page never echoes session tokens, and no terminal content is available without a valid launch URL.</p>
134
+ </main>
135
+ </body>
136
+ </html>
137
+ `;
138
+ }
139
+ const STATIC_ROUTES = [{
140
+ prefix: APP_ASSET_PREFIX,
141
+ rootPath: APP_ASSET_ROOT
142
+ }];
143
+ async function handleBrowserShellRequest(request, response, token, runtimeKind = "pty", policy, shellOptions = {}) {
144
+ if (policy && !isHostAllowed(request.headers.host, policy)) {
145
+ sendText(response, 403, "Forbidden", "text/plain; charset=utf-8");
146
+ return;
147
+ }
148
+ const method = request.method ?? "GET";
149
+ if (method !== "GET" && method !== "HEAD") {
150
+ sendText(response, 405, "Method Not Allowed", "text/plain; charset=utf-8", { Allow: "GET, HEAD" });
151
+ return;
152
+ }
153
+ const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1");
154
+ if (requestUrl.pathname === SESSION_INFO_PATH) {
155
+ if (!isSessionTokenAuthorized(token, requestUrl.searchParams.get("t"))) {
156
+ sendText(response, 403, method === "HEAD" ? "" : "Forbidden", "text/plain; charset=utf-8");
157
+ return;
158
+ }
159
+ sendText(response, 200, method === "HEAD" ? "" : createSessionInfoBody(runtimeKind), "application/json; charset=utf-8");
160
+ return;
161
+ }
162
+ if (requestUrl.pathname.startsWith(AITTY_SESSION_PATH_PREFIX)) {
163
+ if (!isSessionTokenAuthorized(token, requestUrl.searchParams.get("t"))) {
164
+ sendUnauthorizedShellPage(response, method);
165
+ return;
166
+ }
167
+ sendText(response, 200, method === "HEAD" ? "" : await renderBrowserShellTemplate(shellOptions), "text/html; charset=utf-8");
168
+ return;
169
+ }
170
+ if (!requestUrl.pathname.startsWith(APP_ASSET_PREFIX)) {
171
+ sendText(response, 404, "Not Found", "text/plain; charset=utf-8");
172
+ return;
173
+ }
174
+ const asset = resolveStaticAsset(requestUrl.pathname, shellOptions);
175
+ if (!asset) {
176
+ sendText(response, 404, "Not Found", "text/plain; charset=utf-8");
177
+ return;
178
+ }
179
+ let body;
180
+ try {
181
+ body = await readFile(asset.absolutePath);
182
+ } catch (error) {
183
+ if (isMissingStaticAssetError(error)) {
184
+ sendText(response, 404, "Not Found", "text/plain; charset=utf-8");
185
+ return;
186
+ }
187
+ throw error;
188
+ }
189
+ response.statusCode = 200;
190
+ response.setHeader("Content-Type", asset.contentType);
191
+ response.setHeader("Cache-Control", "no-store");
192
+ response.end(method === "HEAD" ? void 0 : body);
193
+ }
194
+ function sendUnauthorizedShellPage(response, method) {
195
+ sendText(response, 403, method === "HEAD" ? "" : buildUnauthorizedContent(), "text/html; charset=utf-8");
196
+ }
197
+ async function resolveShellHtml(options) {
198
+ if (typeof options.shellHtml === "string") return options.shellHtml;
199
+ if (typeof options.shellHtml === "function") return options.shellHtml();
200
+ return loadBrowserShellTemplate();
201
+ }
202
+ function resolveStaticAsset(pathname, shellOptions = {}) {
203
+ const exactAsset = resolveExactStaticAsset(pathname, shellOptions.assets);
204
+ if (exactAsset) return exactAsset;
205
+ for (const route of [...shellOptions.assetRoutes ?? [], ...STATIC_ROUTES]) {
206
+ if (!pathname.startsWith(route.prefix)) continue;
207
+ const relativePath = pathname.slice(route.prefix.length);
208
+ if (!relativePath) return null;
209
+ let decodedPath;
210
+ try {
211
+ decodedPath = decodeURIComponent(relativePath);
212
+ } catch {
213
+ return null;
214
+ }
215
+ const normalizedPath = path.posix.normalize(decodedPath);
216
+ if (normalizedPath === ".." || normalizedPath.startsWith("../") || normalizedPath.startsWith("/") || normalizedPath.includes("\\")) return null;
217
+ const absolutePath = resolveStaticAssetPath(route.rootPath, normalizedPath);
218
+ const rootWithSeparator = route.rootPath.endsWith(path.sep) ? route.rootPath : `${route.rootPath}${path.sep}`;
219
+ if (absolutePath !== route.rootPath && !absolutePath.startsWith(rootWithSeparator)) return null;
220
+ return {
221
+ absolutePath,
222
+ contentType: contentTypeForPath(absolutePath)
223
+ };
224
+ }
225
+ return null;
226
+ }
227
+ function resolveExactStaticAsset(pathname, assets) {
228
+ if (!assets) return null;
229
+ for (const asset of assets) {
230
+ if (asset.pathname !== pathname) continue;
231
+ return {
232
+ absolutePath: asset.filePath,
233
+ contentType: asset.contentType ?? contentTypeForPath(asset.filePath)
234
+ };
235
+ }
236
+ return null;
237
+ }
238
+ function resolveStaticAssetPath(rootPath, normalizedPath) {
239
+ const requestedPath = path.resolve(rootPath, normalizedPath);
240
+ if (existsSync(requestedPath)) return requestedPath;
241
+ if (rootPath === APP_ASSET_ROOT && normalizedPath.endsWith(".js")) {
242
+ const sourceModulePath = path.resolve(rootPath, `${normalizedPath.slice(0, -3)}.ts`);
243
+ if (existsSync(sourceModulePath)) return sourceModulePath;
244
+ }
245
+ return requestedPath;
246
+ }
247
+ function isMissingStaticAssetError(error) {
248
+ return error instanceof Error && "code" in error && [
249
+ "ENOENT",
250
+ "ENOTDIR",
251
+ "EISDIR"
252
+ ].includes(String(error.code));
253
+ }
254
+ function contentTypeForPath(filePath) {
255
+ switch (path.extname(filePath).toLowerCase()) {
256
+ case ".css": return "text/css; charset=utf-8";
257
+ case ".html": return "text/html; charset=utf-8";
258
+ case ".js": return "text/javascript; charset=utf-8";
259
+ case ".json":
260
+ case ".map": return "application/json; charset=utf-8";
261
+ case ".wasm": return "application/wasm";
262
+ default: return "application/octet-stream";
263
+ }
264
+ }
265
+ function sendText(response, statusCode, body, contentType, headers = {}) {
266
+ response.statusCode = statusCode;
267
+ response.setHeader("Content-Type", contentType);
268
+ response.setHeader("Cache-Control", "no-store");
269
+ for (const [name, value] of Object.entries(headers)) response.setHeader(name, value);
270
+ response.end(body);
271
+ }
272
+ function normalizeHtmlAttribute(value) {
273
+ if (typeof value !== "string") return null;
274
+ const trimmed = value.trim();
275
+ if (!trimmed) return null;
276
+ return trimmed.replaceAll("&", "&amp;").replaceAll("\"", "&quot;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
277
+ }
278
+ function normalizeUrlAttribute(value) {
279
+ const normalized = normalizeHtmlAttribute(value);
280
+ if (!normalized) throw new Error("Browser shell asset URLs must be non-empty strings");
281
+ return normalized;
282
+ }
283
+ function escapeHtmlText(value) {
284
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
285
+ }
286
+ function indentHtml(value, spaces) {
287
+ const indentation = " ".repeat(spaces);
288
+ return value.split("\n").map((line) => line ? `${indentation}${line}` : line).join("\n");
289
+ }
290
+ //#endregion
291
+ export { createBrowserShellHtml, handleBrowserShellRequest };