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
7 changes: 7 additions & 0 deletions .changeset/lazy-compilation-cors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@modern-js/server': patch
---

fix(server): add CORS headers to the lazy-compilation trigger endpoint so proxied domains (Whistle / Eden Proxy) can reach it cross-origin

fix(server): 为 lazy-compilation trigger 端点补充 CORS 响应头,使代理域名(Whistle / Eden Proxy)场景可跨源访问
19 changes: 18 additions & 1 deletion packages/server/server/src/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ import type { RequestHandler } from '@modern-js/types';
import { API_DIR, SHARED_DIR, logger } from '@modern-js/utils';
import type { WatchEvent } from './dev-tools/watcher';
import {
createLazyCompilationCorsMiddleware,
getDevOptions,
getLazyCompilationPrefixes,
getMockMiddleware,
initFileReader,
onRepack,
Expand All @@ -39,6 +41,7 @@ export type DevPluginOptions = ModernDevServerOptions<ServerBaseOptions> & {
* into the current server's middleware list:
* - user `dev.setupMiddlewares` (before / after)
* - the mock middleware
* - the lazy-compilation CORS middleware (only when lazy compilation is on)
* - the rsbuild/builder dev middleware (a stable reference, just re-registered)
* - the file-reader middleware
*/
Expand Down Expand Up @@ -90,11 +93,25 @@ export const devRuntimeMiddlewarePlugin = (
handler: mockMiddleware,
});

builderMiddlewares &&
if (builderMiddlewares) {
// Must run before `rsbuild-dev`: rspack's lazy-compilation middleware
// lives inside the builder middlewares and writes to the raw node
// response, so the CORS headers have to be in place beforehand.
const lazyCompilationPrefixes = getLazyCompilationPrefixes(compiler);
if (lazyCompilationPrefixes.length > 0) {
middlewares.push({
name: 'lazy-compilation-cors',
handler: createLazyCompilationCorsMiddleware(
lazyCompilationPrefixes,
),
});
}

middlewares.push({
name: 'rsbuild-dev',
handler: connectMid2HonoMid(builderMiddlewares as any),
});
}

after.forEach((middleware, index) => {
middlewares.push({
Expand Down
1 change: 1 addition & 0 deletions packages/server/server/src/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import Watcher, {
export * from './repack';
export * from './devOptions';
export * from './fileReader';
export * from './lazyCompilationCors';
export * from './mock';

export function startWatcher({
Expand Down
100 changes: 100 additions & 0 deletions packages/server/server/src/helpers/lazyCompilationCors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type { Rspack } from '@modern-js/builder';
import type { Middleware } from '@modern-js/server-core';

/** Keep in sync with rspack's LAZY_COMPILATION_PREFIX. */
const DEFAULT_LAZY_COMPILATION_PREFIX = '/_rspack/lazy/trigger';

/**
* Collect the lazy-compilation endpoint prefixes actually configured on the
* (multi-)compiler, so the CORS middleware only ever touches those paths.
*/
export const getLazyCompilationPrefixes = (
compiler: Rspack.Compiler | Rspack.MultiCompiler | null,
): string[] => {
if (!compiler) {
return [];
}
const compilers = 'compilers' in compiler ? compiler.compilers : [compiler];
const prefixes = new Set<string>();
for (const { options } of compilers) {
const { lazyCompilation } = options;
if (!lazyCompilation) {
continue;
}
prefixes.add(
(typeof lazyCompilation === 'object' && lazyCompilation.prefix) ||
DEFAULT_LAZY_COMPILATION_PREFIX,
);
}
return [...prefixes];
};

/**
* Rspack's lazy-compilation client triggers module compilation with a
* `text/plain` POST to `lazyCompilation.serverUrl` + prefix. When the page is
* served from a proxied domain (Whistle / Eden Proxy mapping an online domain
* to the local dev server) while `serverUrl` points at `127.0.0.1` (derived
* from `dev.client`), that POST is cross-origin, and rspack's middleware
* replies without any CORS headers: the request still reaches the compiler
* (simple request) and invalidates the build, but the browser blocks the
* response, so dynamic imports fail and the page reload-loops.
*
* Reflecting the Origin here keeps the direct-connect setup working — the same
* reason the HMR WebSocket connects straight to 127.0.0.1, except WebSockets
* are exempt from CORS while XHR is not. Only the lazy-compilation prefixes
* are opened up: the endpoint merely triggers compilation and never returns
* source, and the global `server.cors` config stays untouched.
*
* This middleware is a stopgap for rspack's lazyCompilationMiddleware lacking
* CORS handling. If a bundled rspack ships native CORS for this endpoint,
* verify its semantics cover the proxied-domain case and remove (or
* capability-gate) this layer in the same dependency bump — answering the
* preflight here would otherwise mask the native policy.
*/
export const createLazyCompilationCorsMiddleware = (
prefixes: string[],
): Middleware => {
return async (ctx, next) => {
const { path } = ctx.req;
if (!prefixes.some(prefix => path.startsWith(prefix))) {
return next();
}

const origin = ctx.req.header('origin');
if (!origin) {
return next();
}

if (
ctx.req.method === 'OPTIONS' &&
ctx.req.header('access-control-request-method')
) {
// CORS preflight. The Private-Network-Access response header is kept
// for compatibility with browsers that still send the request header —
// Chrome's PNA preflight rollout is paused (superseded by the
// user-permission Local Network Access model in Chrome 142), so this
// does NOT claim to satisfy any future local-network gating.
const headers: Record<string, string> = {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers':
ctx.req.header('access-control-request-headers') || 'content-type',
Vary: 'Origin',
};
if (ctx.req.header('access-control-request-private-network') === 'true') {
headers['Access-Control-Allow-Private-Network'] = 'true';
}
return ctx.body(null, 204, headers);
}

// The rspack middleware behind `rsbuild-dev` writes straight to the raw
// node response (`res.writeHead(200)`), bypassing hono's header handling,
// so the reflected headers must be set on the raw response too.
const nodeRes = ctx.env?.node?.res;
if (nodeRes && !nodeRes.headersSent) {
nodeRes.setHeader('Access-Control-Allow-Origin', origin);
nodeRes.setHeader('Vary', 'Origin');
}
return next();
};
};
Loading
Loading