@jxsuite/server 7.0.0 → 8.0.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.
- package/README.md +24 -2
- package/package.json +36 -16
- package/src/collab.ts +9 -14
- package/src/extension-catalog.ts +5 -12
- package/src/file-response.ts +140 -0
- package/src/fs-glob.ts +45 -0
- package/src/live-preview.ts +26 -12
- package/src/net-guard.ts +2 -5
- package/src/npm-path.ts +145 -0
- package/src/packages-service.ts +212 -0
- package/src/packages.ts +233 -35
- package/src/project-server.ts +17 -17
- package/src/refactor/apply.ts +2 -1
- package/src/refactor/find-refs.ts +0 -0
- package/src/refactor/scan.ts +3 -3
- package/src/resolve.ts +3 -8
- package/src/serve.bun.ts +50 -0
- package/src/serve.node.ts +311 -0
- package/src/serve.ts +67 -0
- package/src/server.ts +10 -142
- package/src/site-preview.ts +36 -21
- package/src/studio-api.ts +41 -51
- package/src/watch-policy.ts +3 -3
package/src/project-server.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* into one createProjectServer() used by the chromium desktop launcher and the Electron hub.
|
|
2
|
+
* The desktop hub's per-window project server (`createProjectServer`).
|
|
4
3
|
*
|
|
5
|
-
* It serves three surfaces from a single loopback-bound
|
|
4
|
+
* It serves three surfaces from a single loopback-bound server (`serve.ts`, which is `node:http` in
|
|
5
|
+
* the packaged app and `Bun.serve` under Bun):
|
|
6
6
|
*
|
|
7
7
|
* - The studio shell + canvas iframe doc + iframe bundle, under a non-colliding studio namespace (so
|
|
8
8
|
* a project's own asset tree never collides);
|
|
@@ -25,12 +25,13 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import { resolve, sep } from "node:path";
|
|
28
|
-
import
|
|
28
|
+
import { serve } from "#serve";
|
|
29
|
+
import type { ServeServer, ServeSocket } from "./serve.ts";
|
|
29
30
|
import { handleJxMounts } from "./jx-mounts.ts";
|
|
30
31
|
import { handleResolve, handleServerFunction, projectAssetMounts } from "./resolve.ts";
|
|
31
32
|
import { handleAiApi } from "./ai-api.ts";
|
|
32
33
|
import { handleImportApi } from "./import-api.ts";
|
|
33
|
-
import { resolveNpmPath } from "./
|
|
34
|
+
import { resolveNpmPath } from "./npm-path.ts";
|
|
34
35
|
import type { ImportApiOptions } from "./import-api.ts";
|
|
35
36
|
import {
|
|
36
37
|
decodeAndNormalizePath,
|
|
@@ -44,6 +45,7 @@ import { createLoopbackAuthorizer, OAUTH_CALLBACK_PATH } from "./oauth-loopback.
|
|
|
44
45
|
import type { LoopbackAuthorizer } from "./oauth-loopback.ts";
|
|
45
46
|
import type { FsEventBatch } from "./sse.ts";
|
|
46
47
|
import type { JsonPrimitive, JsonValue } from "@jxsuite/schema/types";
|
|
48
|
+
import { bundleToString } from "@jxsuite/compiler/bundler";
|
|
47
49
|
|
|
48
50
|
/**
|
|
49
51
|
* What a handler answers, sent as the reply frame's `result`: anything `JSON.stringify` encodes,
|
|
@@ -98,7 +100,7 @@ export interface CreateProjectServerOptions {
|
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
export interface ProjectServerHandle {
|
|
101
|
-
server:
|
|
103
|
+
server: ServeServer<{ winId: string | null }>;
|
|
102
104
|
url: string;
|
|
103
105
|
wsUrl: string;
|
|
104
106
|
rpcToken: string;
|
|
@@ -129,7 +131,9 @@ export interface ProjectServerHandle {
|
|
|
129
131
|
* @param options See {@link CreateProjectServerOptions}.
|
|
130
132
|
* @returns A handle with the bound url/wsUrl, the rpcToken, the canvas iframe URL, and stop().
|
|
131
133
|
*/
|
|
132
|
-
export function createProjectServer(
|
|
134
|
+
export async function createProjectServer(
|
|
135
|
+
options: CreateProjectServerOptions,
|
|
136
|
+
): Promise<ProjectServerHandle> {
|
|
133
137
|
const {
|
|
134
138
|
resolveSession,
|
|
135
139
|
studioDir,
|
|
@@ -147,9 +151,9 @@ export function createProjectServer(options: CreateProjectServerOptions): Projec
|
|
|
147
151
|
/* Live shell sockets, so the server can speak first (see ProjectServerHandle.push). Membership is
|
|
148
152
|
the socket's whole lifetime — added on open, dropped on close — so a push after a window is
|
|
149
153
|
gone reaches nobody rather than throwing. */
|
|
150
|
-
const sockets = new Set<
|
|
154
|
+
const sockets = new Set<ServeSocket<{ winId: string | null }>>();
|
|
151
155
|
|
|
152
|
-
const server =
|
|
156
|
+
const server = await serve<{ winId: string | null }>({
|
|
153
157
|
hostname,
|
|
154
158
|
port,
|
|
155
159
|
// Keep SSE/AI streams alive (mirrors the dev server's generous idle timeout).
|
|
@@ -327,14 +331,10 @@ export function createProjectServer(options: CreateProjectServerOptions): Projec
|
|
|
327
331
|
if (resolved) {
|
|
328
332
|
if (!bundleCache.has(resolved)) {
|
|
329
333
|
try {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
});
|
|
335
|
-
if (result.success && result.outputs.length > 0) {
|
|
336
|
-
bundleCache.set(resolved, await result.outputs[0]!.text());
|
|
337
|
-
}
|
|
334
|
+
bundleCache.set(
|
|
335
|
+
resolved,
|
|
336
|
+
await bundleToString(resolved, { minify: false, target: "browser" }),
|
|
337
|
+
);
|
|
338
338
|
} catch (error) {
|
|
339
339
|
console.error("Bundle failed for", resolved, error);
|
|
340
340
|
}
|
package/src/refactor/apply.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { refactorMounts } from "./mounts.ts";
|
|
|
19
19
|
import type { RefChange } from "./refs.ts";
|
|
20
20
|
import type { AssetMount } from "@jxsuite/schema/asset-paths";
|
|
21
21
|
import type { FormatRegistry } from "@jxsuite/schema/format-registry";
|
|
22
|
+
import { scanFiles } from "../fs-glob.ts";
|
|
22
23
|
|
|
23
24
|
export interface FileChange {
|
|
24
25
|
path: string;
|
|
@@ -117,7 +118,7 @@ export async function applyRename(opts: ApplyRenameOptions): Promise<RenameRepor
|
|
|
117
118
|
let tagFilesChanged = 0;
|
|
118
119
|
let tagRefsUpdated = 0;
|
|
119
120
|
|
|
120
|
-
for await (const match of documentGlob(opts.registry)
|
|
121
|
+
for await (const match of scanFiles(documentGlob(opts.registry), opts.root)) {
|
|
121
122
|
if (skipScanPath(match)) {
|
|
122
123
|
continue;
|
|
123
124
|
}
|
|
Binary file
|
package/src/refactor/scan.ts
CHANGED
|
@@ -61,13 +61,13 @@ export function skipScanPath(match: string): boolean {
|
|
|
61
61
|
);
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
/**
|
|
65
|
-
export function documentGlob(registry: FormatRegistry):
|
|
64
|
+
/** The recursive glob pattern covering JSON plus every registered document/content extension. */
|
|
65
|
+
export function documentGlob(registry: FormatRegistry): string {
|
|
66
66
|
const exts = new Set(["json"]);
|
|
67
67
|
for (const ext of registry.documentExtensions()) {
|
|
68
68
|
exts.add(ext.replace(/^\./, ""));
|
|
69
69
|
}
|
|
70
|
-
return
|
|
70
|
+
return `**/*.{${[...exts].join(",")}}`;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
/**
|
package/src/resolve.ts
CHANGED
|
@@ -360,15 +360,10 @@ export async function handleResolve(
|
|
|
360
360
|
return new Response(`$src "${$src}" escapes the project root`, { status: 403 });
|
|
361
361
|
}
|
|
362
362
|
} else {
|
|
363
|
-
// Npm/bare specifier —
|
|
363
|
+
// Npm/bare specifier — the project's own install, never the server's (a class package is an
|
|
364
|
+
// Extension of the project, specs/extensions.md §3, and the build resolves it the same way).
|
|
364
365
|
const projectRoot = activeProjectRoot || root;
|
|
365
|
-
|
|
366
|
-
try {
|
|
367
|
-
moduleAbsPath = projRequire.resolve($src);
|
|
368
|
-
} catch {
|
|
369
|
-
const serverRequire = createRequire(import.meta.url);
|
|
370
|
-
moduleAbsPath = serverRequire.resolve($src);
|
|
371
|
-
}
|
|
366
|
+
moduleAbsPath = createRequire(resolve(projectRoot, "package.json")).resolve($src);
|
|
372
367
|
trustedResolution = true;
|
|
373
368
|
}
|
|
374
369
|
} catch (error) {
|
package/src/serve.bun.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `serve()` under Bun: `Bun.serve`, adapted to `serve.ts`'s shape. Selected by the `bun` condition
|
|
3
|
+
* of `#serve`, so only a Bun process ever loads this file.
|
|
4
|
+
*
|
|
5
|
+
* @docs studio/desktop
|
|
6
|
+
*/
|
|
7
|
+
import type { Server } from "bun";
|
|
8
|
+
import type { ServeOptions, ServeServer } from "./serve.ts";
|
|
9
|
+
|
|
10
|
+
export type * from "./serve.ts";
|
|
11
|
+
|
|
12
|
+
/** Bun's `Server`, seen through the surface `serve.ts` declares. */
|
|
13
|
+
function adapt<Data>(server: Server<Data>): ServeServer<Data> {
|
|
14
|
+
/* SAFETY: Bun declares `upgrade`'s options as a rest tuple conditional on `Data`, which an
|
|
15
|
+
unresolved type parameter never selects; for every `Data` either branch takes this object. */
|
|
16
|
+
const upgradeSocket = server.upgrade.bind(server) as (
|
|
17
|
+
req: Request,
|
|
18
|
+
options: { data: Data; headers?: HeadersInit },
|
|
19
|
+
) => boolean;
|
|
20
|
+
return {
|
|
21
|
+
get port() {
|
|
22
|
+
return Number(server.url.port);
|
|
23
|
+
},
|
|
24
|
+
get url() {
|
|
25
|
+
return server.url;
|
|
26
|
+
},
|
|
27
|
+
upgrade: (req, { data, headers }) =>
|
|
28
|
+
upgradeSocket(req, headers === undefined ? { data } : { data, headers: { ...headers } }),
|
|
29
|
+
stop: (closeActiveConnections) => server.stop(closeActiveConnections),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Start a server and resolve once it is listening (which, under Bun, is already). */
|
|
34
|
+
export async function serve<Data = undefined>(
|
|
35
|
+
options: ServeOptions<Data>,
|
|
36
|
+
): Promise<ServeServer<Data>> {
|
|
37
|
+
const { fetch: handle, websocket, ...rest } = options;
|
|
38
|
+
let adapted: ServeServer<Data> | null = null;
|
|
39
|
+
const server = Bun.serve<Data>({
|
|
40
|
+
...rest,
|
|
41
|
+
fetch: (req, bunServer) => handle(req, (adapted ??= adapt(bunServer))),
|
|
42
|
+
websocket: {
|
|
43
|
+
close: (ws, code, reason) => websocket?.close?.(ws, code, reason),
|
|
44
|
+
message: (ws, message) => websocket?.message?.(ws, message),
|
|
45
|
+
open: (ws) => websocket?.open?.(ws),
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
adapted ??= adapt(server);
|
|
49
|
+
return adapted;
|
|
50
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `serve()` on `node:http` and `ws`: the default of `#serve` (`serve.ts`), so every runtime that is
|
|
3
|
+
* not Bun gets it, and the packaged desktop backend runs on it (specs/desktop.md §7.1).
|
|
4
|
+
*
|
|
5
|
+
* The work is the conversion at both edges. A request becomes a Fetch `Request` whose `signal`
|
|
6
|
+
* aborts when the client goes away (the AI and import routes cancel upstream work on it), and a
|
|
7
|
+
* `Response` body is streamed chunk by chunk with backpressure, never buffered, because SSE and AI
|
|
8
|
+
* streams are open-ended. An upgrade request reaches the same `fetch` handler, which accepts it
|
|
9
|
+
* with `server.upgrade()` as it would under Bun, or answers it with an ordinary `Response`.
|
|
10
|
+
*
|
|
11
|
+
* `tests/serve-node.test.ts` imports this file directly, so it runs under `bun test` as well.
|
|
12
|
+
*
|
|
13
|
+
* @docs studio/desktop
|
|
14
|
+
*/
|
|
15
|
+
import { createServer } from "node:http";
|
|
16
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
17
|
+
import type { Duplex } from "node:stream";
|
|
18
|
+
import { WebSocketServer } from "ws";
|
|
19
|
+
import type { RawData, WebSocket } from "ws";
|
|
20
|
+
import type { ServeOptions, ServeServer, ServeSocket } from "./serve.ts";
|
|
21
|
+
|
|
22
|
+
export type * from "./serve.ts";
|
|
23
|
+
|
|
24
|
+
/** What an upgrade request carries until the handler accepts or answers it. */
|
|
25
|
+
interface PendingUpgrade {
|
|
26
|
+
nodeReq: IncomingMessage;
|
|
27
|
+
socket: Duplex;
|
|
28
|
+
head: Buffer;
|
|
29
|
+
taken: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The `Sec-WebSocket-Protocol` an accepted upgrade echoes, read by `handleProtocols`. */
|
|
33
|
+
const echoedProtocol = new WeakMap<IncomingMessage, string>();
|
|
34
|
+
|
|
35
|
+
const BODYLESS_METHODS = new Set(["GET", "HEAD"]);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The request body as `node:http` delivers it.
|
|
39
|
+
*
|
|
40
|
+
* @yields {Uint8Array} Each chunk of the body, in order
|
|
41
|
+
*/
|
|
42
|
+
async function* chunksOf(nodeReq: IncomingMessage): AsyncGenerator<Uint8Array> {
|
|
43
|
+
/* SAFETY: an `IncomingMessage` with no encoding set yields its body as Buffers; anything else is
|
|
44
|
+
skipped by the check below. */
|
|
45
|
+
for await (const chunk of nodeReq as AsyncIterable<unknown>) {
|
|
46
|
+
if (chunk instanceof Uint8Array) {
|
|
47
|
+
yield chunk;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The URL of a request, as `Bun.serve` reports it: the Host's origin followed by the target's path.
|
|
54
|
+
*
|
|
55
|
+
* The target is never resolved against the origin as a relative reference. `//home/me/site.json` is
|
|
56
|
+
* how the Studio asks for a file under an absolute project root, and as a reference it is
|
|
57
|
+
* scheme-relative: `new URL()` reads `home` as the host and drops it from the path, so every
|
|
58
|
+
* component and image of such a project answered 404 from the desktop backend. An absolute-form
|
|
59
|
+
* target (`http://other/x`) keeps only its path and query, as Bun keeps them.
|
|
60
|
+
*/
|
|
61
|
+
export function requestUrl(target: string | undefined, origin: string): URL {
|
|
62
|
+
const path = target ?? "/";
|
|
63
|
+
if (path.startsWith("/")) {
|
|
64
|
+
return new URL(`${origin}${path}`);
|
|
65
|
+
}
|
|
66
|
+
const absolute = new URL(path, origin);
|
|
67
|
+
return new URL(`${origin}${absolute.pathname}${absolute.search}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The `Request` for a Node request, with a signal that aborts when the client disconnects. */
|
|
71
|
+
function toRequest(nodeReq: IncomingMessage, origin: string, signal?: AbortSignal): Request {
|
|
72
|
+
const headers = new Headers();
|
|
73
|
+
for (let i = 0; i < nodeReq.rawHeaders.length; i += 2) {
|
|
74
|
+
headers.append(nodeReq.rawHeaders[i]!, nodeReq.rawHeaders[i + 1]!);
|
|
75
|
+
}
|
|
76
|
+
const { host } = nodeReq.headers;
|
|
77
|
+
const url = requestUrl(nodeReq.url, host ? `http://${host}` : origin);
|
|
78
|
+
const method = nodeReq.method ?? "GET";
|
|
79
|
+
const init: RequestInit & { duplex?: "half" } = { headers, method };
|
|
80
|
+
if (signal) {
|
|
81
|
+
init.signal = signal;
|
|
82
|
+
}
|
|
83
|
+
if (!BODYLESS_METHODS.has(method)) {
|
|
84
|
+
const source = chunksOf(nodeReq);
|
|
85
|
+
init.body = new ReadableStream<Uint8Array>({
|
|
86
|
+
async pull(controller) {
|
|
87
|
+
const next = await source.next();
|
|
88
|
+
if (next.done === true) {
|
|
89
|
+
controller.close();
|
|
90
|
+
} else {
|
|
91
|
+
controller.enqueue(next.value);
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
init.duplex = "half";
|
|
96
|
+
}
|
|
97
|
+
return new Request(url, init);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Resolve when `res` can take more, or has closed and never will. */
|
|
101
|
+
function drained(res: ServerResponse): Promise<void> {
|
|
102
|
+
return new Promise((resolve) => {
|
|
103
|
+
const done = (): void => {
|
|
104
|
+
res.off("drain", done);
|
|
105
|
+
res.off("close", done);
|
|
106
|
+
resolve();
|
|
107
|
+
};
|
|
108
|
+
res.on("drain", done);
|
|
109
|
+
res.on("close", done);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Write `response` to `res`, streaming its body and stopping when the client goes away. */
|
|
114
|
+
async function writeResponse(
|
|
115
|
+
response: Response,
|
|
116
|
+
res: ServerResponse,
|
|
117
|
+
method: string,
|
|
118
|
+
): Promise<void> {
|
|
119
|
+
const headers: Record<string, string | string[]> = {};
|
|
120
|
+
for (const [key, value] of response.headers) {
|
|
121
|
+
if (key !== "set-cookie") {
|
|
122
|
+
headers[key] = value;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const cookies = response.headers.getSetCookie();
|
|
126
|
+
if (cookies.length > 0) {
|
|
127
|
+
headers["set-cookie"] = cookies;
|
|
128
|
+
}
|
|
129
|
+
res.writeHead(response.status, response.statusText, headers);
|
|
130
|
+
if (!response.body || method === "HEAD") {
|
|
131
|
+
res.end();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const reader = response.body.getReader();
|
|
135
|
+
const cancel = (): void => {
|
|
136
|
+
void reader.cancel().catch(() => {});
|
|
137
|
+
};
|
|
138
|
+
res.once("close", cancel);
|
|
139
|
+
try {
|
|
140
|
+
for (;;) {
|
|
141
|
+
const { done, value } = await reader.read();
|
|
142
|
+
if (done || res.destroyed) {
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
// `flushHeaders` is implied by the first write; each chunk goes out as it arrives.
|
|
146
|
+
if (!res.write(value)) {
|
|
147
|
+
await drained(res);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
// The body's producer failed or was cancelled; what was sent is all the client gets.
|
|
152
|
+
} finally {
|
|
153
|
+
res.off("close", cancel);
|
|
154
|
+
res.end();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Answer an upgrade request the handler refused (or never accepted) with a plain HTTP response. */
|
|
159
|
+
async function rejectUpgrade(socket: Duplex, response: Response): Promise<void> {
|
|
160
|
+
const body = Buffer.from(await response.arrayBuffer());
|
|
161
|
+
const lines = [`HTTP/1.1 ${response.status} ${response.statusText || "Error"}`];
|
|
162
|
+
for (const [key, value] of response.headers) {
|
|
163
|
+
lines.push(`${key}: ${value}`);
|
|
164
|
+
}
|
|
165
|
+
lines.push(`content-length: ${body.length}`, "connection: close", "", "");
|
|
166
|
+
const head = Buffer.from(lines.join("\r\n"));
|
|
167
|
+
socket.end(Buffer.concat([head, body]));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** A frame as the handler receives it: text as a string, binary as one Buffer. */
|
|
171
|
+
function frameOf(data: RawData, isBinary: boolean): string | Buffer {
|
|
172
|
+
const bytes = Array.isArray(data)
|
|
173
|
+
? Buffer.concat(data)
|
|
174
|
+
: Buffer.isBuffer(data)
|
|
175
|
+
? data
|
|
176
|
+
: Buffer.from(data);
|
|
177
|
+
return isBinary ? bytes : bytes.toString("utf8");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Start a server and resolve once it is listening. */
|
|
181
|
+
export async function serve<Data = undefined>(
|
|
182
|
+
options: ServeOptions<Data>,
|
|
183
|
+
): Promise<ServeServer<Data>> {
|
|
184
|
+
const hostname = options.hostname ?? "0.0.0.0";
|
|
185
|
+
const handler = options.websocket;
|
|
186
|
+
const pending = new WeakMap<Request, PendingUpgrade>();
|
|
187
|
+
const sockets = new Set<WebSocket>();
|
|
188
|
+
const wss = new WebSocketServer({
|
|
189
|
+
handleProtocols: (_protocols, nodeReq) => echoedProtocol.get(nodeReq) ?? false,
|
|
190
|
+
noServer: true,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const http = createServer();
|
|
194
|
+
if (options.idleTimeout !== undefined) {
|
|
195
|
+
http.setTimeout(options.idleTimeout * 1000, (socket) => socket.destroy());
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let origin = "";
|
|
199
|
+
let url = new URL("http://localhost/");
|
|
200
|
+
|
|
201
|
+
const server: ServeServer<Data> = {
|
|
202
|
+
get port() {
|
|
203
|
+
return Number(url.port);
|
|
204
|
+
},
|
|
205
|
+
get url() {
|
|
206
|
+
return url;
|
|
207
|
+
},
|
|
208
|
+
upgrade(req, { data, headers }) {
|
|
209
|
+
const upgrade = pending.get(req);
|
|
210
|
+
if (!upgrade || upgrade.taken) {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
upgrade.taken = true;
|
|
214
|
+
const protocol = headers?.["Sec-WebSocket-Protocol"];
|
|
215
|
+
if (protocol !== undefined) {
|
|
216
|
+
echoedProtocol.set(upgrade.nodeReq, protocol);
|
|
217
|
+
}
|
|
218
|
+
wss.handleUpgrade(upgrade.nodeReq, upgrade.socket, upgrade.head, (ws) => {
|
|
219
|
+
sockets.add(ws);
|
|
220
|
+
const socket: ServeSocket<Data> = {
|
|
221
|
+
data,
|
|
222
|
+
send: (message) => ws.send(message),
|
|
223
|
+
close: (code, reason) => ws.close(code, reason),
|
|
224
|
+
};
|
|
225
|
+
ws.on("message", (raw, isBinary) => {
|
|
226
|
+
void handler?.message?.(socket, frameOf(raw, isBinary));
|
|
227
|
+
});
|
|
228
|
+
ws.on("close", (code, reason) => {
|
|
229
|
+
sockets.delete(ws);
|
|
230
|
+
handler?.close?.(socket, code, reason.toString("utf8"));
|
|
231
|
+
});
|
|
232
|
+
handler?.open?.(socket);
|
|
233
|
+
});
|
|
234
|
+
return true;
|
|
235
|
+
},
|
|
236
|
+
stop(closeActiveConnections) {
|
|
237
|
+
return new Promise<void>((resolve) => {
|
|
238
|
+
http.close(() => resolve());
|
|
239
|
+
if (closeActiveConnections) {
|
|
240
|
+
http.closeAllConnections();
|
|
241
|
+
for (const ws of sockets) {
|
|
242
|
+
ws.terminate();
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
http.on("request", (nodeReq, res) => {
|
|
250
|
+
const controller = new AbortController();
|
|
251
|
+
res.once("close", () => {
|
|
252
|
+
if (!res.writableFinished) {
|
|
253
|
+
controller.abort();
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
const method = nodeReq.method ?? "GET";
|
|
257
|
+
void (async () => {
|
|
258
|
+
let response: Response | undefined;
|
|
259
|
+
try {
|
|
260
|
+
response = await options.fetch(toRequest(nodeReq, origin, controller.signal), server);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
console.error("[serve]", error);
|
|
263
|
+
response = new Response("Internal Server Error", { status: 500 });
|
|
264
|
+
}
|
|
265
|
+
await writeResponse(
|
|
266
|
+
response ?? new Response("Internal Server Error", { status: 500 }),
|
|
267
|
+
res,
|
|
268
|
+
method,
|
|
269
|
+
);
|
|
270
|
+
})();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
http.on("upgrade", (nodeReq, socket, head) => {
|
|
274
|
+
const req = toRequest(nodeReq, origin);
|
|
275
|
+
const upgrade: PendingUpgrade = { head, nodeReq, socket, taken: false };
|
|
276
|
+
pending.set(req, upgrade);
|
|
277
|
+
void (async () => {
|
|
278
|
+
let response: Response | undefined;
|
|
279
|
+
try {
|
|
280
|
+
response = await options.fetch(req, server);
|
|
281
|
+
} catch (error) {
|
|
282
|
+
console.error("[serve]", error);
|
|
283
|
+
response = new Response("Internal Server Error", { status: 500 });
|
|
284
|
+
}
|
|
285
|
+
if (!upgrade.taken) {
|
|
286
|
+
await rejectUpgrade(socket, response ?? new Response("Upgrade failed", { status: 400 }));
|
|
287
|
+
}
|
|
288
|
+
})();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
await new Promise<void>((resolve, reject) => {
|
|
292
|
+
http.once("error", reject);
|
|
293
|
+
http.listen(options.port ?? 0, hostname, () => {
|
|
294
|
+
http.off("error", reject);
|
|
295
|
+
resolve();
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
const address = http.address();
|
|
299
|
+
/* SAFETY: a TCP listener's `address()` is an `AddressInfo`; it is a string only for a pipe or a
|
|
300
|
+
Unix socket, which this function never binds. */
|
|
301
|
+
const { port } = address as { port: number };
|
|
302
|
+
const host =
|
|
303
|
+
hostname === "0.0.0.0" || hostname === "::"
|
|
304
|
+
? "localhost"
|
|
305
|
+
: hostname.includes(":")
|
|
306
|
+
? `[${hostname}]`
|
|
307
|
+
: hostname;
|
|
308
|
+
origin = `http://${host}:${port}`;
|
|
309
|
+
url = new URL(`${origin}/`);
|
|
310
|
+
return server;
|
|
311
|
+
}
|
package/src/serve.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTTP + WebSocket server surface the backend's servers are written against.
|
|
3
|
+
*
|
|
4
|
+
* It is the part of `Bun.serve` they use and nothing more: a Fetch-API handler that may upgrade, a
|
|
5
|
+
* bound port and URL, `stop()`, an idle timeout, and a socket's `data`, `send` and `close`. Two
|
|
6
|
+
* implementations answer it, selected at resolution time by the `#serve` import's conditions
|
|
7
|
+
* (Node's conditional exports): `serve.bun.ts` passes through to `Bun.serve` under Bun, and
|
|
8
|
+
* `serve.node.ts` is `node:http` plus `ws` everywhere else, which is the packaged desktop backend
|
|
9
|
+
* (specs/desktop.md §7.1). A bundle built for Node therefore contains no Bun code at all.
|
|
10
|
+
*
|
|
11
|
+
* `serve()` resolves once the server is listening, because Node binds asynchronously: a port read
|
|
12
|
+
* straight after `listen()` is not there yet.
|
|
13
|
+
*
|
|
14
|
+
* @docs studio/desktop
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** One upgraded WebSocket connection, carrying the `data` its upgrade attached. */
|
|
18
|
+
export interface ServeSocket<Data> {
|
|
19
|
+
readonly data: Data;
|
|
20
|
+
/** Send a text frame (a string) or a binary frame (bytes). A closed socket drops the frame. */
|
|
21
|
+
send: (message: string | Uint8Array) => void;
|
|
22
|
+
close: (code?: number, reason?: string) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Headers an upgrade may add to its 101 response. */
|
|
26
|
+
export interface UpgradeHeaders {
|
|
27
|
+
"Sec-WebSocket-Protocol"?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The running server, as a fetch handler sees it and as its owner stops it. */
|
|
31
|
+
export interface ServeServer<Data> {
|
|
32
|
+
readonly port: number;
|
|
33
|
+
/** The origin the server answers on, with a trailing `/` path. */
|
|
34
|
+
readonly url: URL;
|
|
35
|
+
/**
|
|
36
|
+
* Accept a WebSocket upgrade for `req`, attaching `data` to the socket. True when the upgrade was
|
|
37
|
+
* taken, in which case the handler returns `undefined`.
|
|
38
|
+
*
|
|
39
|
+
* The 101 response's only negotiable header is the subprotocol the server echoes (RFC 6455
|
|
40
|
+
* §4.2.2), so that is the only one this accepts. Under Bun the `ws` package is Bun's own shim,
|
|
41
|
+
* which emits no `headers` event, so a wider set could not be honoured on both runtimes.
|
|
42
|
+
*/
|
|
43
|
+
upgrade: (req: Request, options: { data: Data; headers?: UpgradeHeaders }) => boolean;
|
|
44
|
+
/** Stop listening; with `closeActiveConnections`, also drop open requests and sockets. */
|
|
45
|
+
stop: (closeActiveConnections?: boolean) => Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Callbacks for upgraded sockets. A text frame arrives as a string, a binary one as a Buffer. */
|
|
49
|
+
export interface ServeWebSocketHandler<Data> {
|
|
50
|
+
open?: (ws: ServeSocket<Data>) => void;
|
|
51
|
+
message?: (ws: ServeSocket<Data>, message: string | Buffer) => void | Promise<void>;
|
|
52
|
+
close?: (ws: ServeSocket<Data>, code: number, reason: string) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ServeOptions<Data> {
|
|
56
|
+
/** Interface to bind; `0.0.0.0` when absent. */
|
|
57
|
+
hostname?: string;
|
|
58
|
+
/** Port to bind; `0` picks a free one. */
|
|
59
|
+
port?: number;
|
|
60
|
+
/** Seconds a connection may sit without traffic before it is closed. */
|
|
61
|
+
idleTimeout?: number;
|
|
62
|
+
fetch: (
|
|
63
|
+
req: Request,
|
|
64
|
+
server: ServeServer<Data>,
|
|
65
|
+
) => Response | undefined | Promise<Response | undefined>;
|
|
66
|
+
websocket?: ServeWebSocketHandler<Data>;
|
|
67
|
+
}
|