onProxy needed to support same-origin for existing and stand-alone API servers
Is your feature request related to a problem?
It is impractical for Qwik/Qwik-city to require all supporting API servers to be integrated into the same code base. In my case, I have an api server that has evolved over several years and it would be highly impractical to migrate all this this code, plus test cases into a Qwik project.
Both the Qwik front-end and API server must run on the same protocol+domain+port to achieve same-origin.
Over the last week, I (we in the Qwik community on Discord) have discussed and tried to find solutions to workaround this problem. There are currently bugs that prevent a vite server to work (see #2004) due to priority of plugins. The use of middleware fell flat, and the use of onRequest was painful and ultimately could not support both html body and json responses (as needed by the Sandbox and graphql endpoint).
Ultimately, it was not possible to support an Apollo GraphQL server on /api that had both the Apollo Sandbox with introspection front-end AND the graphQL query processor endpoints.
Please see several long Discord conversations about proxy and reverse-proxy. One such sub-chat, reverse proxy middleware.
Describe the solution you'd like
onProxy that passes through everything seamlessly, just like http-proxy-middleware.
This allows for any route to be reverse-proxied to an external/existing backend while preserving same-origin.
Describe alternatives you've considered
EDIT: Alternatives deleted on Nov 12, 2022. SEE THREE WORKAROUNDS, BELOW
Additional context
http-proxy-middleware was the most successful alternative. SEE "METHOD A", BELOW
I set up qwik-city with express adapter, and set up poxy using it
import { createProxyMiddleware } from 'http-proxy-middleware';
// Create the Qwik City middleware
const { router, notFound } = createQwikCity({
render,
qwikCityPlan,
});
// Create the express server
const app = express();
// Proxy request to CMS: admin + graphql
app.use('/cms', createProxyMiddleware({ target: process.env.PAYLOAD_CMS_ENDPOINT, changeOrigin: true }));
// Use Qwik City's page and endpoint router
app.use(router);
But for vite devserver I was not able to set up proxying using vite proxy settings as described in this issue
Thanks @thekip. Were you able to get both the Apollo Sandbox with introspection, AND graphQL query endpoint to work for queries simultaneously? The acid test is the electron app GraphiQL.
I have been unsuccessful in getting both GET/http and POST/json requirements to work. At this point I'd settle for just the pure graphQL functionality and forgo the sandbox as a temporary workaround.
Also, if you got your code working, what is the file+path of your code (as I've not tried overriding the core server yet). Did anything else in the project have to change?
It's working for production build only, where express adapter used and vite dev server is not involved.
So full listing is
//src/entry.express
import { createQwikCity } from '@builder.io/qwik-city/middleware/node';
import express from 'express';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';
import render from './entry.ssr';
import { createProxyMiddleware } from 'http-proxy-middleware';
import qwikCityPlan from '@qwik-city-plan';
// Directories where the static assets are located
const distDir = join(fileURLToPath(import.meta.url), '..', '..', 'dist');
const buildDir = join(distDir, 'build');
// Create the Qwik City middleware
const { router, notFound } = createQwikCity({
render,
qwikCityPlan,
base: '/welcome/build/'
});
// Create the express server
const app = express();
// Use Qwik City's page and endpoint router
app.use(router);
// Static asset handlers
app.use(`/welcome/build`, express.static(buildDir, { immutable: true, maxAge: '1y', index: false }));
app.use(`/welcome`, express.static(distDir, { index: false }));
app.use('/welcome/cms', createProxyMiddleware({ target: process.env.PAYLOAD_CMS_ENDPOINT, changeOrigin: true }));
// Use Qwik City's 404 handler
app.use(notFound);
// Start the express server
app.listen(80);
The quick application is served from /welcome/ path, and api + admin panel served from welcome/cms/*
This is just usual express app, and createProxyMiddleware works as usual.
During development we use direct access to the CMS without proxying, something like
export const createGraphQLClient = () => {
return createClient({
url: (process.env.PAYLOAD_CMS_ENDPOINT || 'http://localhost:3000') + '/welcome/cms/api/graphql',
exchanges: [
fetchExchange,
],
});
};
While proxying everything works as expected, gql intropection has nothing different from usual queries so why they should not work where regular queries works?
Thanks @thekip. I've used this approach in my prior app -- what I am porting now to Qwik. I am hopeful we find a solution that supports all the development context functionality, and production. I've used both express and nginx but neither work well for dev.
Three Workarounds to make an API proxy / reverse-proxy work:
METHOD A — viteServerMiddlewares with http-proxy-middleware WORKS BEST! Supports queries, introspection, and Apollo Sandbox
vite.config.ts
import { defineConfig, Plugin } from "vite";
import { qwikVite } from "@builder.io/qwik/optimizer";
import { qwikCity } from "@builder.io/qwik-city/vite";
import tsconfigPaths from "vite-tsconfig-paths";
import { qwikReact } from "@builder.io/qwik-react";
import { createProxyMiddleware } from 'http-proxy-middleware';
function viteServerMiddlewares(): Plugin {
const configureServer: Plugin['configureServer'] = async (server) => {
server.middlewares.use(
'/api',
createProxyMiddleware({
target: 'http://localhost:4000/api',
pathRewrite: { '^/api': '/' },
changeOrigin: true,
ws: true,
xfwd: true,
} as any) as any
);
};
return {
name: 'dev-server-middleware',
enforce: 'pre',
apply: 'serve',
configureServer,
configurePreviewServer: configureServer as any,
};
}
export default defineConfig(() => {
return {
build: { sourcemap: true },
plugins: [
viteServerMiddlewares(),
qwikCity(),
qwikVite(),
tsconfigPaths(),
qwikReact()
],
preview: {
headers: {
"Cache-Control": "public, max-age=600",
},
},
};
});
METHOD B — vite server proxy Supports queries and introspection, but NOT Apollo Sandbox
vite.config.ts
import { defineConfig, Plugin } from "vite";
import { qwikVite } from "@builder.io/qwik/optimizer";
import { qwikCity } from "@builder.io/qwik-city/vite";
import tsconfigPaths from "vite-tsconfig-paths";
import { qwikReact } from "@builder.io/qwik-react";
export default defineConfig(() => {
return {
server: {
proxy: {
'/api': {
target: 'http://localhost:4000/api',
changeOrigin: true,
secure: false,
rewrite: (path) => path.replace(/^\/api/, ''), // only needed if changing route
},
}
},
build: { sourcemap: true },
plugins: [
qwikCity(),
qwikVite(),
tsconfigPaths(),
qwikReact()
],
preview: {
headers: {
"Cache-Control": "public, max-age=600",
},
},
};
});
METHOD C — Qwik-city Route Supports queries and introspection, but NOT Apollo Sandbox
/routes/backend/[..route]/index.ts
import { RequestHandler } from "@builder.io/qwik-city";
import axios, { AxiosRequestConfig, AxiosResponse } from "axios";
const backendBaseUrl = "http://localhost:5173";
const proxyUrl = "http://localhost:4000/";
export const onRequest: RequestHandler<any> = async (event) => {
try {
const { request, url, params } = event;
const method = request.method;
const body = await request.text();
const headers: { [key: string]: string } = {}
request.headers.forEach((value, key) => {
if (key === "host") headers[key] = backendBaseUrl
else headers[key] = value;
});
let queryParamString = ``;
url.searchParams.forEach((value, key) => {
queryParamString += `${key}=${value}`
});
const urlParams = queryParamString ? '?' + queryParamString : ''
const pUrl = proxyUrl + (params.route || "") + (urlParams || ""); // Convert undefined to empty strings
const config: AxiosRequestConfig = {
method,
url: pUrl,
headers: {
"Content-Type": "application/json"
},
data: body,
};
const queryResult: AxiosResponse<any> = await axios(config);
console.log("queryResult.data =", queryResult.data);
return queryResult.data;
} catch (err: any) {
console.log(err);
return err.message;
}
}
There is a bug with the Vite Server Proxy Middleware (METHOD A) using http-proxy-middleware that causes an infinite reload loop. This does not occur with the other proxy methods, described above.
TO REPRODUCE:
- In the browser, trigger a
fetchoraxioscall to the local vite server proxy - Reload the page, or update the source to trigger an HMR refresh
The page goes into an infinite reload loop and causes the server to generate the following error infinitely with every page reload:
[HPM] Upgrading to WebSocket
[HPM] WebSocket error: Error: write after end
at new NodeError (node:internal/errors:393:5)
at _write (node:internal/streams/writable:322:11)
at Writable.write (node:internal/streams/writable:337:10)
at Sender$1.sendFrame (file:///~~~/node_modules/.pnpm/[email protected]_@[email protected]/node_modules/vite/dist/node/chunks/dep-5605cfa4.js:55386:20)
at Sender$1.close (file:///~~~/node_modules/.pnpm/[email protected]_@[email protected]/node_modules/vite/dist/node/chunks/dep-5605cfa4.js:55114:12)
at WebSocket$1.close (file:///~~~/node_modules/.pnpm/[email protected]_@[email protected]/node_modules/vite/dist/node/chunks/dep-5605cfa4.js:56160:18)
at Receiver$1.receiverOnConclude (file:///~~~/node_modules/.pnpm/[email protected]_@[email protected]/node_modules/vite/dist/node/chunks/dep-5605cfa4.js:56983:18)
at Receiver$1.emit (node:events:513:28)
at Receiver$1.controlMessage (file:///~~~/node_modules/.pnpm/[email protected]_@[email protected]/node_modules/vite/dist/node/chunks/dep-5605cfa4.js:54885:14)
at Receiver$1.getData (file:///~~~/node_modules/.pnpm/[email protected]_@[email protected]/node_modules/vite/dist/node/chunks/dep-5605cfa4.js:54732:42)
at Receiver$1.startLoop (file:///~~~/node_modules/.pnpm/[email protected]_@[email protected]/node_modules/vite/dist/node/chunks/dep-5605cfa4.js:54451:22)
at Receiver$1._write (file:///~~~/node_modules/.pnpm/[email protected]_@[email protected]/node_modules/vite/dist/node/chunks/dep-5605cfa4.js:54386:10)
at writeOrBuffer (node:internal/streams/writable:392:12)
at _write (node:internal/streams/writable:333:10)
at Writable.write (node:internal/streams/writable:337:10)
at Socket.socketOnData (file:///~~~/node_modules/.pnpm/[email protected]_@[email protected]/node_modules/vite/dist/node/chunks/dep-5605cfa4.js:57132:37)
at Socket.emit (node:events:513:28)
at addChunk (node:internal/streams/readable:324:12)
at readableAddChunk (node:internal/streams/readable:297:9)
at Readable.push (node:internal/streams/readable:234:10)
at TCP.onStreamRead (node:internal/stream_base_commons:190:23) {
code: 'ERR_STREAM_WRITE_AFTER_END'
}
Also see #2004
With onProxy route handler pushed to v1.0 backlog, the only solution seems to be PR #2542 (which fixes the Qwik-City vite server proxy bug).
Closing with merge of #2542 which solves this specific issue. TODO: Another issue will need to be created to capture dev mode failure when an overlapping catch-all route exists that hijacks the vite server proxy (e.g. /api).
Closing with merge of #2542 which solves this specific issue. TODO: Another issue will need to be created to capture
devmode failure when an overlapping catch-all route exists that hijacks the vite server proxy (e.g./api).
Your solution helped me a lot. But I have question, how are you handling requests from server like routeAction$, routeLoader$ etc.. ?
This doesn't send any headers related to visitor:
export const useSignin = routeAction$(async (data, event) => {
const URL = event.query.get("callbackUrl") || "/app/index/";
const response = await fetch(
"http://localhost:5173/api/account/signin?role=user",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
},
);
const json = await response.json();
if (response.status === 201) {
event.redirect(302, URL);
}
return json;
});
I worked around and made it to work, but I believe there is better way around? If you can tell me how you did it I will really appreciate it.
export const fetchProxy = server$(async function (
url: string,
req: Record<string, any>,
ev?: RequestEventAction | RequestEvent | RequestEventBase,
) {
const event = ev || this;
const abandon = ["content-length"];
event.request.headers.forEach((value, key) => {
if (!abandon.includes(key))
if (key in req.headers === false) req.headers[key] = value;
});
const response = await fetch(url, req);
response.headers.getSetCookie().forEach((cookie: string) => {
const parsed = parseCookieString(cookie);
event.cookie.set(parsed.name, parsed.value, parsed.options);
});
return response;
});
function parseCookieString(cookieString: string): {
name: string;
value: string;
options: { [key: string]: string | boolean };
} {
const parts = cookieString.split(";").map((part) => part.trim());
const [nameValue, ...optionsParts] = parts;
const [name, value] = nameValue.split("=").map((part) => part.trim());
const options = optionsParts.reduce<{ [key: string]: string | boolean }>(
(acc, part) => {
const [k, v] = part.split("=");
const option = (key: string, val: string) => {
switch (key) {
case "Max-Age":
return { key: "maxAge", value: val ? val.trim() : true };
case "Expires":
return { key: "expires", value: val ? val.trim() : true };
case "SameSite":
return { key: "sameSite", value: val ? val.trim() : true };
case "Secure":
return { key: "secure", value: val ? val.trim() : true };
case "HttpOnly":
return { key: "httpOnly", value: val ? val.trim() : true };
case "Path":
return { key: "path", value: val ? val.trim() : true };
case "Domain":
return { key: "domain", value: val ? val.trim() : true };
default:
return { key: null, value: null };
}
};
const { key, value } = option(k, v);
if (key) acc[key] = value;
return acc;
},
{},
);
return { name, value, options };
}
export const useSigninProxy = routeAction$(async (data, event) => {
const URL = event.query.get("callbackUrl") || "/app/index/";
const response = await fetchProxy(
"http://localhost:5173/api/account/signin?role=user",
{
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(data),
},
event,
);
const json = await response.json();
if (response.status === 201) {
event.redirect(302, URL);
}
return json;
});