radiant-docs 0.1.62 → 0.1.63
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/package.json +1 -1
- package/template/astro.config.mjs +38 -27
- package/template/package-lock.json +2857 -1145
- package/template/package.json +16 -20
- package/template/scripts/generate-proxy-allowed-origins.mjs +10 -187
- package/template/scripts/publish-shiki-platform-assets.mjs +32 -6
- package/template/src/components/chat/AssistantEmbedPanel.tsx +133 -1
- package/template/src/components/endpoint/PlaygroundForm.astro +69 -55
- package/template/src/components/endpoint/ResponseDisplay.astro +2 -2
- package/template/src/generated/shiki-platform-assets.json +8 -8
- package/template/src/lib/assistant-panel-config.ts +2 -57
- package/template/src/lib/assistant-shiki-client.ts +16 -0
- package/template/src/lib/client-shiki-config.ts +60 -0
- package/template/src/lib/dev-playground-proxy.mjs +597 -0
- package/template/src/lib/proxy-allowed-origins.mjs +189 -0
- package/template/src/styles/global.css +4 -4
- package/template/src/components/ui/demo/CodeDemo.astro +0 -15
- package/template/src/components/ui/demo/Demo.astro +0 -3
- package/template/src/components/ui/demo/UiDisplay.astro +0 -13
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
import dns from "node:dns/promises";
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { Readable } from "node:stream";
|
|
5
|
+
import {
|
|
6
|
+
buildAllowedOrigins,
|
|
7
|
+
normalizeAllowedOrigin,
|
|
8
|
+
} from "./proxy-allowed-origins.mjs";
|
|
9
|
+
|
|
10
|
+
const PROXY_PATHNAME = "/_platform/proxy";
|
|
11
|
+
const POLICY_CACHE_TTL_MS = 30_000;
|
|
12
|
+
const MAX_REQUEST_BODY_BYTES = 10 * 1024 * 1024;
|
|
13
|
+
const PROXY_TIMEOUT_MS = 30_000;
|
|
14
|
+
const MAX_REDIRECTS = 5;
|
|
15
|
+
|
|
16
|
+
const DROP_REQUEST_HEADERS = new Set([
|
|
17
|
+
"accept-encoding",
|
|
18
|
+
"connection",
|
|
19
|
+
"cookie",
|
|
20
|
+
"cf-connecting-ip",
|
|
21
|
+
"cf-worker",
|
|
22
|
+
"host",
|
|
23
|
+
"keep-alive",
|
|
24
|
+
"origin",
|
|
25
|
+
"proxy-connection",
|
|
26
|
+
"referer",
|
|
27
|
+
"sec-fetch-dest",
|
|
28
|
+
"sec-fetch-mode",
|
|
29
|
+
"sec-fetch-site",
|
|
30
|
+
"sec-fetch-user",
|
|
31
|
+
"te",
|
|
32
|
+
"trailer",
|
|
33
|
+
"transfer-encoding",
|
|
34
|
+
"upgrade",
|
|
35
|
+
"x-forwarded-for",
|
|
36
|
+
"x-proxy-cookie",
|
|
37
|
+
"x-proxy-url",
|
|
38
|
+
"x-real-ip",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
const DROP_RESPONSE_HEADERS = new Set([
|
|
42
|
+
"connection",
|
|
43
|
+
"content-encoding",
|
|
44
|
+
"content-length",
|
|
45
|
+
"keep-alive",
|
|
46
|
+
"proxy-authenticate",
|
|
47
|
+
"proxy-authorization",
|
|
48
|
+
"proxy-connection",
|
|
49
|
+
"set-cookie",
|
|
50
|
+
"set-cookie2",
|
|
51
|
+
"te",
|
|
52
|
+
"trailer",
|
|
53
|
+
"transfer-encoding",
|
|
54
|
+
"upgrade",
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
58
|
+
|
|
59
|
+
class ProxyRequestError extends Error {
|
|
60
|
+
constructor(status, message) {
|
|
61
|
+
super(message);
|
|
62
|
+
this.status = status;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalizePathname(pathname) {
|
|
67
|
+
const normalized = pathname.replace(/\/{2,}/g, "/").replace(/\/+$/, "");
|
|
68
|
+
return normalized || "/";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function stripBasePath(pathname, basePath) {
|
|
72
|
+
const normalizedBase = normalizePathname(basePath || "/");
|
|
73
|
+
if (normalizedBase === "/") {
|
|
74
|
+
return pathname;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (pathname === normalizedBase) {
|
|
78
|
+
return "/";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (pathname.startsWith(`${normalizedBase}/`)) {
|
|
82
|
+
return pathname.slice(normalizedBase.length) || "/";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return pathname;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function normalizeHostname(hostname) {
|
|
89
|
+
return hostname
|
|
90
|
+
.trim()
|
|
91
|
+
.toLowerCase()
|
|
92
|
+
.replace(/^\[/, "")
|
|
93
|
+
.replace(/\]$/, "")
|
|
94
|
+
.replace(/\.$/, "");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function parseIpv4Address(address) {
|
|
98
|
+
const parts = address.split(".");
|
|
99
|
+
if (parts.length !== 4) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const bytes = [];
|
|
104
|
+
for (const part of parts) {
|
|
105
|
+
if (!/^\d+$/.test(part)) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const byte = Number(part);
|
|
110
|
+
if (!Number.isInteger(byte) || byte < 0 || byte > 255) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
bytes.push(byte);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return bytes;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isPrivateIpv4Address(address) {
|
|
120
|
+
const bytes = parseIpv4Address(address);
|
|
121
|
+
if (!bytes) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const [a, b] = bytes;
|
|
126
|
+
return (
|
|
127
|
+
a === 0 ||
|
|
128
|
+
a === 10 ||
|
|
129
|
+
a === 127 ||
|
|
130
|
+
(a === 100 && b >= 64 && b <= 127) ||
|
|
131
|
+
(a === 169 && b === 254) ||
|
|
132
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
133
|
+
(a === 192 && b === 168) ||
|
|
134
|
+
(a === 198 && (b === 18 || b === 19)) ||
|
|
135
|
+
a >= 224
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function isBlockedIpv6Address(address) {
|
|
140
|
+
const normalized = normalizeHostname(address);
|
|
141
|
+
if (
|
|
142
|
+
normalized === "::" ||
|
|
143
|
+
normalized === "::1" ||
|
|
144
|
+
normalized.startsWith("fe80:") ||
|
|
145
|
+
normalized.startsWith("fe90:") ||
|
|
146
|
+
normalized.startsWith("fea0:") ||
|
|
147
|
+
normalized.startsWith("feb0:") ||
|
|
148
|
+
normalized.startsWith("fc") ||
|
|
149
|
+
normalized.startsWith("fd")
|
|
150
|
+
) {
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const mappedIpv4 = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
155
|
+
return mappedIpv4 ? isPrivateIpv4Address(mappedIpv4[1]) : false;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function isLocalHostname(hostname) {
|
|
159
|
+
const normalized = normalizeHostname(hostname);
|
|
160
|
+
return (
|
|
161
|
+
normalized === "localhost" ||
|
|
162
|
+
normalized.endsWith(".localhost") ||
|
|
163
|
+
normalized.endsWith(".local")
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function isPrivateOrLocalHostname(hostname) {
|
|
168
|
+
const normalized = normalizeHostname(hostname);
|
|
169
|
+
if (isLocalHostname(normalized)) {
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const ipType = net.isIP(normalized);
|
|
174
|
+
if (ipType === 4) {
|
|
175
|
+
return isPrivateIpv4Address(normalized);
|
|
176
|
+
}
|
|
177
|
+
if (ipType === 6) {
|
|
178
|
+
return isBlockedIpv6Address(normalized);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function hostnameResolvesToPrivateAddress(hostname) {
|
|
185
|
+
const normalized = normalizeHostname(hostname);
|
|
186
|
+
if (net.isIP(normalized)) {
|
|
187
|
+
return isPrivateOrLocalHostname(normalized);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const addresses = await dns.lookup(normalized, {
|
|
191
|
+
all: true,
|
|
192
|
+
verbatim: true,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
return addresses.some(({ address }) => isPrivateOrLocalHostname(address));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function isLoopbackAddress(address) {
|
|
199
|
+
if (!address) {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const normalized = normalizeHostname(address);
|
|
204
|
+
if (normalized === "::1") {
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (normalized.startsWith("::ffff:")) {
|
|
209
|
+
return isLoopbackAddress(normalized.slice("::ffff:".length));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return normalized.startsWith("127.");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function isLocalOrigin(origin) {
|
|
216
|
+
try {
|
|
217
|
+
const parsed = new URL(origin);
|
|
218
|
+
const hostname = normalizeHostname(parsed.hostname);
|
|
219
|
+
return (
|
|
220
|
+
hostname === "localhost" ||
|
|
221
|
+
hostname === "::1" ||
|
|
222
|
+
hostname === "127.0.0.1" ||
|
|
223
|
+
hostname.endsWith(".localhost")
|
|
224
|
+
);
|
|
225
|
+
} catch {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function getRequestHeader(request, name) {
|
|
231
|
+
const value = request.headers[name.toLowerCase()];
|
|
232
|
+
if (Array.isArray(value)) {
|
|
233
|
+
return value[0] ?? null;
|
|
234
|
+
}
|
|
235
|
+
return typeof value === "string" ? value : null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function buildCorsHeaders(request) {
|
|
239
|
+
const origin = getRequestHeader(request, "origin");
|
|
240
|
+
if (!origin || !isLocalOrigin(origin)) {
|
|
241
|
+
return {};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
"Access-Control-Allow-Headers":
|
|
246
|
+
"Content-Type, Authorization, x-proxy-url, x-proxy-cookie",
|
|
247
|
+
"Access-Control-Allow-Methods": "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
248
|
+
"Access-Control-Allow-Origin": origin,
|
|
249
|
+
"Access-Control-Max-Age": "600",
|
|
250
|
+
Vary: "Origin",
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function writeTextResponse(response, status, message, headers = {}) {
|
|
255
|
+
response.writeHead(status, {
|
|
256
|
+
"Cache-Control": "no-store",
|
|
257
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
258
|
+
...headers,
|
|
259
|
+
});
|
|
260
|
+
response.end(message);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function isAllowedIncomingRequest(request) {
|
|
264
|
+
if (!isLoopbackAddress(request.socket.remoteAddress)) {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const origin = getRequestHeader(request, "origin");
|
|
269
|
+
return !origin || isLocalOrigin(origin);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function readRequestBody(request) {
|
|
273
|
+
return new Promise((resolve, reject) => {
|
|
274
|
+
const chunks = [];
|
|
275
|
+
let totalBytes = 0;
|
|
276
|
+
|
|
277
|
+
request.on("data", (chunk) => {
|
|
278
|
+
totalBytes += chunk.length;
|
|
279
|
+
if (totalBytes > MAX_REQUEST_BODY_BYTES) {
|
|
280
|
+
reject(new ProxyRequestError(413, "Request body is too large"));
|
|
281
|
+
request.destroy();
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
chunks.push(chunk);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
request.on("end", () => {
|
|
288
|
+
resolve(Buffer.concat(chunks));
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
request.on("error", reject);
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function buildForwardedHeaders(request) {
|
|
296
|
+
const headers = new Headers();
|
|
297
|
+
|
|
298
|
+
for (const [name, value] of Object.entries(request.headers)) {
|
|
299
|
+
const lowerName = name.toLowerCase();
|
|
300
|
+
if (DROP_REQUEST_HEADERS.has(lowerName)) {
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (lowerName.startsWith("sec-")) {
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (Array.isArray(value)) {
|
|
309
|
+
for (const item of value) {
|
|
310
|
+
headers.append(name, item);
|
|
311
|
+
}
|
|
312
|
+
} else if (typeof value === "string") {
|
|
313
|
+
headers.set(name, value);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const proxyCookieHeader = getRequestHeader(request, "x-proxy-cookie");
|
|
318
|
+
if (proxyCookieHeader) {
|
|
319
|
+
headers.set("cookie", proxyCookieHeader);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return headers;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function getTargetPolicyError(target, allowedOrigins) {
|
|
326
|
+
if (target.protocol !== "https:") {
|
|
327
|
+
return "Only HTTPS proxy targets are allowed";
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (!target.hostname || target.username || target.password) {
|
|
331
|
+
return "Proxy target is invalid";
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const normalizedTargetOrigin = normalizeAllowedOrigin(target.origin);
|
|
335
|
+
if (!normalizedTargetOrigin || !allowedOrigins.includes(normalizedTargetOrigin)) {
|
|
336
|
+
return "Proxy target origin is not allowed for this site";
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (isPrivateOrLocalHostname(target.hostname)) {
|
|
340
|
+
return "Proxy target is blocked";
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
try {
|
|
344
|
+
if (await hostnameResolvesToPrivateAddress(target.hostname)) {
|
|
345
|
+
return "Proxy target resolves to a blocked address";
|
|
346
|
+
}
|
|
347
|
+
} catch {
|
|
348
|
+
return "Proxy target hostname could not be verified";
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function fetchWithVerifiedRedirects(initialTarget, init, allowedOrigins) {
|
|
355
|
+
let target = initialTarget;
|
|
356
|
+
let method = init.method;
|
|
357
|
+
let body = init.body;
|
|
358
|
+
const headers = new Headers(init.headers);
|
|
359
|
+
|
|
360
|
+
for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount += 1) {
|
|
361
|
+
const response = await fetch(target, {
|
|
362
|
+
body: ["GET", "HEAD"].includes(method) ? undefined : body,
|
|
363
|
+
headers,
|
|
364
|
+
method,
|
|
365
|
+
redirect: "manual",
|
|
366
|
+
signal: init.signal,
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
if (!REDIRECT_STATUSES.has(response.status)) {
|
|
370
|
+
return response;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const location = response.headers.get("location");
|
|
374
|
+
if (!location) {
|
|
375
|
+
return response;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const nextTarget = new URL(location, target);
|
|
379
|
+
const policyError = await getTargetPolicyError(nextTarget, allowedOrigins);
|
|
380
|
+
if (policyError) {
|
|
381
|
+
throw new ProxyRequestError(403, `Redirect target blocked: ${policyError}`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (response.status === 303 || ([301, 302].includes(response.status) && method === "POST")) {
|
|
385
|
+
method = "GET";
|
|
386
|
+
body = undefined;
|
|
387
|
+
headers.delete("content-type");
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
target = nextTarget;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
throw new ProxyRequestError(508, "Too many redirects");
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function writeProxyResponse(response, upstreamResponse, request) {
|
|
397
|
+
const headers = {};
|
|
398
|
+
const corsHeaders = buildCorsHeaders(request);
|
|
399
|
+
|
|
400
|
+
for (const [key, value] of upstreamResponse.headers) {
|
|
401
|
+
if (DROP_RESPONSE_HEADERS.has(key.toLowerCase())) {
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
headers[key] = value;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
Object.assign(headers, corsHeaders, {
|
|
408
|
+
"Cache-Control": "no-store",
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
response.writeHead(upstreamResponse.status, headers);
|
|
412
|
+
|
|
413
|
+
if (!upstreamResponse.body) {
|
|
414
|
+
response.end();
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
Readable.fromWeb(upstreamResponse.body).pipe(response);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function isContentDocsPath(filePath, docsDir) {
|
|
422
|
+
const relativePath = path.relative(docsDir, filePath);
|
|
423
|
+
return Boolean(relativePath) && !relativePath.startsWith("..");
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function createDevPlaygroundProxyPlugin({ basePath = "/" } = {}) {
|
|
427
|
+
const cwd = process.cwd();
|
|
428
|
+
const docsDir = path.join(cwd, "src/content/docs");
|
|
429
|
+
let allowedOriginsCache = null;
|
|
430
|
+
let allowedOriginsCacheTime = 0;
|
|
431
|
+
let allowedOriginsPromise = null;
|
|
432
|
+
|
|
433
|
+
function clearAllowedOriginsCache(filePath) {
|
|
434
|
+
if (typeof filePath === "string" && !isContentDocsPath(filePath, docsDir)) {
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
allowedOriginsCache = null;
|
|
439
|
+
allowedOriginsCacheTime = 0;
|
|
440
|
+
allowedOriginsPromise = null;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async function getAllowedOrigins() {
|
|
444
|
+
const now = Date.now();
|
|
445
|
+
if (
|
|
446
|
+
allowedOriginsCache &&
|
|
447
|
+
now - allowedOriginsCacheTime < POLICY_CACHE_TTL_MS
|
|
448
|
+
) {
|
|
449
|
+
return allowedOriginsCache;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (!allowedOriginsPromise) {
|
|
453
|
+
allowedOriginsPromise = buildAllowedOrigins({
|
|
454
|
+
cwd,
|
|
455
|
+
onSourceError(source, error) {
|
|
456
|
+
console.warn(
|
|
457
|
+
`⚠️ Failed to extract OpenAPI server origins from "${source}":`,
|
|
458
|
+
error instanceof Error ? error.message : String(error),
|
|
459
|
+
);
|
|
460
|
+
},
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
try {
|
|
465
|
+
allowedOriginsCache = await allowedOriginsPromise;
|
|
466
|
+
allowedOriginsCacheTime = Date.now();
|
|
467
|
+
return allowedOriginsCache;
|
|
468
|
+
} finally {
|
|
469
|
+
allowedOriginsPromise = null;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async function handleProxyRequest(request, response) {
|
|
474
|
+
const requestUrl = new URL(request.url || "/", "http://localhost");
|
|
475
|
+
const routedPathname = stripBasePath(requestUrl.pathname, basePath);
|
|
476
|
+
if (routedPathname !== PROXY_PATHNAME) {
|
|
477
|
+
return false;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const corsHeaders = buildCorsHeaders(request);
|
|
481
|
+
|
|
482
|
+
if (!isAllowedIncomingRequest(request)) {
|
|
483
|
+
writeTextResponse(response, 403, "Proxy request is blocked", corsHeaders);
|
|
484
|
+
return true;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (request.method === "OPTIONS") {
|
|
488
|
+
response.writeHead(204, corsHeaders);
|
|
489
|
+
response.end();
|
|
490
|
+
return true;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const method = (request.method || "GET").toUpperCase();
|
|
494
|
+
if (!["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"].includes(method)) {
|
|
495
|
+
writeTextResponse(response, 405, "Method not allowed", corsHeaders);
|
|
496
|
+
return true;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const targetUrl = getRequestHeader(request, "x-proxy-url");
|
|
500
|
+
if (!targetUrl) {
|
|
501
|
+
writeTextResponse(response, 400, "Missing x-proxy-url header", corsHeaders);
|
|
502
|
+
return true;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
let target;
|
|
506
|
+
try {
|
|
507
|
+
target = new URL(targetUrl);
|
|
508
|
+
} catch {
|
|
509
|
+
writeTextResponse(response, 400, "Invalid x-proxy-url value", corsHeaders);
|
|
510
|
+
return true;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const allowedOrigins = await getAllowedOrigins();
|
|
514
|
+
if (allowedOrigins.length === 0) {
|
|
515
|
+
writeTextResponse(
|
|
516
|
+
response,
|
|
517
|
+
403,
|
|
518
|
+
"No allowed proxy origins are configured for this site",
|
|
519
|
+
corsHeaders,
|
|
520
|
+
);
|
|
521
|
+
return true;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const policyError = await getTargetPolicyError(target, allowedOrigins);
|
|
525
|
+
if (policyError) {
|
|
526
|
+
writeTextResponse(response, 403, policyError, corsHeaders);
|
|
527
|
+
return true;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const forwardedHeaders = buildForwardedHeaders(request);
|
|
531
|
+
let body;
|
|
532
|
+
try {
|
|
533
|
+
body = ["GET", "HEAD"].includes(method)
|
|
534
|
+
? undefined
|
|
535
|
+
: await readRequestBody(request);
|
|
536
|
+
} catch (error) {
|
|
537
|
+
if (error instanceof ProxyRequestError) {
|
|
538
|
+
writeTextResponse(response, error.status, error.message, corsHeaders);
|
|
539
|
+
} else {
|
|
540
|
+
writeTextResponse(response, 400, "Failed to read request body", corsHeaders);
|
|
541
|
+
}
|
|
542
|
+
return true;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const controller = new AbortController();
|
|
546
|
+
const timeout = setTimeout(() => controller.abort(), PROXY_TIMEOUT_MS);
|
|
547
|
+
|
|
548
|
+
try {
|
|
549
|
+
const upstreamResponse = await fetchWithVerifiedRedirects(
|
|
550
|
+
target,
|
|
551
|
+
{
|
|
552
|
+
body,
|
|
553
|
+
headers: forwardedHeaders,
|
|
554
|
+
method,
|
|
555
|
+
signal: controller.signal,
|
|
556
|
+
},
|
|
557
|
+
allowedOrigins,
|
|
558
|
+
);
|
|
559
|
+
writeProxyResponse(response, upstreamResponse, request);
|
|
560
|
+
} catch (error) {
|
|
561
|
+
if (error instanceof ProxyRequestError) {
|
|
562
|
+
writeTextResponse(response, error.status, error.message, corsHeaders);
|
|
563
|
+
} else if (error?.name === "AbortError") {
|
|
564
|
+
writeTextResponse(response, 504, "Upstream request timed out", corsHeaders);
|
|
565
|
+
} else {
|
|
566
|
+
console.error("Local playground proxy request failed:", error);
|
|
567
|
+
writeTextResponse(response, 502, "Upstream request failed", corsHeaders);
|
|
568
|
+
}
|
|
569
|
+
} finally {
|
|
570
|
+
clearTimeout(timeout);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
return true;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return {
|
|
577
|
+
name: "radiant-dev-playground-proxy",
|
|
578
|
+
configureServer(server) {
|
|
579
|
+
server.watcher.on("add", clearAllowedOriginsCache);
|
|
580
|
+
server.watcher.on("change", clearAllowedOriginsCache);
|
|
581
|
+
server.watcher.on("unlink", clearAllowedOriginsCache);
|
|
582
|
+
|
|
583
|
+
server.middlewares.use((request, response, next) => {
|
|
584
|
+
handleProxyRequest(request, response)
|
|
585
|
+
.then((handled) => {
|
|
586
|
+
if (!handled) {
|
|
587
|
+
next();
|
|
588
|
+
}
|
|
589
|
+
})
|
|
590
|
+
.catch((error) => {
|
|
591
|
+
console.error("Local playground proxy failed:", error);
|
|
592
|
+
writeTextResponse(response, 500, "Local proxy failed");
|
|
593
|
+
});
|
|
594
|
+
});
|
|
595
|
+
},
|
|
596
|
+
};
|
|
597
|
+
}
|