qwik icon indicating copy to clipboard operation
qwik copied to clipboard

onProxy needed to support same-origin for existing and stand-alone API servers

Open n8sabes opened this issue 3 years ago • 7 comments

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

n8sabes avatar Nov 10 '22 06:11 n8sabes

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

timofei-iatsenko avatar Nov 10 '22 10:11 timofei-iatsenko

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?

n8sabes avatar Nov 10 '22 14:11 n8sabes

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?

timofei-iatsenko avatar Nov 11 '22 12:11 timofei-iatsenko

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.

n8sabes avatar Nov 11 '22 18:11 n8sabes

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;
    }
}

n8sabes avatar Nov 13 '22 07:11 n8sabes

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:

  1. In the browser, trigger a fetch or axios call to the local vite server proxy
  2. 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

n8sabes avatar Dec 14 '22 04:12 n8sabes

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).

n8sabes avatar Jan 02 '23 04:01 n8sabes

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).

n8sabes avatar Apr 25 '23 19:04 n8sabes

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).

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;
});

notcod avatar Feb 15 '24 05:02 notcod