Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 45 additions & 2 deletions packages/plugin-vite/src/plugins/dev_server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { DevEnvironment, Plugin } from "vite";
import type { DevEnvironment, Plugin, ResolvedServerOptions } from "vite";
import * as path from "@std/path";
import { contentType as getStdContentType } from "@std/media-types/content-type";
import { ASSET_CACHE_BUST_KEY } from "fresh/internal";
Expand All @@ -10,6 +10,39 @@ function getContentType(ext: string): string {
return getStdContentType(ext) ?? "application/octet-stream";
}

/**
* Handling the user config of proxy
* https://vite.dev/config/server-options#server-proxy
*
* Note: per-entry `bypass(req, res, options)` callbacks are not supported here.
* Requests matched by a proxy key are always short-circuited to Vite's proxy
* handler; a `bypass` that returns `false` would still skip Fresh but then
* fall through the proxy without being proxied, resulting in a 404.
*/
function createProxyUrlMatcher(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One behavioral gap worth flagging: Vite's proxy options support a bypass(req, res, options) callback per entry that can return false to opt out of proxying for a given request. With this matcher, such requests would still be short-circuited away from Fresh and then fall through Vite's proxy without being proxied, ending up as 404s. Probably uncommon in practice — fine to leave for a follow-up, but a code comment noting the limitation would help future readers.

proxy: ResolvedServerOptions["proxy"],
): ((url: string) => boolean) | undefined {
if (proxy === undefined) return undefined;

const matchers = Object.keys(proxy).map((context) => {
// with RegExp
if (context[0] === "^") {
const regex = new RegExp(context);
return (url: string) => regex.test(url);
}
// string shorthand
return (url: string) => url.startsWith(context);
});

return (url: string) => {
for (const matches of matchers) {
if (matches(url)) return true;
}

return false;
};
}

export function devServer(freshConfig: ResolvedFreshViteConfig): Plugin[] {
let publicDir = "";
return [
Expand All @@ -27,15 +60,25 @@ export function devServer(freshConfig: ResolvedFreshViteConfig): Plugin[] {
const IGNORE_URLS = new RegExp(
`^(${base})?/(@(vite|fs|id)|\\.vite)/`,
);
// Precompute the proxy URL matcher; proxy config is fixed at server start.
const matchesProxyUrl = createProxyUrlMatcher(
server.config.server.proxy,
);

server.middlewares.use(async (nodeReq, nodeRes, next) => {
const serverCfg = server.config.server;
const rawUrl = nodeReq.url ?? "/";

// bypass the request when the proxy specified
if (matchesProxyUrl?.(rawUrl)) {
return next();
}

const protocol = serverCfg.https ? "https" : "http";
const host = serverCfg.host ? serverCfg.host : "localhost";
const port = serverCfg.port;
const url = new URL(
`${protocol}://${host}:${port}${nodeReq.url ?? "/"}`,
`${protocol}://${host}:${port}${rawUrl}`,
);

// Don't cache in dev
Expand Down
86 changes: 86 additions & 0 deletions packages/plugin-vite/tests/dev_server_test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as path from "@std/path";
import { expect } from "@std/expect";
import { withTmpDir, writeFiles } from "../../fresh/src/test_utils.ts";
import {
waitFor,
waitForText,
Expand Down Expand Up @@ -465,6 +466,91 @@ integrationTest(
},
);

// https://github.com/denoland/fresh/issues/3814
integrationTest("vite dev - server.proxy bypasses Fresh routes", async () => {
const api = new URLPattern({ pathname: "/api/*" });
const api2 = new URLPattern({ pathname: "/api2/*" });
const api3 = new URLPattern({ pathname: "/api3/*" });
await using proxy = Deno.serve({
hostname: "127.0.0.1",
port: 0,
}, (req) => {
const url = new URL(req.url);
if (api.test({ pathname: url.pathname })) {
return new Response("api");
}
if (api2.test({ pathname: url.pathname })) {
return new Response("api2");
}
if (api3.test({ pathname: url.pathname })) {
return new Response("api3");
}
throw new Error("unreachable");
});

await using tmp = await withTmpDir({
dir: path.join(import.meta.dirname!, ".."),
prefix: "tmp_vite_",
});

await writeFiles(tmp.dir, {
"main.ts": `import { App } from "@fresh/core";
export const app = new App()
.get("/", () => new Response("ok"));
`,
"vite.config.ts": `import { defineConfig } from "vite";
import { fresh } from "@fresh/plugin-vite";

export default defineConfig({
plugins: [fresh()],
server: {
proxy: {
"/api": Deno.env.get("FRESH_TEST_PROXY_TARGET")!,
"/api2": {
target: Deno.env.get("FRESH_TEST_PROXY_TARGET")!,
rewrite: (path) => path.replace(/^\\/api2\\/ping/, "/api2/pong"),
},
'^/api3/.*': {
target: Deno.env.get("FRESH_TEST_PROXY_TARGET")!,
changeOrigin: true,
},
},
},
});
`,
});

await launchDevServer(
tmp.dir,
async (address) => {
{
const res = await fetch(`${address}/api/ping?x=1`);
expect(res.status).toEqual(200);
expect(await res.text()).toEqual("api");
}
{
const res = await fetch(`${address}/api2/pong?y=2`);
expect(res.status).toEqual(200);
expect(await res.text()).toEqual("api2");
}
{
const res = await fetch(`${address}/api3/pong?z=3`);
expect(res.status).toEqual(200);
expect(await res.text()).toEqual("api3");
}
Comment on lines +536 to +540
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding a negative-case assertion so we prove the bypass is selective — e.g. that / still hits the Fresh route. Without it, a regression that bypasses every request would still pass these checks.

Suggested change
{
const res = await fetch(`${address}/api3/pong?z=3`);
expect(res.status).toEqual(200);
expect(await res.text()).toEqual("api3");
}
{
const res = await fetch(`${address}/api3/pong?z=3`);
expect(res.status).toEqual(200);
expect(await res.text()).toEqual("api3");
}
{
const res = await fetch(`${address}/`);
expect(res.status).toEqual(200);
expect(await res.text()).toEqual("ok");
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly, thanks!

{
// Ensure the bypass is selective — non-proxied routes still hit Fresh.
const res = await fetch(`${address}/`);
expect(res.status).toEqual(200);
expect(await res.text()).toEqual("ok");
}
},
{
FRESH_TEST_PROXY_TARGET: `http://127.0.0.1:${proxy.addr.port}`,
},
);
});

integrationTest("vite dev - source mapped stack traces", async () => {
const res = await fetch(`${demoServer.address()}/tests/throw`);
const text = await res.text();
Expand Down
Loading