@abstractframework/code 0.5.0 → 0.6.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 +15 -5
- package/bin/cli.js +24 -19
- package/bin/server.js +169 -644
- package/dist/assets/index-0dumrfuU.css +1 -0
- package/dist/assets/index-Bh7omXp3.js +59 -0
- package/dist/index.html +2 -2
- package/dist/sw.js +4 -3
- package/package.json +8 -5
- package/dist/assets/index-C5-M1QJI.js +0 -58
- package/dist/assets/index-mDhO5UHl.css +0 -1
package/bin/server.js
CHANGED
|
@@ -1,28 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AbstractCode web server: the built app (`dist/`) and the app-origin
|
|
3
|
+
* gateway session proxy, on the shared app-server kit
|
|
4
|
+
* (`@abstractframework/app-server`).
|
|
5
|
+
*
|
|
6
|
+
* - Serves under the gateway's `/apps/code/` as well as at its own port
|
|
7
|
+
* (`createMountedHandler`: base path from `X-Forwarded-Prefix`, the
|
|
8
|
+
* browser's address from a loopback peer's `X-Forwarded-For`, the identity
|
|
9
|
+
* header `X-AbstractFramework-App: code; mount=1` on every response). The
|
|
10
|
+
* shell gets `<base href>` and `base_path` (`injectShell`), so every asset
|
|
11
|
+
* and API call resolves under the base.
|
|
12
|
+
* - `/api/connection/gateway` and `/api/gateway/*` go through the kit's
|
|
13
|
+
* session proxy (HttpOnly session cookie + CSRF twin at `Path=<base>/`,
|
|
14
|
+
* browser `Authorization` and cookies stripped, `X-Forwarded-For` = the
|
|
15
|
+
* browser, server-pinned gateway URL).
|
|
16
|
+
* - What this app adds in front of the proxy: a browser mutation must come
|
|
17
|
+
* from this app's own origin, a sign-in must be JSON, and a sign-in over a
|
|
18
|
+
* loopback socket must name a loopback host (DNS-rebinding guard).
|
|
19
|
+
*/
|
|
1
20
|
import * as http from "node:http";
|
|
2
|
-
import * as https from "node:https";
|
|
3
21
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
4
22
|
import { isIP } from "node:net";
|
|
5
23
|
import { dirname, extname, join, resolve, sep } from "node:path";
|
|
6
24
|
import { fileURLToPath } from "node:url";
|
|
7
25
|
|
|
26
|
+
import {
|
|
27
|
+
createGatewaySessionProxy,
|
|
28
|
+
createMountedHandler,
|
|
29
|
+
injectShell,
|
|
30
|
+
requestContext,
|
|
31
|
+
socketPeerAddress,
|
|
32
|
+
} from "@abstractframework/app-server";
|
|
33
|
+
|
|
34
|
+
export { socketPeerAddress };
|
|
35
|
+
|
|
8
36
|
const BIN_DIR = dirname(fileURLToPath(import.meta.url));
|
|
9
37
|
const DEFAULT_DIST_DIR = resolve(BIN_DIR, "..", "dist");
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
"connection",
|
|
17
|
-
"keep-alive",
|
|
18
|
-
"proxy-authenticate",
|
|
19
|
-
"proxy-authorization",
|
|
20
|
-
"te",
|
|
21
|
-
"trailer",
|
|
22
|
-
"trailers",
|
|
23
|
-
"transfer-encoding",
|
|
24
|
-
"upgrade",
|
|
25
|
-
]);
|
|
38
|
+
/** The gateway's catalog id: `/apps/code/` and the identity header. */
|
|
39
|
+
export const APP_ID = "code";
|
|
40
|
+
/** Cookie / CSRF-header prefix (`abstractcode_gateway_*`, `x-abstractcode-csrf`). */
|
|
41
|
+
export const COOKIE_APP_ID = "abstractcode";
|
|
42
|
+
const CONNECTION_PATH = "/api/connection/gateway";
|
|
43
|
+
const GATEWAY_PREFIX = "/api/gateway/";
|
|
26
44
|
const MIME_TYPES = {
|
|
27
45
|
".html": "text/html",
|
|
28
46
|
".js": "application/javascript",
|
|
@@ -37,656 +55,151 @@ const MIME_TYPES = {
|
|
|
37
55
|
".webmanifest": "application/manifest+json",
|
|
38
56
|
};
|
|
39
57
|
|
|
40
|
-
function
|
|
41
|
-
|
|
42
|
-
.
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const env = options.env || process.env;
|
|
48
|
-
const defaultGatewayUrl =
|
|
49
|
-
normalizeGatewayUrl(
|
|
50
|
-
options.defaultGatewayUrl ||
|
|
51
|
-
env.ABSTRACTCODE_GATEWAY_URL ||
|
|
52
|
-
env.ABSTRACTGATEWAY_URL ||
|
|
53
|
-
FALLBACK_GATEWAY_URL,
|
|
54
|
-
) || FALLBACK_GATEWAY_URL;
|
|
55
|
-
return { env, defaultGatewayUrl };
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function envBool(env, name) {
|
|
59
|
-
const raw = env[name];
|
|
60
|
-
return typeof raw === "string" && TRUE_VALUES.has(raw.trim().toLowerCase());
|
|
58
|
+
function sendJson(res, status, payload) {
|
|
59
|
+
if (res.headersSent) {
|
|
60
|
+
res.destroy();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
64
|
+
res.end(JSON.stringify(payload));
|
|
61
65
|
}
|
|
62
66
|
|
|
63
|
-
function
|
|
64
|
-
|
|
65
|
-
envBool(config.env, "ABSTRACTCODE_TRUST_PROXY_HEADERS") ||
|
|
66
|
-
envBool(config.env, "ABSTRACTGATEWAY_TRUST_PROXY_HEADERS");
|
|
67
|
-
const value = trusted
|
|
68
|
-
? req.headers["x-forwarded-host"] || req.headers.host
|
|
69
|
-
: req.headers.host;
|
|
70
|
-
const raw = String(value || "")
|
|
67
|
+
function firstHeader(value) {
|
|
68
|
+
return String(Array.isArray(value) ? value[0] : value || "")
|
|
71
69
|
.split(",", 1)[0]
|
|
72
70
|
.trim();
|
|
73
|
-
if (!raw) return "";
|
|
74
|
-
if (raw.startsWith("["))
|
|
75
|
-
return raw.slice(1).split("]", 1)[0].trim().toLowerCase();
|
|
76
|
-
if ((raw.match(/:/g) || []).length === 1)
|
|
77
|
-
return raw.split(":")[0].trim().toLowerCase();
|
|
78
|
-
return raw.toLowerCase();
|
|
79
71
|
}
|
|
80
72
|
|
|
81
|
-
function
|
|
82
|
-
|
|
83
|
-
.trim()
|
|
84
|
-
.toLowerCase();
|
|
85
|
-
if (value === "localhost" || value.endsWith(".localhost") || value === "::1") return true;
|
|
86
|
-
return isIP(value) === 4 && Number(value.split(".", 1)[0]) === 127;
|
|
73
|
+
function mutatingMethod(method) {
|
|
74
|
+
return ["POST", "PUT", "PATCH", "DELETE"].includes(String(method || "GET").toUpperCase());
|
|
87
75
|
}
|
|
88
76
|
|
|
89
|
-
function
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if (address.startsWith("::ffff:")) address = address.slice("::ffff:".length);
|
|
94
|
-
return (
|
|
95
|
-
address === "::1" || address === "127.0.0.1" || address.startsWith("127.")
|
|
96
|
-
);
|
|
77
|
+
function hostnameOf(host) {
|
|
78
|
+
const raw = String(host || "").trim().toLowerCase();
|
|
79
|
+
if (raw.startsWith("[")) return raw.slice(1).split("]", 1)[0];
|
|
80
|
+
return (raw.match(/:/g) || []).length === 1 ? raw.split(":")[0] : raw;
|
|
97
81
|
}
|
|
98
82
|
|
|
99
|
-
/**
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
* A-2; same rule as abstractuic's app-server). An unknown peer is refused.
|
|
105
|
-
*/
|
|
106
|
-
export function socketPeerAddress(req) {
|
|
107
|
-
let address = String(req?.socket?.remoteAddress || "").trim().toLowerCase();
|
|
108
|
-
if (address.startsWith("::ffff:") && address.includes(".")) address = address.slice(7);
|
|
109
|
-
return address;
|
|
83
|
+
/** `localhost`, `*.localhost`, `::1` or a 127.x IP LITERAL (never a DNS name starting "127."). */
|
|
84
|
+
function isLoopbackHostname(host) {
|
|
85
|
+
const h = hostnameOf(host);
|
|
86
|
+
if (h === "localhost" || h.endsWith(".localhost") || h === "::1") return true;
|
|
87
|
+
return isIP(h) === 4 && h.startsWith("127.");
|
|
110
88
|
}
|
|
111
89
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
"
|
|
121
|
-
"x-forwarded-host",
|
|
122
|
-
"x-forwarded-proto",
|
|
123
|
-
"x-real-ip",
|
|
124
|
-
"forwarded",
|
|
125
|
-
]);
|
|
126
|
-
|
|
127
|
-
function connectionConfigAllowed(req, config) {
|
|
128
|
-
if (envBool(config.env, "ABSTRACTCODE_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG"))
|
|
129
|
-
return true;
|
|
130
|
-
if (
|
|
131
|
-
envBool(config.env, "ABSTRACTGATEWAY_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG")
|
|
132
|
-
)
|
|
133
|
-
return true;
|
|
134
|
-
const trusted =
|
|
135
|
-
envBool(config.env, "ABSTRACTCODE_TRUST_PROXY_HEADERS") ||
|
|
136
|
-
envBool(config.env, "ABSTRACTGATEWAY_TRUST_PROXY_HEADERS");
|
|
137
|
-
// A trusted proxy makes its own socket address meaningless. Hosted
|
|
138
|
-
// deployments must explicitly opt in before a browser can choose an SSRF
|
|
139
|
-
// destination; direct loopback development remains convenient.
|
|
140
|
-
return (
|
|
141
|
-
!trusted &&
|
|
142
|
-
isLoopbackPeer(req) &&
|
|
143
|
-
isLoopbackHostname(requestHostname(req, config))
|
|
144
|
-
);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function connectionConfigDenial(req, config) {
|
|
148
|
-
const host = requestHostname(req, config) || "unknown host";
|
|
90
|
+
/**
|
|
91
|
+
* The warning printed at start when `--host` is not a loopback address
|
|
92
|
+
* (`0.0.0.0`, `::`, a LAN address or name): the app is then reachable from
|
|
93
|
+
* other machines, and the gateway already serves it at `/apps/code/`.
|
|
94
|
+
* `null` for a loopback host.
|
|
95
|
+
*/
|
|
96
|
+
export function bindExposureWarning(host, port) {
|
|
97
|
+
if (isLoopbackHostname(host)) return null;
|
|
98
|
+
const where = host === "0.0.0.0" || host === "::" || host === "[::]" ? "every network interface" : String(host);
|
|
149
99
|
return (
|
|
150
|
-
`
|
|
151
|
-
|
|
100
|
+
`WARNING: --host ${host} exposes AbstractCode Web beyond this machine: it listens on ${where}, ` +
|
|
101
|
+
`so anyone who can reach this computer on port ${port} can open it. ` +
|
|
102
|
+
`The gateway already serves it at /apps/code/; keep the default --host 127.0.0.1 unless you mean this.`
|
|
152
103
|
);
|
|
153
104
|
}
|
|
154
105
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
for (const part of String(req.headers.cookie || "").split(";")) {
|
|
158
|
-
const index = part.indexOf("=");
|
|
159
|
-
if (index < 0) continue;
|
|
160
|
-
const key = part.slice(0, index).trim();
|
|
161
|
-
const value = part.slice(index + 1).trim();
|
|
162
|
-
if (!key) continue;
|
|
163
|
-
try {
|
|
164
|
-
out[key] = decodeURIComponent(value);
|
|
165
|
-
} catch {
|
|
166
|
-
out[key] = value;
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
return out;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function cookieSecure(req, config) {
|
|
173
|
-
if (req.socket?.encrypted) return "; Secure";
|
|
174
|
-
const trusted =
|
|
175
|
-
envBool(config.env, "ABSTRACTCODE_TRUST_PROXY_HEADERS") ||
|
|
176
|
-
envBool(config.env, "ABSTRACTGATEWAY_TRUST_PROXY_HEADERS");
|
|
177
|
-
return trusted && firstHeader(req.headers["x-forwarded-proto"]).toLowerCase() === "https"
|
|
178
|
-
? "; Secure"
|
|
179
|
-
: "";
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function setSessionCookies(
|
|
183
|
-
res,
|
|
184
|
-
req,
|
|
185
|
-
gatewayUrl,
|
|
186
|
-
sessionId,
|
|
187
|
-
csrfToken,
|
|
188
|
-
persist,
|
|
189
|
-
config,
|
|
190
|
-
) {
|
|
191
|
-
const secure = cookieSecure(req, config);
|
|
192
|
-
const maxAge = persist ? "; Max-Age=2592000" : "";
|
|
193
|
-
const privateAttrs = `; Path=/; HttpOnly; SameSite=Lax${secure}${maxAge}`;
|
|
194
|
-
const csrfAttrs = `; Path=/; SameSite=Lax${secure}${maxAge}`;
|
|
195
|
-
res.setHeader("Set-Cookie", [
|
|
196
|
-
`${URL_COOKIE}=${encodeURIComponent(gatewayUrl)}${privateAttrs}`,
|
|
197
|
-
`${SESSION_COOKIE}=${encodeURIComponent(sessionId)}${privateAttrs}`,
|
|
198
|
-
`${CSRF_COOKIE}=${encodeURIComponent(csrfToken)}${csrfAttrs}`,
|
|
199
|
-
]);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function clearSessionCookies(res, req, config) {
|
|
203
|
-
const secure = cookieSecure(req, config);
|
|
204
|
-
res.setHeader("Set-Cookie", [
|
|
205
|
-
`${URL_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`,
|
|
206
|
-
`${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`,
|
|
207
|
-
`${CSRF_COOKIE}=; Path=/; SameSite=Lax; Max-Age=0${secure}`,
|
|
208
|
-
]);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
function browserSession(req, config) {
|
|
212
|
-
const cookies = parseCookies(req);
|
|
213
|
-
const cookieUrl = normalizeGatewayUrl(cookies[URL_COOKIE]);
|
|
214
|
-
const allowCookieUrl =
|
|
215
|
-
envBool(config.env, "ABSTRACTCODE_ALLOW_BROWSER_GATEWAY_URL_COOKIE") ||
|
|
216
|
-
envBool(config.env, "ABSTRACTCODE_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG") ||
|
|
217
|
-
envBool(
|
|
218
|
-
config.env,
|
|
219
|
-
"ABSTRACTGATEWAY_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG",
|
|
220
|
-
) ||
|
|
221
|
-
connectionConfigAllowed(req, config);
|
|
222
|
-
return {
|
|
223
|
-
gatewayUrl:
|
|
224
|
-
cookieUrl && (allowCookieUrl || cookieUrl === config.defaultGatewayUrl)
|
|
225
|
-
? cookieUrl
|
|
226
|
-
: config.defaultGatewayUrl,
|
|
227
|
-
sessionId: String(cookies[SESSION_COOKIE] || "").trim(),
|
|
228
|
-
csrfToken: String(cookies[CSRF_COOKIE] || "").trim(),
|
|
229
|
-
};
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
function resolveBackend(gatewayUrl, config) {
|
|
233
|
-
const url = new URL(
|
|
234
|
-
normalizeGatewayUrl(gatewayUrl) || config.defaultGatewayUrl,
|
|
235
|
-
);
|
|
236
|
-
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
237
|
-
throw new Error("Gateway URL must use http or https");
|
|
238
|
-
if (!url.port) url.port = url.protocol === "https:" ? "443" : "80";
|
|
239
|
-
return {
|
|
240
|
-
url,
|
|
241
|
-
origin: `${url.protocol}//${url.host}`,
|
|
242
|
-
client: url.protocol === "https:" ? https : http,
|
|
243
|
-
};
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
function mutatingMethod(method) {
|
|
247
|
-
return ["POST", "PUT", "PATCH", "DELETE"].includes(
|
|
248
|
-
String(method || "GET").toUpperCase(),
|
|
249
|
-
);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
function firstHeader(value) {
|
|
253
|
-
return String(Array.isArray(value) ? value[0] : value || "")
|
|
254
|
-
.split(",", 1)[0]
|
|
255
|
-
.trim();
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
function requestOrigin(req, config) {
|
|
259
|
-
const trusted =
|
|
260
|
-
envBool(config.env, "ABSTRACTCODE_TRUST_PROXY_HEADERS") ||
|
|
261
|
-
envBool(config.env, "ABSTRACTGATEWAY_TRUST_PROXY_HEADERS");
|
|
262
|
-
const host = firstHeader(
|
|
263
|
-
trusted
|
|
264
|
-
? req.headers["x-forwarded-host"] || req.headers.host
|
|
265
|
-
: req.headers.host,
|
|
266
|
-
);
|
|
267
|
-
const protocol =
|
|
268
|
-
firstHeader(trusted ? req.headers["x-forwarded-proto"] : "") ||
|
|
269
|
-
(req.socket?.encrypted ? "https" : "http");
|
|
270
|
-
if (!host || !["http", "https"].includes(protocol.toLowerCase())) return "";
|
|
271
|
-
try {
|
|
272
|
-
return new URL(`${protocol.toLowerCase()}://${host}`).origin;
|
|
273
|
-
} catch {
|
|
274
|
-
return "";
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
function browserMutationAllowed(req, config) {
|
|
106
|
+
/** A browser mutation must come from the origin the browser sees this app at. */
|
|
107
|
+
function browserMutationAllowed(req, ctx) {
|
|
279
108
|
const origin = firstHeader(req.headers.origin);
|
|
280
109
|
if (origin) {
|
|
281
|
-
let normalized;
|
|
282
110
|
try {
|
|
283
|
-
|
|
111
|
+
return new URL(origin).origin === new URL(`${ctx.proto}://${ctx.host}`).origin;
|
|
284
112
|
} catch {
|
|
285
113
|
return false;
|
|
286
114
|
}
|
|
287
|
-
return normalized === requestOrigin(req, config);
|
|
288
115
|
}
|
|
289
|
-
return (
|
|
290
|
-
firstHeader(req.headers["sec-fetch-site"]).toLowerCase() !== "cross-site"
|
|
291
|
-
);
|
|
116
|
+
return firstHeader(req.headers["sec-fetch-site"]).toLowerCase() !== "cross-site";
|
|
292
117
|
}
|
|
293
118
|
|
|
294
119
|
function jsonRequest(req) {
|
|
295
|
-
return (
|
|
296
|
-
firstHeader(req.headers["content-type"])
|
|
297
|
-
.split(";", 1)[0]
|
|
298
|
-
.trim()
|
|
299
|
-
.toLowerCase() === "application/json"
|
|
300
|
-
);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
function sendJson(res, status, payload) {
|
|
304
|
-
if (res.headersSent) {
|
|
305
|
-
res.destroy();
|
|
306
|
-
return;
|
|
307
|
-
}
|
|
308
|
-
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
309
|
-
res.end(JSON.stringify(payload));
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
function readRequestJson(req) {
|
|
313
|
-
return new Promise((resolveValue) => {
|
|
314
|
-
const chunks = [];
|
|
315
|
-
req.on("data", (chunk) => chunks.push(chunk));
|
|
316
|
-
req.on("end", () => {
|
|
317
|
-
try {
|
|
318
|
-
const raw = Buffer.concat(chunks).toString("utf8");
|
|
319
|
-
resolveValue(raw ? JSON.parse(raw) : {});
|
|
320
|
-
} catch {
|
|
321
|
-
resolveValue({});
|
|
322
|
-
}
|
|
323
|
-
});
|
|
324
|
-
req.on("error", () => resolveValue({}));
|
|
325
|
-
});
|
|
120
|
+
return firstHeader(req.headers["content-type"]).split(";", 1)[0].trim().toLowerCase() === "application/json";
|
|
326
121
|
}
|
|
327
122
|
|
|
328
|
-
function
|
|
329
|
-
return new Promise((resolveValue) => {
|
|
330
|
-
let backend;
|
|
331
|
-
try {
|
|
332
|
-
backend = resolveBackend(gatewayUrl, config);
|
|
333
|
-
} catch (error) {
|
|
334
|
-
resolveValue({
|
|
335
|
-
ok: false,
|
|
336
|
-
status: 0,
|
|
337
|
-
payload: {
|
|
338
|
-
detail: `Invalid gateway URL: ${String(error?.message || error)}`,
|
|
339
|
-
},
|
|
340
|
-
});
|
|
341
|
-
return;
|
|
342
|
-
}
|
|
343
|
-
const request = backend.client.request(
|
|
344
|
-
{
|
|
345
|
-
protocol: backend.url.protocol,
|
|
346
|
-
hostname: backend.url.hostname,
|
|
347
|
-
port: backend.url.port,
|
|
348
|
-
timeout: options.timeout || 4000,
|
|
349
|
-
...options,
|
|
350
|
-
},
|
|
351
|
-
(response) => {
|
|
352
|
-
const chunks = [];
|
|
353
|
-
response.on("data", (chunk) => chunks.push(chunk));
|
|
354
|
-
response.on("end", () => {
|
|
355
|
-
const raw = Buffer.concat(chunks).toString("utf8");
|
|
356
|
-
let payload;
|
|
357
|
-
try {
|
|
358
|
-
payload = raw ? JSON.parse(raw) : {};
|
|
359
|
-
} catch {
|
|
360
|
-
payload = { detail: raw };
|
|
361
|
-
}
|
|
362
|
-
const status = response.statusCode || 0;
|
|
363
|
-
resolveValue({
|
|
364
|
-
ok: status >= 200 && status < 300,
|
|
365
|
-
status,
|
|
366
|
-
payload,
|
|
367
|
-
headers: response.headers,
|
|
368
|
-
origin: backend.origin,
|
|
369
|
-
});
|
|
370
|
-
});
|
|
371
|
-
},
|
|
372
|
-
);
|
|
373
|
-
request.on("timeout", () => {
|
|
374
|
-
request.destroy();
|
|
375
|
-
resolveValue({
|
|
376
|
-
ok: false,
|
|
377
|
-
status: 0,
|
|
378
|
-
payload: { detail: "Gateway request timed out" },
|
|
379
|
-
origin: backend.origin,
|
|
380
|
-
});
|
|
381
|
-
});
|
|
382
|
-
request.on("error", (error) =>
|
|
383
|
-
resolveValue({
|
|
384
|
-
ok: false,
|
|
385
|
-
status: 0,
|
|
386
|
-
payload: { detail: String(error?.message || error) },
|
|
387
|
-
origin: backend.origin,
|
|
388
|
-
}),
|
|
389
|
-
);
|
|
390
|
-
if (body) request.write(body);
|
|
391
|
-
request.end();
|
|
392
|
-
});
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
function cookieValueFromSetCookie(rawHeaders, name) {
|
|
396
|
-
const headers = Array.isArray(rawHeaders)
|
|
397
|
-
? rawHeaders
|
|
398
|
-
: rawHeaders
|
|
399
|
-
? [rawHeaders]
|
|
400
|
-
: [];
|
|
401
|
-
for (const header of headers) {
|
|
402
|
-
for (const candidate of String(header || "").split(/,(?=\s*[^;,=]+=)/)) {
|
|
403
|
-
const first = candidate.split(";", 1)[0];
|
|
404
|
-
const index = first.indexOf("=");
|
|
405
|
-
if (index < 0 || first.slice(0, index).trim() !== name) continue;
|
|
406
|
-
const raw = first.slice(index + 1).trim();
|
|
407
|
-
try {
|
|
408
|
-
return decodeURIComponent(raw);
|
|
409
|
-
} catch {
|
|
410
|
-
return raw;
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
return "";
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
async function handleConnectionApi(req, res, config) {
|
|
123
|
+
function isLoopbackPeerSocket(req) {
|
|
418
124
|
const peer = socketPeerAddress(req);
|
|
419
|
-
|
|
420
|
-
sendJson(res, 400, UNKNOWN_PEER);
|
|
421
|
-
return;
|
|
422
|
-
}
|
|
423
|
-
if (req.method === "GET") {
|
|
424
|
-
const session = browserSession(req, config);
|
|
425
|
-
if (!session.sessionId) {
|
|
426
|
-
sendJson(res, 200, {
|
|
427
|
-
ok: false,
|
|
428
|
-
gateway_url: session.gatewayUrl,
|
|
429
|
-
has_session: false,
|
|
430
|
-
gateway: { ok: false, error: "Gateway sign-in required" },
|
|
431
|
-
});
|
|
432
|
-
return;
|
|
433
|
-
}
|
|
434
|
-
const checked = await gatewayRequest(
|
|
435
|
-
session.gatewayUrl,
|
|
436
|
-
{
|
|
437
|
-
method: "GET",
|
|
438
|
-
path: "/api/gateway/me",
|
|
439
|
-
headers: {
|
|
440
|
-
Accept: "application/json",
|
|
441
|
-
"X-AbstractGateway-Session": session.sessionId,
|
|
442
|
-
"X-Forwarded-For": peer,
|
|
443
|
-
[APP_PROXY_HEADER]: APP_PROXY_NAME,
|
|
444
|
-
},
|
|
445
|
-
},
|
|
446
|
-
undefined,
|
|
447
|
-
config,
|
|
448
|
-
);
|
|
449
|
-
sendJson(res, 200, {
|
|
450
|
-
ok: checked.ok,
|
|
451
|
-
gateway_url: session.gatewayUrl,
|
|
452
|
-
has_session: true,
|
|
453
|
-
gateway: checked.payload,
|
|
454
|
-
});
|
|
455
|
-
return;
|
|
456
|
-
}
|
|
457
|
-
if (req.method === "POST") {
|
|
458
|
-
if (!jsonRequest(req)) {
|
|
459
|
-
sendJson(res, 415, {
|
|
460
|
-
detail:
|
|
461
|
-
"Gateway connection requests require Content-Type: application/json",
|
|
462
|
-
});
|
|
463
|
-
return;
|
|
464
|
-
}
|
|
465
|
-
const payload = await readRequestJson(req);
|
|
466
|
-
const gatewayUrl =
|
|
467
|
-
normalizeGatewayUrl(payload.gateway_url || config.defaultGatewayUrl) ||
|
|
468
|
-
config.defaultGatewayUrl;
|
|
469
|
-
if (
|
|
470
|
-
!connectionConfigAllowed(req, config) &&
|
|
471
|
-
gatewayUrl !== config.defaultGatewayUrl
|
|
472
|
-
) {
|
|
473
|
-
sendJson(res, 403, { detail: connectionConfigDenial(req, config) });
|
|
474
|
-
return;
|
|
475
|
-
}
|
|
476
|
-
const body = Buffer.from(
|
|
477
|
-
JSON.stringify({
|
|
478
|
-
user_id: String(payload.gateway_user_id || "").trim(),
|
|
479
|
-
token: String(payload.gateway_token || "").trim(),
|
|
480
|
-
remember: payload.persist === true,
|
|
481
|
-
}),
|
|
482
|
-
);
|
|
483
|
-
const login = await gatewayRequest(
|
|
484
|
-
gatewayUrl,
|
|
485
|
-
{
|
|
486
|
-
method: "POST",
|
|
487
|
-
path: "/api/gateway/session/login",
|
|
488
|
-
headers: {
|
|
489
|
-
Accept: "application/json",
|
|
490
|
-
"Content-Type": "application/json",
|
|
491
|
-
"Content-Length": String(body.length),
|
|
492
|
-
"X-Forwarded-For": peer,
|
|
493
|
-
[APP_PROXY_HEADER]: APP_PROXY_NAME,
|
|
494
|
-
},
|
|
495
|
-
},
|
|
496
|
-
body,
|
|
497
|
-
config,
|
|
498
|
-
);
|
|
499
|
-
const session =
|
|
500
|
-
login.payload && typeof login.payload.session === "object"
|
|
501
|
-
? login.payload.session
|
|
502
|
-
: {};
|
|
503
|
-
const setCookie = login.headers?.["set-cookie"];
|
|
504
|
-
const sessionId =
|
|
505
|
-
cookieValueFromSetCookie(setCookie, "abstractgateway_session") ||
|
|
506
|
-
String(session.session_id || "").trim();
|
|
507
|
-
const csrfToken =
|
|
508
|
-
cookieValueFromSetCookie(setCookie, "abstractgateway_csrf") ||
|
|
509
|
-
String(session.csrf_token || "").trim();
|
|
510
|
-
if (!login.ok || !sessionId || !csrfToken) {
|
|
511
|
-
sendJson(res, login.status || 401, {
|
|
512
|
-
ok: false,
|
|
513
|
-
detail: login.payload?.detail || "Gateway browser session failed",
|
|
514
|
-
gateway: login.payload,
|
|
515
|
-
});
|
|
516
|
-
return;
|
|
517
|
-
}
|
|
518
|
-
setSessionCookies(
|
|
519
|
-
res,
|
|
520
|
-
req,
|
|
521
|
-
gatewayUrl,
|
|
522
|
-
sessionId,
|
|
523
|
-
csrfToken,
|
|
524
|
-
payload.persist === true,
|
|
525
|
-
config,
|
|
526
|
-
);
|
|
527
|
-
sendJson(res, 200, {
|
|
528
|
-
ok: true,
|
|
529
|
-
gateway_url: gatewayUrl,
|
|
530
|
-
has_session: true,
|
|
531
|
-
gateway: login.payload,
|
|
532
|
-
});
|
|
533
|
-
return;
|
|
534
|
-
}
|
|
535
|
-
if (req.method === "DELETE") {
|
|
536
|
-
const session = browserSession(req, config);
|
|
537
|
-
if (session.sessionId) {
|
|
538
|
-
const body = Buffer.from("{}");
|
|
539
|
-
await gatewayRequest(
|
|
540
|
-
session.gatewayUrl,
|
|
541
|
-
{
|
|
542
|
-
method: "POST",
|
|
543
|
-
path: "/api/gateway/session/logout",
|
|
544
|
-
timeout: 2000,
|
|
545
|
-
headers: {
|
|
546
|
-
Accept: "application/json",
|
|
547
|
-
"Content-Type": "application/json",
|
|
548
|
-
"Content-Length": String(body.length),
|
|
549
|
-
"X-AbstractGateway-Session": session.sessionId,
|
|
550
|
-
"X-AbstractGateway-CSRF": session.csrfToken,
|
|
551
|
-
"X-Forwarded-For": peer,
|
|
552
|
-
[APP_PROXY_HEADER]: APP_PROXY_NAME,
|
|
553
|
-
},
|
|
554
|
-
},
|
|
555
|
-
body,
|
|
556
|
-
config,
|
|
557
|
-
);
|
|
558
|
-
}
|
|
559
|
-
clearSessionCookies(res, req, config);
|
|
560
|
-
sendJson(res, 200, { ok: true });
|
|
561
|
-
return;
|
|
562
|
-
}
|
|
563
|
-
sendJson(res, 405, { detail: "Method not allowed" });
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
function proxyHeaders(headers) {
|
|
567
|
-
const out = {};
|
|
568
|
-
for (const [key, value] of Object.entries(headers || {})) {
|
|
569
|
-
const normalized = String(key).toLowerCase();
|
|
570
|
-
if (HOP_HEADERS.has(normalized) || normalized === "content-length")
|
|
571
|
-
continue;
|
|
572
|
-
out[key] = value;
|
|
573
|
-
}
|
|
574
|
-
return out;
|
|
125
|
+
return peer === "::1" || peer.startsWith("127.");
|
|
575
126
|
}
|
|
576
127
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
return;
|
|
582
|
-
}
|
|
583
|
-
const peer = socketPeerAddress(req);
|
|
584
|
-
if (!peer) {
|
|
585
|
-
sendJson(res, 400, UNKNOWN_PEER);
|
|
586
|
-
return;
|
|
587
|
-
}
|
|
588
|
-
if (mutatingMethod(req.method)) {
|
|
589
|
-
const presented = String(req.headers["x-abstractcode-csrf"] || "").trim();
|
|
590
|
-
if (!session.csrfToken || presented !== session.csrfToken) {
|
|
591
|
-
sendJson(res, 403, {
|
|
592
|
-
detail: "Gateway browser session CSRF token missing or invalid",
|
|
593
|
-
reason_code: "csrf_required",
|
|
594
|
-
});
|
|
595
|
-
return;
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
let backend;
|
|
599
|
-
try {
|
|
600
|
-
backend = resolveBackend(session.gatewayUrl, config);
|
|
601
|
-
} catch (error) {
|
|
602
|
-
sendJson(res, 500, {
|
|
603
|
-
detail: `Invalid gateway URL: ${String(error?.message || error)}`,
|
|
604
|
-
});
|
|
605
|
-
return;
|
|
606
|
-
}
|
|
607
|
-
const headers = { ...req.headers, host: backend.url.host };
|
|
608
|
-
delete headers.cookie;
|
|
609
|
-
delete headers.authorization;
|
|
610
|
-
// Forwarding headers: drop every client-supplied spelling (any case, plus
|
|
611
|
-
// RFC 7239 `Forwarded` and `X-Real-IP`), then X-Forwarded-For = the socket
|
|
612
|
-
// peer, so the gateway can tell whether the browser is on its machine.
|
|
613
|
-
for (const key of Object.keys(headers))
|
|
614
|
-
if (FORWARDING_HEADERS.has(key.toLowerCase())) delete headers[key];
|
|
615
|
-
headers["x-forwarded-for"] = peer;
|
|
616
|
-
headers["x-abstractframework-app-proxy"] = APP_PROXY_NAME;
|
|
617
|
-
delete headers["x-abstractcode-csrf"];
|
|
618
|
-
headers["x-abstractgateway-session"] = session.sessionId;
|
|
619
|
-
if (mutatingMethod(req.method))
|
|
620
|
-
headers["x-abstractgateway-csrf"] = session.csrfToken;
|
|
621
|
-
const proxyRequest = backend.client.request(
|
|
622
|
-
{
|
|
623
|
-
protocol: backend.url.protocol,
|
|
624
|
-
hostname: backend.url.hostname,
|
|
625
|
-
port: backend.url.port,
|
|
626
|
-
method: req.method,
|
|
627
|
-
path: req.url,
|
|
628
|
-
headers,
|
|
629
|
-
},
|
|
630
|
-
(proxyResponse) => {
|
|
631
|
-
res.writeHead(
|
|
632
|
-
proxyResponse.statusCode || 502,
|
|
633
|
-
proxyHeaders(proxyResponse.headers),
|
|
634
|
-
);
|
|
635
|
-
proxyResponse.pipe(res);
|
|
636
|
-
},
|
|
128
|
+
/** The operator declared a reverse proxy in front of this app (legacy env switches, read by the kit too). */
|
|
129
|
+
function trustProxyDeclared() {
|
|
130
|
+
return ["ABSTRACTCODE_TRUST_PROXY_HEADERS", "ABSTRACTGATEWAY_TRUST_PROXY_HEADERS"].some((name) =>
|
|
131
|
+
["1", "true", "yes", "y", "on"].includes(String(process.env[name] || "").trim().toLowerCase()),
|
|
637
132
|
);
|
|
638
|
-
proxyRequest.on("error", (error) =>
|
|
639
|
-
sendJson(res, 502, {
|
|
640
|
-
detail: `Backend not reachable at ${backend.origin} (${String(error?.message || error)})`,
|
|
641
|
-
}),
|
|
642
|
-
);
|
|
643
|
-
req.pipe(proxyRequest);
|
|
644
133
|
}
|
|
645
134
|
|
|
646
|
-
function
|
|
135
|
+
function pathnameOf(req) {
|
|
647
136
|
return new URL(req.url || "/", "http://abstractcode.local").pathname;
|
|
648
137
|
}
|
|
649
138
|
|
|
650
|
-
/**
|
|
139
|
+
/**
|
|
140
|
+
* The gateway half: connection API + `/api/gateway/*`, with this app's
|
|
141
|
+
* guards in front of the kit proxy. Connect-compatible (`next` for every
|
|
142
|
+
* other path), so the Vite dev server mounts the SAME code.
|
|
143
|
+
*/
|
|
651
144
|
export function createGatewayMiddleware(options = {}) {
|
|
652
|
-
const
|
|
653
|
-
|
|
145
|
+
const proxy = createGatewaySessionProxy({
|
|
146
|
+
appId: COOKIE_APP_ID,
|
|
147
|
+
...(options.defaultGatewayUrl ? { defaultGatewayUrl: options.defaultGatewayUrl } : {}),
|
|
148
|
+
connectionPath: CONNECTION_PATH,
|
|
149
|
+
proxyPrefix: GATEWAY_PREFIX,
|
|
150
|
+
});
|
|
151
|
+
function middleware(req, res, next) {
|
|
654
152
|
let pathname;
|
|
655
153
|
try {
|
|
656
|
-
pathname =
|
|
154
|
+
pathname = pathnameOf(req);
|
|
657
155
|
} catch {
|
|
658
156
|
sendJson(res, 400, { detail: "Invalid request URL" });
|
|
659
157
|
return;
|
|
660
158
|
}
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
159
|
+
const owned = pathname === CONNECTION_PATH || pathname.startsWith(GATEWAY_PREFIX);
|
|
160
|
+
if (!owned) {
|
|
161
|
+
if (typeof next === "function") next();
|
|
162
|
+
else sendJson(res, 404, { detail: "Not found" });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
let ctx;
|
|
166
|
+
try {
|
|
167
|
+
ctx = requestContext(req);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
sendJson(res, Number(error?.status) || 400, { detail: String(error?.message || error) });
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
// DNS rebinding: a page at a hostile name resolving to 127.0.0.1 reaches
|
|
173
|
+
// this loopback server with its own Host; as a same-origin script it can
|
|
174
|
+
// also add X-Forwarded-* headers, which a loopback peer is believed for.
|
|
175
|
+
// So a sign-in over a loopback socket must name a loopback host (the
|
|
176
|
+
// gateway's /apps/code/ proxy and a local browser both do), unless the
|
|
177
|
+
// operator declared a reverse proxy in front of this app.
|
|
178
|
+
if (
|
|
179
|
+
pathname === CONNECTION_PATH &&
|
|
180
|
+
mutatingMethod(req.method) &&
|
|
181
|
+
isLoopbackPeerSocket(req) &&
|
|
182
|
+
!isLoopbackHostname(req.headers.host) &&
|
|
183
|
+
!trustProxyDeclared()
|
|
184
|
+
) {
|
|
185
|
+
sendJson(res, 403, {
|
|
186
|
+
detail: `Sign-in refused: this local app was reached under the host name ${hostnameOf(req.headers.host) || "(none)"}, not a loopback address. Browser-supplied Gateway URL changes are disabled for it.`,
|
|
187
|
+
reason_code: "host_not_allowed",
|
|
673
188
|
});
|
|
674
189
|
return;
|
|
675
190
|
}
|
|
676
|
-
if (
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
return;
|
|
683
|
-
}
|
|
684
|
-
proxyGatewayRequest(req, res, config);
|
|
191
|
+
if (mutatingMethod(req.method) && !browserMutationAllowed(req, ctx)) {
|
|
192
|
+
sendJson(res, 403, { detail: "Cross-origin browser requests are not allowed", reason_code: "origin_required" });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (pathname === CONNECTION_PATH && req.method === "POST" && !jsonRequest(req)) {
|
|
196
|
+
sendJson(res, 415, { detail: "Gateway connection requests require Content-Type: application/json" });
|
|
685
197
|
return;
|
|
686
198
|
}
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
199
|
+
proxy.handle(req, res, pathname);
|
|
200
|
+
}
|
|
201
|
+
middleware.proxy = proxy;
|
|
202
|
+
return middleware;
|
|
690
203
|
}
|
|
691
204
|
|
|
692
205
|
function unsafeStaticPath(req) {
|
|
@@ -700,48 +213,60 @@ function unsafeStaticPath(req) {
|
|
|
700
213
|
return decoded.includes("\0") || decoded.split(/[\\/]+/).includes("..");
|
|
701
214
|
}
|
|
702
215
|
|
|
703
|
-
function
|
|
216
|
+
function isFile(filePath) {
|
|
704
217
|
try {
|
|
705
|
-
|
|
706
|
-
res.writeHead(200, {
|
|
707
|
-
"Content-Type":
|
|
708
|
-
MIME_TYPES[extname(filePath).toLowerCase()] ||
|
|
709
|
-
"application/octet-stream",
|
|
710
|
-
"Cache-Control": "no-cache",
|
|
711
|
-
});
|
|
712
|
-
res.end(readFileSync(filePath));
|
|
713
|
-
return true;
|
|
218
|
+
return existsSync(filePath) && statSync(filePath).isFile();
|
|
714
219
|
} catch {
|
|
715
220
|
return false;
|
|
716
221
|
}
|
|
717
222
|
}
|
|
718
223
|
|
|
224
|
+
function serveFile(res, filePath) {
|
|
225
|
+
if (!isFile(filePath)) return false;
|
|
226
|
+
res.writeHead(200, {
|
|
227
|
+
"Content-Type": MIME_TYPES[extname(filePath).toLowerCase()] || "application/octet-stream",
|
|
228
|
+
"Cache-Control": "no-cache",
|
|
229
|
+
});
|
|
230
|
+
res.end(readFileSync(filePath));
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The app shell with `<base href="<basePath>/">` and `base_path`. */
|
|
235
|
+
function serveShell(res, distDir, basePath, gatewayUrl) {
|
|
236
|
+
const index = join(distDir, "index.html");
|
|
237
|
+
if (!isFile(index)) return false;
|
|
238
|
+
const html = injectShell(readFileSync(index, "utf8"), { basePath, config: { gateway_url: gatewayUrl } });
|
|
239
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" });
|
|
240
|
+
res.end(html);
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
|
|
719
244
|
export function createCodeRequestHandler(options = {}) {
|
|
720
245
|
const distDir = resolve(options.distDir || DEFAULT_DIST_DIR);
|
|
721
246
|
const gatewayMiddleware = createGatewayMiddleware(options);
|
|
722
|
-
return
|
|
247
|
+
return createMountedHandler({ appId: APP_ID }, (req, res, ctx) => {
|
|
723
248
|
gatewayMiddleware(req, res, () => {
|
|
724
249
|
let pathname;
|
|
725
250
|
try {
|
|
726
|
-
pathname = decodeURIComponent(
|
|
251
|
+
pathname = decodeURIComponent(pathnameOf(req));
|
|
727
252
|
} catch {
|
|
728
253
|
res.writeHead(400);
|
|
729
254
|
res.end("Bad Request");
|
|
730
255
|
return;
|
|
731
256
|
}
|
|
732
|
-
if (
|
|
733
|
-
|
|
734
|
-
pathname.startsWith("/api/") ||
|
|
735
|
-
unsafeStaticPath(req)
|
|
736
|
-
) {
|
|
737
|
-
if (pathname === "/api" || pathname.startsWith("/api/"))
|
|
738
|
-
sendJson(res, 404, { detail: "Not found" });
|
|
739
|
-
else {
|
|
740
|
-
res.writeHead(400);
|
|
741
|
-
res.end("Bad Request");
|
|
742
|
-
}
|
|
257
|
+
if (pathname === "/api" || pathname.startsWith("/api/")) {
|
|
258
|
+
sendJson(res, 404, { detail: "Not found" });
|
|
743
259
|
return;
|
|
744
260
|
}
|
|
261
|
+
if (unsafeStaticPath(req)) {
|
|
262
|
+
res.writeHead(400);
|
|
263
|
+
res.end("Bad Request");
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const gatewayUrl = gatewayMiddleware.proxy.defaultGatewayUrl;
|
|
267
|
+
if (pathname === "/" || pathname === "/index.html") {
|
|
268
|
+
if (serveShell(res, distDir, ctx.basePath, gatewayUrl)) return;
|
|
269
|
+
}
|
|
745
270
|
const filePath = resolve(distDir, pathname.replace(/^\/+/, ""));
|
|
746
271
|
if (filePath !== distDir && !filePath.startsWith(`${distDir}${sep}`)) {
|
|
747
272
|
res.writeHead(400);
|
|
@@ -750,12 +275,12 @@ export function createCodeRequestHandler(options = {}) {
|
|
|
750
275
|
}
|
|
751
276
|
if (serveFile(res, filePath)) return;
|
|
752
277
|
if (serveFile(res, `${filePath}.html`)) return;
|
|
753
|
-
|
|
754
|
-
if (
|
|
278
|
+
// Single-page app: any other path is the shell.
|
|
279
|
+
if (serveShell(res, distDir, ctx.basePath, gatewayUrl)) return;
|
|
755
280
|
res.writeHead(404);
|
|
756
281
|
res.end("Not Found");
|
|
757
282
|
});
|
|
758
|
-
};
|
|
283
|
+
});
|
|
759
284
|
}
|
|
760
285
|
|
|
761
286
|
export function createCodeServer(options = {}) {
|