@neuralnomads/codenomad-dev 0.15.0-dev-20260509-71531697 → 0.15.0-dev-20260511-efe3f505
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/dist/index.js +4 -0
- package/dist/previews/manager.js +63 -0
- package/dist/server/http-server.js +372 -14
- package/dist/server/routes/previews.js +25 -0
- package/package.json +1 -1
- package/public/assets/{ChangesTab-CnpuqxE4.js → ChangesTab-DGWzwppj.js} +2 -2
- package/public/assets/{DiffToolbar-CNfiD6cp.js → DiffToolbar-dXDVyXwL.js} +1 -1
- package/public/assets/{FilesTab-Bkn2Glaq.js → FilesTab-IN-3vEym.js} +2 -2
- package/public/assets/{GitChangesTab-D5kJ45cq.js → GitChangesTab-yhDvCETZ.js} +2 -2
- package/public/assets/{SplitFilePanel-DMnUqWnk.js → SplitFilePanel-01YOnTOG.js} +1 -1
- package/public/assets/StatusTab-CI5m-66G.js +1 -0
- package/public/assets/{align-justify-pBFgPIct.js → align-justify-DRTNZj9A.js} +1 -1
- package/public/assets/{bundle-full-CAX-gm_x.js → bundle-full-D0o5M_wa.js} +1 -1
- package/public/assets/{diff-viewer-hE2nGS5H.js → diff-viewer-jXMkhi-L.js} +1 -1
- package/public/assets/{index-Dsw4vOfl.css → index-BBdin4zl.css} +1 -1
- package/public/assets/{index-DPZxQS72.js → index-Be11ZFEb.js} +1 -1
- package/public/assets/index-CNG3sDWM.js +1 -0
- package/public/assets/index-CZr9LwXP.js +1 -0
- package/public/assets/{index-BJHLw6jN.js → index-Cb-FGBAx.js} +1 -1
- package/public/assets/index-CfmQyy0c.js +1 -0
- package/public/assets/index-CmWJRIhG.js +1 -0
- package/public/assets/index-Cpe_O_IY.js +1 -0
- package/public/assets/index-PvzRwXKl.js +2 -0
- package/public/assets/index-fZ-H5e-Q.js +1 -0
- package/public/assets/{loading-aqR3QZbb.js → loading-BNcShOZ7.js} +1 -1
- package/public/assets/main-CkAUlWH0.js +62 -0
- package/public/assets/{markdown-CpWe8Ysv.js → markdown-BO-eqU7M.js} +3 -3
- package/public/assets/{monaco-viewer-DephYCRo.js → monaco-viewer-CUKSC3oL.js} +10 -10
- package/public/assets/{tool-call-DE6Sz4v2.js → tool-call-B2LR4TW4.js} +3 -3
- package/public/assets/{unified-picker-Drs_u9Rq.js → unified-picker-hHK-At_1.js} +1 -1
- package/public/assets/{wrap-text-DATH1miG.js → wrap-text-y6UsswWF.js} +1 -1
- package/public/index.html +4 -4
- package/public/loading.html +4 -4
- package/public/sw.js +1 -1
- package/public/assets/StatusTab-DgvQQ67G.js +0 -1
- package/public/assets/index-BHFt2rWX.js +0 -1
- package/public/assets/index-Bv35Fv-v.js +0 -2
- package/public/assets/index-CFwfPYqZ.js +0 -1
- package/public/assets/index-CNMyfcAf.js +0 -1
- package/public/assets/index-CNcpLvjh.js +0 -1
- package/public/assets/index-DazH9JZS.js +0 -1
- package/public/assets/index-Z75kPRX1.js +0 -1
- package/public/assets/main-DhuDexWn.js +0 -57
package/dist/index.js
CHANGED
|
@@ -25,6 +25,7 @@ import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/networ
|
|
|
25
25
|
import { startDevReleaseMonitor } from "./releases/dev-release-monitor";
|
|
26
26
|
import { SpeechService } from "./speech/service";
|
|
27
27
|
import { SideCarManager } from "./sidecars/manager";
|
|
28
|
+
import { PreviewManager } from "./previews/manager";
|
|
28
29
|
import { ClientConnectionManager } from "./clients/connection-manager";
|
|
29
30
|
import { PluginChannelManager } from "./plugins/channel";
|
|
30
31
|
import { VoiceModeManager } from "./plugins/voice-mode";
|
|
@@ -230,6 +231,7 @@ async function main() {
|
|
|
230
231
|
eventBus,
|
|
231
232
|
logger: logger.child({ component: "sidecars" }),
|
|
232
233
|
});
|
|
234
|
+
const previewManager = new PreviewManager();
|
|
233
235
|
const instanceEventBridge = new InstanceEventBridge({
|
|
234
236
|
workspaceManager,
|
|
235
237
|
eventBus,
|
|
@@ -313,6 +315,7 @@ async function main() {
|
|
|
313
315
|
instanceStore,
|
|
314
316
|
speechService,
|
|
315
317
|
sidecarManager,
|
|
318
|
+
previewManager,
|
|
316
319
|
authManager,
|
|
317
320
|
clientConnectionManager,
|
|
318
321
|
pluginChannel,
|
|
@@ -338,6 +341,7 @@ async function main() {
|
|
|
338
341
|
instanceStore,
|
|
339
342
|
speechService,
|
|
340
343
|
sidecarManager,
|
|
344
|
+
previewManager,
|
|
341
345
|
authManager,
|
|
342
346
|
clientConnectionManager,
|
|
343
347
|
pluginChannel,
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
export class PreviewManager {
|
|
3
|
+
constructor() {
|
|
4
|
+
this.previews = new Map();
|
|
5
|
+
}
|
|
6
|
+
create(sessionId, rawUrl) {
|
|
7
|
+
const target = this.normalizeTargetUrl(rawUrl);
|
|
8
|
+
const token = randomUUID();
|
|
9
|
+
const record = {
|
|
10
|
+
token,
|
|
11
|
+
sessionId,
|
|
12
|
+
target,
|
|
13
|
+
createdAt: new Date().toISOString(),
|
|
14
|
+
};
|
|
15
|
+
this.previews.set(token, record);
|
|
16
|
+
return this.toPreviewSession(record);
|
|
17
|
+
}
|
|
18
|
+
get(token) {
|
|
19
|
+
const record = this.previews.get(token);
|
|
20
|
+
return record ? this.toPreviewSession(record) : undefined;
|
|
21
|
+
}
|
|
22
|
+
delete(token) {
|
|
23
|
+
return this.previews.delete(token);
|
|
24
|
+
}
|
|
25
|
+
buildTargetUrl(token, incomingPath, search = "") {
|
|
26
|
+
const record = this.previews.get(token);
|
|
27
|
+
if (!record)
|
|
28
|
+
return undefined;
|
|
29
|
+
const publicBase = this.buildProxyBasePath(token);
|
|
30
|
+
let targetPath = incomingPath.startsWith(publicBase) ? incomingPath.slice(publicBase.length) : incomingPath;
|
|
31
|
+
if (!targetPath || targetPath === "/") {
|
|
32
|
+
targetPath = record.target.pathname || "/";
|
|
33
|
+
}
|
|
34
|
+
else if (!targetPath.startsWith("/")) {
|
|
35
|
+
targetPath = `/${targetPath}`;
|
|
36
|
+
}
|
|
37
|
+
return new URL(`${targetPath}${search}`, record.target.origin);
|
|
38
|
+
}
|
|
39
|
+
buildProxyBasePath(token) {
|
|
40
|
+
return `/previews/${encodeURIComponent(token)}`;
|
|
41
|
+
}
|
|
42
|
+
normalizeTargetUrl(rawUrl) {
|
|
43
|
+
const trimmed = rawUrl.trim();
|
|
44
|
+
const withProtocol = /^[a-z][a-z0-9+.-]*:/i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
|
45
|
+
const target = new URL(withProtocol);
|
|
46
|
+
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
|
47
|
+
throw new Error("Preview URL must use HTTP or HTTPS");
|
|
48
|
+
}
|
|
49
|
+
if (target.username || target.password) {
|
|
50
|
+
throw new Error("Preview URL cannot include credentials");
|
|
51
|
+
}
|
|
52
|
+
return target;
|
|
53
|
+
}
|
|
54
|
+
toPreviewSession(record) {
|
|
55
|
+
return {
|
|
56
|
+
token: record.token,
|
|
57
|
+
sessionId: record.sessionId,
|
|
58
|
+
targetUrl: record.target.toString(),
|
|
59
|
+
proxyUrl: `${this.buildProxyBasePath(record.token)}${record.target.pathname}${record.target.search}${record.target.hash}`,
|
|
60
|
+
createdAt: record.createdAt,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -22,6 +22,7 @@ import { registerSpeechRoutes } from "./routes/speech";
|
|
|
22
22
|
import { registerRemoteServerRoutes } from "./routes/remote-servers";
|
|
23
23
|
import { registerRemoteProxyRoutes } from "./routes/remote-proxy";
|
|
24
24
|
import { registerSideCarRoutes } from "./routes/sidecars";
|
|
25
|
+
import { registerPreviewRoutes } from "./routes/previews";
|
|
25
26
|
import { BackgroundProcessManager } from "../background-processes/manager";
|
|
26
27
|
import { registerAuthRoutes } from "./routes/auth";
|
|
27
28
|
import { sendUnauthorized, wantsHtml } from "../auth/http-auth";
|
|
@@ -148,7 +149,7 @@ export function createHttpServer(deps) {
|
|
|
148
149
|
return;
|
|
149
150
|
}
|
|
150
151
|
const session = deps.authManager.getSessionFromRequest(request);
|
|
151
|
-
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/") || pathname.startsWith("/sidecars/");
|
|
152
|
+
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/") || pathname.startsWith("/sidecars/") || pathname.startsWith("/previews/");
|
|
152
153
|
if (requiresAuthForApi && !session) {
|
|
153
154
|
// Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
|
|
154
155
|
const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/);
|
|
@@ -210,12 +211,19 @@ export function createHttpServer(deps) {
|
|
|
210
211
|
registerRemoteProxyRoutes(app, { logger: proxyLogger, sessionManager: deps.remoteProxySessionManager });
|
|
211
212
|
registerSpeechRoutes(app, { speechService: deps.speechService });
|
|
212
213
|
registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager });
|
|
214
|
+
registerPreviewRoutes(app, { previewManager: deps.previewManager });
|
|
213
215
|
registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger });
|
|
216
|
+
registerPreviewProxyRoutes(app, { previewManager: deps.previewManager, logger: proxyLogger });
|
|
214
217
|
setupSideCarWebSocketProxy(app, {
|
|
215
218
|
sidecarManager: deps.sidecarManager,
|
|
216
219
|
authManager: deps.authManager,
|
|
217
220
|
logger: proxyLogger,
|
|
218
221
|
});
|
|
222
|
+
setupPreviewWebSocketProxy(app, {
|
|
223
|
+
previewManager: deps.previewManager,
|
|
224
|
+
authManager: deps.authManager,
|
|
225
|
+
logger: proxyLogger,
|
|
226
|
+
});
|
|
219
227
|
registerPluginRoutes(app, {
|
|
220
228
|
workspaceManager: deps.workspaceManager,
|
|
221
229
|
eventBus: deps.eventBus,
|
|
@@ -226,10 +234,10 @@ export function createHttpServer(deps) {
|
|
|
226
234
|
registerBackgroundProcessRoutes(app, { backgroundProcessManager });
|
|
227
235
|
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger });
|
|
228
236
|
if (deps.uiDevServerUrl) {
|
|
229
|
-
setupDevProxy(app, deps.uiDevServerUrl, deps.authManager);
|
|
237
|
+
setupDevProxy(app, deps.uiDevServerUrl, deps.authManager, deps.previewManager, proxyLogger);
|
|
230
238
|
}
|
|
231
239
|
else {
|
|
232
|
-
setupStaticUi(app, deps.uiStaticDir, deps.authManager);
|
|
240
|
+
setupStaticUi(app, deps.uiStaticDir, deps.authManager, deps.previewManager, proxyLogger);
|
|
233
241
|
}
|
|
234
242
|
return {
|
|
235
243
|
instance: app,
|
|
@@ -306,6 +314,28 @@ function registerSideCarProxyRoutes(app, deps) {
|
|
|
306
314
|
app.all("/sidecars/:id", proxyBaseHandler);
|
|
307
315
|
app.all("/sidecars/:id/*", proxyWildcardHandler);
|
|
308
316
|
}
|
|
317
|
+
function registerPreviewProxyRoutes(app, deps) {
|
|
318
|
+
const proxyBaseHandler = async (request, reply) => {
|
|
319
|
+
await proxyPreviewRequest({
|
|
320
|
+
request,
|
|
321
|
+
reply,
|
|
322
|
+
previewManager: deps.previewManager,
|
|
323
|
+
logger: deps.logger,
|
|
324
|
+
pathSuffix: "",
|
|
325
|
+
});
|
|
326
|
+
};
|
|
327
|
+
const proxyWildcardHandler = async (request, reply) => {
|
|
328
|
+
await proxyPreviewRequest({
|
|
329
|
+
request,
|
|
330
|
+
reply,
|
|
331
|
+
previewManager: deps.previewManager,
|
|
332
|
+
logger: deps.logger,
|
|
333
|
+
pathSuffix: request.params["*"] ?? "",
|
|
334
|
+
});
|
|
335
|
+
};
|
|
336
|
+
app.all("/previews/:token", proxyBaseHandler);
|
|
337
|
+
app.all("/previews/:token/*", proxyWildcardHandler);
|
|
338
|
+
}
|
|
309
339
|
function setupSideCarWebSocketProxy(app, deps) {
|
|
310
340
|
app.server.on("upgrade", (request, socket, head) => {
|
|
311
341
|
const rawUrl = request.url ?? "/";
|
|
@@ -326,6 +356,26 @@ function setupSideCarWebSocketProxy(app, deps) {
|
|
|
326
356
|
});
|
|
327
357
|
});
|
|
328
358
|
}
|
|
359
|
+
function setupPreviewWebSocketProxy(app, deps) {
|
|
360
|
+
app.server.on("upgrade", (request, socket, head) => {
|
|
361
|
+
const rawUrl = request.url ?? "/";
|
|
362
|
+
const parsed = parsePreviewUpgradePath(rawUrl);
|
|
363
|
+
if (!parsed) {
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
void proxyPreviewWebSocketUpgrade({
|
|
367
|
+
request,
|
|
368
|
+
socket: socket,
|
|
369
|
+
head,
|
|
370
|
+
token: parsed.token,
|
|
371
|
+
incomingPath: parsed.pathname,
|
|
372
|
+
search: parsed.search,
|
|
373
|
+
previewManager: deps.previewManager,
|
|
374
|
+
authManager: deps.authManager,
|
|
375
|
+
logger: deps.logger,
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
}
|
|
329
379
|
function registerInstanceProxyRoutes(app, deps) {
|
|
330
380
|
app.register(async (instance) => {
|
|
331
381
|
instance.removeAllContentTypeParsers();
|
|
@@ -608,7 +658,7 @@ function normalizeInstanceSuffix(pathSuffix) {
|
|
|
608
658
|
const trimmed = pathSuffix.replace(/^\/+/, "");
|
|
609
659
|
return trimmed.length === 0 ? "/" : `/${trimmed}`;
|
|
610
660
|
}
|
|
611
|
-
function setupStaticUi(app, uiDir, authManager) {
|
|
661
|
+
function setupStaticUi(app, uiDir, authManager, previewManager, logger) {
|
|
612
662
|
if (!uiDir) {
|
|
613
663
|
app.log.warn("UI static directory not provided; API endpoints only");
|
|
614
664
|
return;
|
|
@@ -617,6 +667,13 @@ function setupStaticUi(app, uiDir, authManager) {
|
|
|
617
667
|
app.log.warn({ uiDir }, "UI static directory missing; API endpoints only");
|
|
618
668
|
return;
|
|
619
669
|
}
|
|
670
|
+
app.addHook("preHandler", (request, reply, done) => {
|
|
671
|
+
const session = authManager.getSessionFromRequest(request);
|
|
672
|
+
if (session && proxyPreviewFallbackFromReferer(request, reply, previewManager, logger)) {
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
done();
|
|
676
|
+
});
|
|
620
677
|
app.register(fastifyStatic, {
|
|
621
678
|
root: uiDir,
|
|
622
679
|
prefix: "/",
|
|
@@ -630,6 +687,9 @@ function setupStaticUi(app, uiDir, authManager) {
|
|
|
630
687
|
return;
|
|
631
688
|
}
|
|
632
689
|
const session = authManager.getSessionFromRequest(request);
|
|
690
|
+
if (session && proxyPreviewFallbackFromReferer(request, reply, previewManager, logger)) {
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
633
693
|
if (!session && wantsHtml(request)) {
|
|
634
694
|
reply.redirect("/login");
|
|
635
695
|
return;
|
|
@@ -642,7 +702,7 @@ function setupStaticUi(app, uiDir, authManager) {
|
|
|
642
702
|
}
|
|
643
703
|
});
|
|
644
704
|
}
|
|
645
|
-
function setupDevProxy(app, upstreamBase, authManager) {
|
|
705
|
+
function setupDevProxy(app, upstreamBase, authManager, previewManager, logger) {
|
|
646
706
|
app.log.info({ upstreamBase }, "Proxying UI requests to development server");
|
|
647
707
|
app.setNotFoundHandler((request, reply) => {
|
|
648
708
|
const url = request.raw.url ?? "";
|
|
@@ -651,6 +711,9 @@ function setupDevProxy(app, upstreamBase, authManager) {
|
|
|
651
711
|
return;
|
|
652
712
|
}
|
|
653
713
|
const session = authManager.getSessionFromRequest(request);
|
|
714
|
+
if (session && proxyPreviewFallbackFromReferer(request, reply, previewManager, logger)) {
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
654
717
|
if (!session && wantsHtml(request)) {
|
|
655
718
|
reply.redirect("/login");
|
|
656
719
|
return;
|
|
@@ -658,6 +721,45 @@ function setupDevProxy(app, upstreamBase, authManager) {
|
|
|
658
721
|
void proxyToDevServer(request, reply, upstreamBase);
|
|
659
722
|
});
|
|
660
723
|
}
|
|
724
|
+
function proxyPreviewFallbackFromReferer(request, reply, previewManager, logger) {
|
|
725
|
+
const rawUrl = request.raw.url ?? request.url ?? "";
|
|
726
|
+
const pathname = rawUrl.split("?")[0] ?? "";
|
|
727
|
+
if (!isPreviewFallbackPath(pathname)) {
|
|
728
|
+
return false;
|
|
729
|
+
}
|
|
730
|
+
const refererHeader = request.headers.referer ?? request.headers.referrer;
|
|
731
|
+
const referer = Array.isArray(refererHeader) ? refererHeader[0] : refererHeader;
|
|
732
|
+
if (!referer) {
|
|
733
|
+
return false;
|
|
734
|
+
}
|
|
735
|
+
const parsed = parsePreviewUpgradePath(referer);
|
|
736
|
+
if (!parsed) {
|
|
737
|
+
return false;
|
|
738
|
+
}
|
|
739
|
+
void proxyPreviewAssetRequest({
|
|
740
|
+
request,
|
|
741
|
+
reply,
|
|
742
|
+
previewManager,
|
|
743
|
+
logger,
|
|
744
|
+
token: parsed.token,
|
|
745
|
+
});
|
|
746
|
+
return true;
|
|
747
|
+
}
|
|
748
|
+
function isPreviewFallbackPath(pathname) {
|
|
749
|
+
if (!pathname || pathname === "/")
|
|
750
|
+
return false;
|
|
751
|
+
if (pathname.startsWith("/api/") || pathname === "/api")
|
|
752
|
+
return false;
|
|
753
|
+
if (pathname.startsWith("/workspaces/"))
|
|
754
|
+
return false;
|
|
755
|
+
if (pathname.startsWith("/sidecars/"))
|
|
756
|
+
return false;
|
|
757
|
+
if (pathname.startsWith("/previews/"))
|
|
758
|
+
return false;
|
|
759
|
+
if (pathname.startsWith("/auth/") || pathname === "/login")
|
|
760
|
+
return false;
|
|
761
|
+
return true;
|
|
762
|
+
}
|
|
661
763
|
async function proxyToDevServer(request, reply, upstreamBase) {
|
|
662
764
|
try {
|
|
663
765
|
const targetUrl = new URL(request.raw.url ?? "/", upstreamBase);
|
|
@@ -698,6 +800,69 @@ function buildProxyHeaders(headers) {
|
|
|
698
800
|
}
|
|
699
801
|
return result;
|
|
700
802
|
}
|
|
803
|
+
function buildFetchProxyHeaders(headers, targetOrigin) {
|
|
804
|
+
const sanitized = sanitizeSideCarProxyRequestHeaders(headers, targetOrigin);
|
|
805
|
+
const result = {};
|
|
806
|
+
for (const [key, value] of Object.entries(sanitized)) {
|
|
807
|
+
if (!value)
|
|
808
|
+
continue;
|
|
809
|
+
if (key.toLowerCase() === "cookie")
|
|
810
|
+
continue;
|
|
811
|
+
result[key] = Array.isArray(value) ? value.join(",") : value;
|
|
812
|
+
}
|
|
813
|
+
return result;
|
|
814
|
+
}
|
|
815
|
+
function headersToRecord(headers) {
|
|
816
|
+
const result = {};
|
|
817
|
+
headers.forEach((value, key) => {
|
|
818
|
+
result[key.toLowerCase()] = value;
|
|
819
|
+
});
|
|
820
|
+
return result;
|
|
821
|
+
}
|
|
822
|
+
function getHeaderValue(headers, key) {
|
|
823
|
+
const value = headers[key.toLowerCase()];
|
|
824
|
+
return Array.isArray(value) ? value[0] : value;
|
|
825
|
+
}
|
|
826
|
+
function shouldForwardRequestBody(method) {
|
|
827
|
+
const normalized = method.toUpperCase();
|
|
828
|
+
return normalized !== "GET" && normalized !== "HEAD";
|
|
829
|
+
}
|
|
830
|
+
function isHtmlContentType(contentType) {
|
|
831
|
+
const normalized = contentType.toLowerCase();
|
|
832
|
+
return normalized.includes("text/html") || normalized.includes("application/xhtml+xml");
|
|
833
|
+
}
|
|
834
|
+
function isCssContentType(contentType) {
|
|
835
|
+
return contentType.toLowerCase().includes("text/css");
|
|
836
|
+
}
|
|
837
|
+
function rewritePreviewBodyUrls(body, publicBase, kind) {
|
|
838
|
+
if (kind === "css") {
|
|
839
|
+
return rewriteCssPreviewUrls(body, publicBase);
|
|
840
|
+
}
|
|
841
|
+
return rewriteCssPreviewUrls(body
|
|
842
|
+
.replace(/\b(src|href|action|poster|data)=(["'])\/(?!\/)([^"']*)\2/gi, (_match, attr, quote, pathValue) => {
|
|
843
|
+
return `${attr}=${quote}${publicBase}/${pathValue}${quote}`;
|
|
844
|
+
})
|
|
845
|
+
.replace(/\bsrcset=(["'])([^"']*)\1/gi, (_match, quote, value) => {
|
|
846
|
+
return `srcset=${quote}${rewriteSrcsetPreviewUrls(value, publicBase)}${quote}`;
|
|
847
|
+
}), publicBase);
|
|
848
|
+
}
|
|
849
|
+
function rewriteCssPreviewUrls(body, publicBase) {
|
|
850
|
+
return body.replace(/url\((\s*)(["']?)\/(?!\/)([^"')]+)\2(\s*)\)/gi, (_match, before, quote, pathValue, after) => {
|
|
851
|
+
return `url(${before}${quote}${publicBase}/${pathValue}${quote}${after})`;
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
function rewriteSrcsetPreviewUrls(value, publicBase) {
|
|
855
|
+
return value
|
|
856
|
+
.split(",")
|
|
857
|
+
.map((entry) => {
|
|
858
|
+
const trimmed = entry.trimStart();
|
|
859
|
+
if (!trimmed.startsWith("/") || trimmed.startsWith("//"))
|
|
860
|
+
return entry;
|
|
861
|
+
const leading = entry.slice(0, entry.length - trimmed.length);
|
|
862
|
+
return `${leading}${publicBase}${trimmed}`;
|
|
863
|
+
})
|
|
864
|
+
.join(",");
|
|
865
|
+
}
|
|
701
866
|
async function proxySideCarRequest(args) {
|
|
702
867
|
const sidecarId = args.request.params.id ?? "";
|
|
703
868
|
const sidecar = await args.sidecarManager.get(sidecarId);
|
|
@@ -714,13 +879,113 @@ async function proxySideCarRequest(args) {
|
|
|
714
879
|
const targetOrigin = args.sidecarManager.buildTargetOrigin(sidecar);
|
|
715
880
|
const targetUrl = `${targetOrigin}${targetPath}`;
|
|
716
881
|
args.logger.debug({ sidecarId: sidecar.id, targetUrl, pathname, prefixMode: sidecar.prefixMode }, "Proxying request to SideCar");
|
|
717
|
-
await
|
|
718
|
-
|
|
882
|
+
await proxyTargetRequest({
|
|
883
|
+
reply: args.reply,
|
|
884
|
+
logger: args.logger,
|
|
885
|
+
targetUrl,
|
|
886
|
+
targetOrigin,
|
|
887
|
+
logContext: { sidecarId: sidecar.id },
|
|
888
|
+
errorMessage: "SideCar proxy failed",
|
|
719
889
|
rewriteHeaders: (headers) => rewriteSideCarResponseHeaders(headers, sidecarId, targetOrigin, sidecar.prefixMode),
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
async function proxyPreviewRequest(args) {
|
|
893
|
+
const token = args.request.params.token ?? "";
|
|
894
|
+
const preview = args.previewManager.get(token);
|
|
895
|
+
if (!preview) {
|
|
896
|
+
args.reply.code(404).send({ error: "Preview not found" });
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
const rawUrl = args.request.raw.url ?? args.request.url ?? "";
|
|
900
|
+
const queryIndex = rawUrl.indexOf("?");
|
|
901
|
+
const search = queryIndex >= 0 ? rawUrl.slice(queryIndex) : "";
|
|
902
|
+
const pathSuffix = args.pathSuffix ?? "";
|
|
903
|
+
const requestPath = pathSuffix ? `${args.previewManager.buildProxyBasePath(token)}/${pathSuffix.replace(/^\/+/, "")}` : args.previewManager.buildProxyBasePath(token);
|
|
904
|
+
const targetUrl = args.previewManager.buildTargetUrl(token, requestPath, search);
|
|
905
|
+
if (!targetUrl) {
|
|
906
|
+
args.reply.code(404).send({ error: "Preview not found" });
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
args.logger.debug({ previewToken: token, targetUrl: targetUrl.toString() }, "Proxying request to preview");
|
|
910
|
+
await proxyPreviewTargetRequest({
|
|
911
|
+
request: args.request,
|
|
912
|
+
reply: args.reply,
|
|
913
|
+
logger: args.logger,
|
|
914
|
+
targetUrl: targetUrl.toString(),
|
|
915
|
+
targetOrigin: targetUrl.origin,
|
|
916
|
+
publicBase: args.previewManager.buildProxyBasePath(token),
|
|
917
|
+
logContext: { previewToken: token },
|
|
918
|
+
errorMessage: "Preview proxy failed",
|
|
919
|
+
rewriteHeaders: (headers) => rewritePreviewResponseHeaders(headers, token, targetUrl.origin),
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
async function proxyPreviewAssetRequest(args) {
|
|
923
|
+
const rawUrl = args.request.raw.url ?? args.request.url ?? "";
|
|
924
|
+
const queryIndex = rawUrl.indexOf("?");
|
|
925
|
+
const search = queryIndex >= 0 ? rawUrl.slice(queryIndex) : "";
|
|
926
|
+
const pathname = rawUrl.split("?")[0] ?? "/";
|
|
927
|
+
const targetUrl = args.previewManager.buildTargetUrl(args.token, pathname, search);
|
|
928
|
+
if (!targetUrl) {
|
|
929
|
+
args.reply.code(404).send({ error: "Preview not found" });
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
args.logger.debug({ previewToken: args.token, targetUrl: targetUrl.toString() }, "Proxying preview fallback asset");
|
|
933
|
+
await proxyPreviewTargetRequest({
|
|
934
|
+
request: args.request,
|
|
935
|
+
reply: args.reply,
|
|
936
|
+
logger: args.logger,
|
|
937
|
+
targetUrl: targetUrl.toString(),
|
|
938
|
+
targetOrigin: targetUrl.origin,
|
|
939
|
+
publicBase: args.previewManager.buildProxyBasePath(args.token),
|
|
940
|
+
logContext: { previewToken: args.token, previewFallback: true },
|
|
941
|
+
errorMessage: "Preview proxy failed",
|
|
942
|
+
rewriteHeaders: (headers) => rewritePreviewResponseHeaders(headers, args.token, targetUrl.origin),
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
async function proxyPreviewTargetRequest(args) {
|
|
946
|
+
try {
|
|
947
|
+
const response = await fetch(args.targetUrl, {
|
|
948
|
+
method: args.request.method,
|
|
949
|
+
headers: buildFetchProxyHeaders(args.request.headers, args.targetOrigin),
|
|
950
|
+
body: shouldForwardRequestBody(args.request.method) ? args.request.raw : undefined,
|
|
951
|
+
duplex: shouldForwardRequestBody(args.request.method) ? "half" : undefined,
|
|
952
|
+
redirect: "manual",
|
|
953
|
+
});
|
|
954
|
+
const headers = args.rewriteHeaders(headersToRecord(response.headers));
|
|
955
|
+
const contentType = getHeaderValue(headers, "content-type") ?? response.headers.get("content-type") ?? "";
|
|
956
|
+
delete headers["content-length"];
|
|
957
|
+
delete headers["content-encoding"];
|
|
958
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
959
|
+
if (value !== undefined)
|
|
960
|
+
args.reply.header(key, value);
|
|
961
|
+
}
|
|
962
|
+
args.reply.code(response.status);
|
|
963
|
+
if (!response.body || args.request.method === "HEAD") {
|
|
964
|
+
args.reply.send();
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
if (isHtmlContentType(contentType) || isCssContentType(contentType)) {
|
|
968
|
+
const text = await response.text();
|
|
969
|
+
args.reply.send(rewritePreviewBodyUrls(text, args.publicBase, isCssContentType(contentType) ? "css" : "html"));
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
args.reply.send(Buffer.from(await response.arrayBuffer()));
|
|
973
|
+
}
|
|
974
|
+
catch (error) {
|
|
975
|
+
args.logger.error({ ...args.logContext, err: error, targetUrl: args.targetUrl }, args.errorMessage);
|
|
976
|
+
if (!args.reply.sent) {
|
|
977
|
+
args.reply.code(502).send({ error: args.errorMessage });
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
async function proxyTargetRequest(args) {
|
|
982
|
+
await args.reply.from(args.targetUrl, {
|
|
983
|
+
rewriteRequestHeaders: (_originalRequest, headers) => sanitizeSideCarProxyRequestHeaders(headers, args.targetOrigin),
|
|
984
|
+
rewriteHeaders: args.rewriteHeaders,
|
|
720
985
|
onError: (reply, { error }) => {
|
|
721
|
-
args.logger.error({
|
|
986
|
+
args.logger.error({ ...args.logContext, err: error, targetUrl: args.targetUrl }, args.errorMessage);
|
|
722
987
|
if (!reply.sent) {
|
|
723
|
-
reply.code(502).send({ error:
|
|
988
|
+
reply.code(502).send({ error: args.errorMessage });
|
|
724
989
|
}
|
|
725
990
|
},
|
|
726
991
|
});
|
|
@@ -748,6 +1013,29 @@ function parseSideCarUpgradePath(rawUrl) {
|
|
|
748
1013
|
return null;
|
|
749
1014
|
}
|
|
750
1015
|
}
|
|
1016
|
+
function parsePreviewUpgradePath(rawUrl) {
|
|
1017
|
+
let parsed;
|
|
1018
|
+
try {
|
|
1019
|
+
parsed = new URL(rawUrl, "http://localhost");
|
|
1020
|
+
}
|
|
1021
|
+
catch {
|
|
1022
|
+
return null;
|
|
1023
|
+
}
|
|
1024
|
+
const match = parsed.pathname.match(/^\/previews\/([^/]+)(?:\/.*)?$/);
|
|
1025
|
+
if (!match) {
|
|
1026
|
+
return null;
|
|
1027
|
+
}
|
|
1028
|
+
try {
|
|
1029
|
+
return {
|
|
1030
|
+
token: decodeURIComponent(match[1] ?? ""),
|
|
1031
|
+
pathname: parsed.pathname,
|
|
1032
|
+
search: parsed.search,
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
catch {
|
|
1036
|
+
return null;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
751
1039
|
async function proxySideCarWebSocketUpgrade(args) {
|
|
752
1040
|
const { request, socket, head, sidecarId, incomingPath, search, sidecarManager, authManager, logger } = args;
|
|
753
1041
|
if (!isWebSocketUpgradeRequest(request)) {
|
|
@@ -768,6 +1056,46 @@ async function proxySideCarWebSocketUpgrade(args) {
|
|
|
768
1056
|
const targetPath = sidecarManager.buildTargetPath(sidecarId, incomingPath, search);
|
|
769
1057
|
const targetUrl = new URL(`${targetOrigin}${targetPath}`);
|
|
770
1058
|
logger.debug({ sidecarId, targetUrl: targetUrl.toString(), prefixMode: sidecar.prefixMode }, "Proxying websocket to SideCar");
|
|
1059
|
+
proxyTargetWebSocketUpgrade({
|
|
1060
|
+
request,
|
|
1061
|
+
socket,
|
|
1062
|
+
head,
|
|
1063
|
+
targetUrl,
|
|
1064
|
+
logger,
|
|
1065
|
+
logContext: { sidecarId },
|
|
1066
|
+
proxyLabel: "SideCar",
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
async function proxyPreviewWebSocketUpgrade(args) {
|
|
1070
|
+
const { request, socket, head, token, incomingPath, search, previewManager, authManager, logger } = args;
|
|
1071
|
+
if (!isWebSocketUpgradeRequest(request)) {
|
|
1072
|
+
rejectUpgrade(socket, 400, "Bad Request");
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
const session = authManager.getSessionFromHeaders(request.headers);
|
|
1076
|
+
if (!session) {
|
|
1077
|
+
rejectUpgrade(socket, 401, "Unauthorized");
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
const targetUrl = previewManager.buildTargetUrl(token, incomingPath, search);
|
|
1081
|
+
if (!targetUrl) {
|
|
1082
|
+
rejectUpgrade(socket, 404, "Not Found");
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
logger.debug({ previewToken: token, targetUrl: targetUrl.toString() }, "Proxying websocket to preview");
|
|
1086
|
+
proxyTargetWebSocketUpgrade({
|
|
1087
|
+
request,
|
|
1088
|
+
socket,
|
|
1089
|
+
head,
|
|
1090
|
+
targetUrl,
|
|
1091
|
+
logger,
|
|
1092
|
+
logContext: { previewToken: token },
|
|
1093
|
+
proxyLabel: "preview",
|
|
1094
|
+
stripCookies: true,
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
function proxyTargetWebSocketUpgrade(args) {
|
|
1098
|
+
const { request, socket, head, targetUrl, logger, logContext, proxyLabel, stripCookies } = args;
|
|
771
1099
|
const { socket: upstream, readyEvent } = createSideCarUpstreamSocket(targetUrl);
|
|
772
1100
|
const closeBoth = () => {
|
|
773
1101
|
if (!socket.destroyed) {
|
|
@@ -778,21 +1106,21 @@ async function proxySideCarWebSocketUpgrade(args) {
|
|
|
778
1106
|
}
|
|
779
1107
|
};
|
|
780
1108
|
upstream.once("error", (error) => {
|
|
781
|
-
logger.error({
|
|
1109
|
+
logger.error({ ...logContext, err: error, targetUrl: targetUrl.toString() }, `Failed to proxy ${proxyLabel} websocket`);
|
|
782
1110
|
rejectUpgrade(socket, 502, "Bad Gateway");
|
|
783
1111
|
if (!upstream.destroyed) {
|
|
784
1112
|
upstream.destroy();
|
|
785
1113
|
}
|
|
786
1114
|
});
|
|
787
1115
|
socket.once("error", (error) => {
|
|
788
|
-
logger.debug({
|
|
1116
|
+
logger.debug({ ...logContext, err: error }, `${proxyLabel} websocket client socket errored`);
|
|
789
1117
|
if (!upstream.destroyed) {
|
|
790
1118
|
upstream.destroy();
|
|
791
1119
|
}
|
|
792
1120
|
});
|
|
793
1121
|
upstream.once(readyEvent, () => {
|
|
794
1122
|
try {
|
|
795
|
-
upstream.write(buildSideCarWebSocketRequest(request, targetUrl));
|
|
1123
|
+
upstream.write(buildSideCarWebSocketRequest(request, targetUrl, { stripCookies }));
|
|
796
1124
|
if (head.length > 0) {
|
|
797
1125
|
upstream.write(head);
|
|
798
1126
|
}
|
|
@@ -800,7 +1128,7 @@ async function proxySideCarWebSocketUpgrade(args) {
|
|
|
800
1128
|
socket.pipe(upstream);
|
|
801
1129
|
}
|
|
802
1130
|
catch (error) {
|
|
803
|
-
logger.error({
|
|
1131
|
+
logger.error({ ...logContext, err: error, targetUrl: targetUrl.toString() }, `Failed to forward ${proxyLabel} websocket upgrade`);
|
|
804
1132
|
closeBoth();
|
|
805
1133
|
}
|
|
806
1134
|
});
|
|
@@ -832,7 +1160,7 @@ function createSideCarUpstreamSocket(targetUrl) {
|
|
|
832
1160
|
readyEvent: "connect",
|
|
833
1161
|
};
|
|
834
1162
|
}
|
|
835
|
-
function buildSideCarWebSocketRequest(request, targetUrl) {
|
|
1163
|
+
function buildSideCarWebSocketRequest(request, targetUrl, options) {
|
|
836
1164
|
const pathWithQuery = `${targetUrl.pathname}${targetUrl.search}`;
|
|
837
1165
|
const requestLine = `${request.method ?? "GET"} ${pathWithQuery} HTTP/${request.httpVersion}\r\n`;
|
|
838
1166
|
const headerLines = [];
|
|
@@ -846,6 +1174,8 @@ function buildSideCarWebSocketRequest(request, targetUrl) {
|
|
|
846
1174
|
const lower = key.toLowerCase();
|
|
847
1175
|
if (blockedHeaders.has(lower))
|
|
848
1176
|
continue;
|
|
1177
|
+
if (options?.stripCookies && lower === "cookie")
|
|
1178
|
+
continue;
|
|
849
1179
|
if (lower === "origin") {
|
|
850
1180
|
headerLines.push(`Origin: ${targetUrl.origin}\r\n`);
|
|
851
1181
|
continue;
|
|
@@ -899,6 +1229,34 @@ function rewriteSideCarResponseHeaders(headers, sidecarId, targetOrigin, prefixM
|
|
|
899
1229
|
}
|
|
900
1230
|
return next;
|
|
901
1231
|
}
|
|
1232
|
+
function rewritePreviewResponseHeaders(headers, token, targetOrigin) {
|
|
1233
|
+
const next = { ...headers };
|
|
1234
|
+
delete next["x-frame-options"];
|
|
1235
|
+
delete next["content-security-policy"];
|
|
1236
|
+
delete next["content-security-policy-report-only"];
|
|
1237
|
+
delete next["set-cookie"];
|
|
1238
|
+
delete next["set-cookie2"];
|
|
1239
|
+
const locationHeader = next.location;
|
|
1240
|
+
const location = Array.isArray(locationHeader) ? locationHeader[0] : locationHeader;
|
|
1241
|
+
if (!location) {
|
|
1242
|
+
return next;
|
|
1243
|
+
}
|
|
1244
|
+
const publicBase = `/previews/${encodeURIComponent(token)}`;
|
|
1245
|
+
if (location.startsWith("/")) {
|
|
1246
|
+
next.location = `${publicBase}${location}`;
|
|
1247
|
+
return next;
|
|
1248
|
+
}
|
|
1249
|
+
try {
|
|
1250
|
+
const parsed = new URL(location);
|
|
1251
|
+
if (parsed.origin === targetOrigin) {
|
|
1252
|
+
next.location = `${publicBase}${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
catch {
|
|
1256
|
+
// Relative redirects should continue to resolve against the current preview path.
|
|
1257
|
+
}
|
|
1258
|
+
return next;
|
|
1259
|
+
}
|
|
902
1260
|
function sanitizeSideCarProxyRequestHeaders(headers, targetOrigin) {
|
|
903
1261
|
const blockedHeaders = getBlockedSideCarRequestHeaders();
|
|
904
1262
|
const next = {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const PreviewCreateSchema = z.object({
|
|
3
|
+
sessionId: z.string().trim().min(1),
|
|
4
|
+
url: z.string().trim().min(1),
|
|
5
|
+
});
|
|
6
|
+
export function registerPreviewRoutes(app, deps) {
|
|
7
|
+
app.post("/api/previews", async (request, reply) => {
|
|
8
|
+
try {
|
|
9
|
+
const body = PreviewCreateSchema.parse(request.body ?? {});
|
|
10
|
+
return deps.previewManager.create(body.sessionId, body.url);
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
reply.code(400);
|
|
14
|
+
return { error: error instanceof Error ? error.message : "Failed to create preview" };
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
app.delete("/api/previews/:token", async (request, reply) => {
|
|
18
|
+
const removed = deps.previewManager.delete(request.params.token);
|
|
19
|
+
if (!removed) {
|
|
20
|
+
reply.code(404);
|
|
21
|
+
return { error: "Preview not found" };
|
|
22
|
+
}
|
|
23
|
+
reply.code(204);
|
|
24
|
+
});
|
|
25
|
+
}
|