@larose/pi-web 0.3.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.
Files changed (43) hide show
  1. package/LICENSE +235 -0
  2. package/README.md +50 -0
  3. package/THIRD_PARTY_LICENSES.md +40 -0
  4. package/dist/client/home.js +1619 -0
  5. package/dist/client/session.js +3703 -0
  6. package/dist/server/api.js +485 -0
  7. package/dist/server/cli.js +51 -0
  8. package/dist/server/directory-browser.js +104 -0
  9. package/dist/server/errors.js +10 -0
  10. package/dist/server/event-buffer.js +40 -0
  11. package/dist/server/extension-ui.js +245 -0
  12. package/dist/server/git-workspaces.js +559 -0
  13. package/dist/server/runtime-registry.js +703 -0
  14. package/dist/server/server.js +190 -0
  15. package/dist/server/session-repository.js +374 -0
  16. package/package.json +46 -0
  17. package/public/home.html +139 -0
  18. package/public/session.html +144 -0
  19. package/public/styles.css +2463 -0
  20. package/screenshots/home.png +0 -0
  21. package/screenshots/session.png +0 -0
  22. package/src/client/display-title.ts +36 -0
  23. package/src/client/event-stream.ts +194 -0
  24. package/src/client/home.ts +1575 -0
  25. package/src/client/markdown.ts +98 -0
  26. package/src/client/message-queue.ts +67 -0
  27. package/src/client/path-combobox.ts +271 -0
  28. package/src/client/session.ts +2174 -0
  29. package/src/client/shared.ts +99 -0
  30. package/src/client/slash-completion.ts +184 -0
  31. package/src/client/transcript-activity.ts +188 -0
  32. package/src/client/usage-format.ts +156 -0
  33. package/src/client/workspace-browser.ts +36 -0
  34. package/src/server/api.ts +652 -0
  35. package/src/server/cli.ts +63 -0
  36. package/src/server/directory-browser.ts +137 -0
  37. package/src/server/errors.ts +11 -0
  38. package/src/server/event-buffer.ts +59 -0
  39. package/src/server/extension-ui.ts +359 -0
  40. package/src/server/git-workspaces.ts +750 -0
  41. package/src/server/runtime-registry.ts +943 -0
  42. package/src/server/server.ts +248 -0
  43. package/src/server/session-repository.ts +488 -0
@@ -0,0 +1,248 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ import { handleApi, type ApiContext, writeApiError } from "./api.js";
7
+ import { DirectoryBrowser } from "./directory-browser.js";
8
+ import { AppError } from "./errors.js";
9
+ import { GitWorkspaceService } from "./git-workspaces.js";
10
+ import { createRuntimeRegistry, type RuntimeRegistry } from "./runtime-registry.js";
11
+ import { SessionRepository } from "./session-repository.js";
12
+
13
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
14
+
15
+ interface StaticAsset {
16
+ path: string;
17
+ contentType: string;
18
+ }
19
+
20
+ const homeDocument: StaticAsset = {
21
+ path: join(packageRoot, "public/home.html"),
22
+ contentType: "text/html; charset=utf-8",
23
+ };
24
+ const sessionDocument: StaticAsset = {
25
+ path: join(packageRoot, "public/session.html"),
26
+ contentType: "text/html; charset=utf-8",
27
+ };
28
+ const staticFiles = new Map<string, StaticAsset>([
29
+ ["/", homeDocument],
30
+ ["/styles.css", { path: join(packageRoot, "public/styles.css"), contentType: "text/css; charset=utf-8" }],
31
+ ["/home.js", { path: join(packageRoot, "dist/client/home.js"), contentType: "text/javascript; charset=utf-8" }],
32
+ ["/session.js", { path: join(packageRoot, "dist/client/session.js"), contentType: "text/javascript; charset=utf-8" }],
33
+ ]);
34
+
35
+ export interface WebServerOptions {
36
+ hostname?: string;
37
+ port?: number;
38
+ startupCwd?: string;
39
+ context?: Omit<ApiContext, "directories" | "gitWorkspaces"> &
40
+ Partial<Pick<ApiContext, "directories" | "gitWorkspaces">>;
41
+ }
42
+
43
+ export interface RunningWebServer {
44
+ hostname: string;
45
+ port: number;
46
+ server: Server;
47
+ repository: SessionRepository;
48
+ runtimes: RuntimeRegistry;
49
+ directories: ApiContext["directories"];
50
+ gitWorkspaces: ApiContext["gitWorkspaces"];
51
+ close(): Promise<void>;
52
+ }
53
+
54
+ function errorDetails(error: unknown): string {
55
+ if (!(error instanceof Error)) {
56
+ return String(error);
57
+ }
58
+
59
+ const details = error.stack ?? error.message;
60
+ return error.cause === undefined ? details : `${details}\nCaused by: ${errorDetails(error.cause)}`;
61
+ }
62
+
63
+ function shouldReportError(error: unknown): boolean {
64
+ return !(error instanceof AppError) || error.status >= 500 || error.cause !== undefined;
65
+ }
66
+
67
+ function applySecurityHeaders(response: ServerResponse): void {
68
+ response.setHeader(
69
+ "content-security-policy",
70
+ "default-src 'self'; base-uri 'none'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'; script-src 'self'; style-src 'self'",
71
+ );
72
+ response.setHeader("referrer-policy", "no-referrer");
73
+ response.setHeader("x-content-type-options", "nosniff");
74
+ response.setHeader("x-frame-options", "DENY");
75
+ }
76
+
77
+ function boundPort(server: Server): number {
78
+ const address = server.address();
79
+ if (!address || typeof address === "string") {
80
+ throw new Error("Server is not bound to a TCP address");
81
+ }
82
+ return address.port;
83
+ }
84
+
85
+ function validateLocalRequest(request: IncomingMessage, server: Server): void {
86
+ const host = request.headers.host;
87
+ if (!host) {
88
+ throw new AppError("invalid_host", "A Host header is required", 403);
89
+ }
90
+
91
+ let hostUrl: URL;
92
+ try {
93
+ hostUrl = new URL(`http://${host}`);
94
+ } catch {
95
+ throw new AppError("invalid_host", "The Host header is invalid", 403);
96
+ }
97
+
98
+ const expectedPort = String(boundPort(server));
99
+ if (!new Set(["127.0.0.1", "localhost"]).has(hostUrl.hostname) || hostUrl.port !== expectedPort) {
100
+ throw new AppError("invalid_host", "The Host header is not allowed", 403);
101
+ }
102
+
103
+ const origin = request.headers.origin;
104
+ if (!origin) {
105
+ return;
106
+ }
107
+
108
+ try {
109
+ const originUrl = new URL(origin);
110
+ if (
111
+ originUrl.protocol !== "http:" ||
112
+ !new Set(["127.0.0.1", "localhost"]).has(originUrl.hostname) ||
113
+ originUrl.port !== expectedPort
114
+ ) {
115
+ throw new Error("Origin does not match the local server");
116
+ }
117
+ } catch {
118
+ throw new AppError("invalid_origin", "The Origin header is not allowed", 403);
119
+ }
120
+ }
121
+
122
+ async function serveAsset(asset: StaticAsset, response: ServerResponse): Promise<void> {
123
+ const contents = await readFile(asset.path);
124
+
125
+ response.writeHead(200, {
126
+ "cache-control": "no-store",
127
+ "content-length": contents.byteLength,
128
+ "content-type": asset.contentType,
129
+ });
130
+ response.end(contents);
131
+ }
132
+
133
+ async function serveStatic(pathname: string, response: ServerResponse): Promise<boolean> {
134
+ const asset = staticFiles.get(pathname);
135
+ if (!asset) {
136
+ return false;
137
+ }
138
+
139
+ await serveAsset(asset, response);
140
+ return true;
141
+ }
142
+
143
+ function newSessionSource(pathname: string): string | undefined {
144
+ const match = /^\/sessions\/([^/]+)\/new\/?$/.exec(pathname);
145
+ if (!match?.[1]) {
146
+ return undefined;
147
+ }
148
+
149
+ try {
150
+ return decodeURIComponent(match[1]);
151
+ } catch {
152
+ throw new AppError("invalid_path", "The source session id is not valid", 400);
153
+ }
154
+ }
155
+
156
+ export async function startServer(options: WebServerOptions = {}): Promise<RunningWebServer> {
157
+ const hostname = options.hostname ?? "127.0.0.1";
158
+ const port = options.port ?? 31_415;
159
+
160
+ if (hostname !== "127.0.0.1") {
161
+ throw new Error("Pi Web only supports the loopback host 127.0.0.1");
162
+ }
163
+
164
+ const repository =
165
+ options.context?.repository ?? (await SessionRepository.create(options.startupCwd ?? process.cwd()));
166
+ const runtimes = options.context?.runtimes ?? (await createRuntimeRegistry(repository));
167
+ const directories = options.context?.directories ?? new DirectoryBrowser();
168
+ const gitWorkspaces = options.context?.gitWorkspaces ?? new GitWorkspaceService();
169
+ const context: ApiContext = { repository, runtimes, directories, gitWorkspaces };
170
+ let closing: Promise<void> | undefined;
171
+
172
+ const server = createServer(async (request, response) => {
173
+ applySecurityHeaders(response);
174
+
175
+ try {
176
+ validateLocalRequest(request, server);
177
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
178
+
179
+ if (await handleApi(request, response, url.pathname, context)) {
180
+ return;
181
+ }
182
+
183
+ if (request.method === "GET") {
184
+ const sourceId = newSessionSource(url.pathname);
185
+ if (sourceId) {
186
+ await repository.get(sourceId);
187
+ await serveAsset(homeDocument, response);
188
+ return;
189
+ }
190
+
191
+ if (/^\/sessions\/[^/]+\/?$/.test(url.pathname)) {
192
+ await serveAsset(sessionDocument, response);
193
+ return;
194
+ }
195
+ if (await serveStatic(url.pathname, response)) {
196
+ return;
197
+ }
198
+ }
199
+
200
+ throw new AppError("not_found", "Not found", 404);
201
+ } catch (error) {
202
+ if (shouldReportError(error)) {
203
+ process.stderr.write(
204
+ `[pi-web] ${request.method ?? "UNKNOWN"} ${request.url ?? "/"} failed\n${errorDetails(error)}\n`,
205
+ );
206
+ }
207
+ writeApiError(response, error);
208
+ }
209
+ });
210
+
211
+ try {
212
+ await new Promise<void>((resolveListening, reject) => {
213
+ server.once("error", reject);
214
+ server.listen(port, hostname, () => {
215
+ server.off("error", reject);
216
+ resolveListening();
217
+ });
218
+ });
219
+ } catch (error) {
220
+ if (!options.context) {
221
+ await runtimes.close();
222
+ }
223
+ throw error;
224
+ }
225
+
226
+ return {
227
+ hostname,
228
+ port: boundPort(server),
229
+ server,
230
+ repository,
231
+ runtimes,
232
+ directories,
233
+ gitWorkspaces,
234
+ close: () => {
235
+ if (closing) {
236
+ return closing;
237
+ }
238
+
239
+ closing = (async () => {
240
+ await runtimes.close();
241
+ await new Promise<void>((resolveClose, reject) => {
242
+ server.close((error) => (error ? reject(error) : resolveClose()));
243
+ });
244
+ })();
245
+ return closing;
246
+ },
247
+ };
248
+ }