Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: direct ws connection fallback #4474

Merged
Merged
Show file tree
Hide file tree
Changes from 3 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
13 changes: 10 additions & 3 deletions packages/core/src/client/hmr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { NormalizedClientConfig } from '../types';

const compilationId = RSBUILD_COMPILATION_NAME;
const config: NormalizedClientConfig = RSBUILD_CLIENT_CONFIG;
const resolvedConfig: NormalizedClientConfig = RSBUILD_RESOLVED_CLIENT_CONFIG;

function formatURL({
port,
Expand Down Expand Up @@ -211,13 +212,19 @@ function onClose() {
removeListeners();
connection = null;
reconnectCount++;
setTimeout(connect, 1000 * 1.5 ** reconnectCount);
setTimeout(() => connect(true), 1000 * 1.5 ** reconnectCount);
Copy link
Member

@chenjiahan chenjiahan Feb 3, 2025

Choose a reason for hiding this comment

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

I am worried that changing the default fallback logic here will cause some cases to fail to connect.

In Vite, each connect will use the host of the current page first, and then use the direct host if it fails. (https://github.com/vitejs/vite/blob/main/packages/vite/src/client/client.ts#L53-L71)

However, this PR forces all re-connects to use only the direct host, but it is obvious that it cannot be connected in some scenarios, such as on Cloud IDE.

Copy link
Contributor Author

@wxiaoyun wxiaoyun Feb 3, 2025

Choose a reason for hiding this comment

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

I have changed to onError instead of onClose so that it wont break existing reconnect logic. Would this be better?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

image

Copy link
Member

Choose a reason for hiding this comment

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

Nice, using onError looks good to me 👍

}

// Establishing a WebSocket connection with the server.
function connect() {
function connect(fallback: boolean = false) {
let cfg = config;
if (fallback) {
cfg = resolvedConfig;
console.info('[HMR] Using direct websocket fallback');
}

const { location } = self;
const { host, port, path, protocol } = config;
const { host, port, path, protocol } = cfg;
const socketUrl = formatURL({
protocol: protocol || (location.protocol === 'https:' ? 'wss' : 'ws'),
hostname: host || location.hostname,
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ declare const WEBPACK_HASH: string;

declare const RSBUILD_CLIENT_CONFIG: ClientConfig;

declare const RSBUILD_RESOLVED_CLIENT_CONFIG: ClientConfig;

declare const RSBUILD_DEV_LIVE_RELOAD: boolean;

declare const RSBUILD_COMPILATION_NAME: string;
6 changes: 4 additions & 2 deletions packages/core/src/provider/createCompiler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { rspack } from '@rspack/core';
import type { StatsCompilation } from '@rspack/core';
import { rspack } from '@rspack/core';
import {
color,
formatStats,
Expand All @@ -10,7 +10,7 @@ import {
} from '../helpers';
import { registerDevHook } from '../hooks';
import { logger } from '../logger';
import type { DevConfig, Rspack } from '../types';
import type { DevConfig, Rspack, ServerConfig } from '../types';
import { type InitConfigsOptions, initConfigs } from './initConfigs';

export async function createCompiler(options: InitConfigsOptions): Promise<{
Expand Down Expand Up @@ -162,4 +162,6 @@ export type DevMiddlewareOptions = {

/** whether use Server Side Render */
serverSideRender?: boolean;

serverConfig: ServerConfig;
};
18 changes: 10 additions & 8 deletions packages/core/src/server/compilerDevMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,10 @@ export class CompilerDevMiddleware {
public async init(): Promise<void> {
// start compiling
const devMiddleware = await getDevMiddleware(this.compiler);
this.middleware = this.setupDevMiddleware(devMiddleware, this.publicPaths);
this.middleware = await this.setupDevMiddleware(
devMiddleware,
this.publicPaths,
);
await this.socketServer.prepare();
}

Expand Down Expand Up @@ -149,14 +152,12 @@ export class CompilerDevMiddleware {
});
}

private setupDevMiddleware(
private async setupDevMiddleware(
devMiddleware: CustomDevMiddleware,
publicPaths: string[],
): DevMiddlewareAPI {
const {
devConfig,
serverConfig: { headers, base },
} = this;
): Promise<DevMiddlewareAPI> {
const { devConfig, serverConfig } = this;
const { headers, base } = serverConfig;

const callbacks = {
onInvalid: (compilationId?: string, fileName?: string | null) => {
Expand All @@ -181,7 +182,7 @@ export class CompilerDevMiddleware {

const clientPaths = getClientPaths(devConfig);

const middleware = devMiddleware({
const middleware = await devMiddleware({
headers,
publicPath: '/',
stats: false,
Expand All @@ -194,6 +195,7 @@ export class CompilerDevMiddleware {
// weak is enough in dev
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Conditional_requests#weak_validation
etag: 'weak',
serverConfig,
});

const assetPrefixes = publicPaths
Expand Down
25 changes: 21 additions & 4 deletions packages/core/src/server/devMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { applyToCompiler } from '../helpers';
import type { DevMiddlewareOptions } from '../provider/createCompiler';
import type { DevConfig, NextFunction } from '../types';
import { getCompilationId } from './helper';
import { getResolvedClientConfig } from './hmrFallback';

type ServerCallbacks = {
onInvalid: (compilationId?: string, fileName?: string | null) => void;
Expand Down Expand Up @@ -62,11 +63,13 @@ function applyHMREntry({
compiler,
clientPaths,
clientConfig = {},
resolvedClientConfig = {},
liveReload = true,
}: {
compiler: Compiler;
clientPaths: string[];
clientConfig: DevConfig['client'];
resolvedClientConfig: DevConfig['client'];
liveReload: DevConfig['liveReload'];
}) {
if (!isClientCompiler(compiler)) {
Expand All @@ -76,6 +79,7 @@ function applyHMREntry({
new compiler.webpack.DefinePlugin({
RSBUILD_COMPILATION_NAME: JSON.stringify(getCompilationId(compiler)),
RSBUILD_CLIENT_CONFIG: JSON.stringify(clientConfig),
RSBUILD_RESOLVED_CLIENT_CONFIG: JSON.stringify(resolvedClientConfig),
RSBUILD_DEV_LIVE_RELOAD: liveReload,
}).apply(compiler);

Expand All @@ -102,24 +106,37 @@ export type DevMiddlewareAPI = Middleware & {
* - Inject the HMR client path into page (the HMR client rsbuild/server already provide).
* - Notify server when compiler hooks are triggered.
*/
export type DevMiddleware = (options: DevMiddlewareOptions) => DevMiddlewareAPI;
export type DevMiddleware = (
options: DevMiddlewareOptions,
) => Promise<DevMiddlewareAPI>;

export const getDevMiddleware = async (
multiCompiler: Compiler | MultiCompiler,
): Promise<NonNullable<DevMiddleware>> => {
const { default: rsbuildDevMiddleware } = await import(
'../../compiled/rsbuild-dev-middleware/index.js'
);
return (options) => {
const { clientPaths, clientConfig, callbacks, liveReload, ...restOptions } =
options;
return async (options) => {
const {
clientPaths,
clientConfig,
callbacks,
liveReload,
serverConfig,
...restOptions
} = options;
const resolvedClientConfig = await getResolvedClientConfig(
clientConfig,
serverConfig,
);

const setupCompiler = (compiler: Compiler) => {
if (clientPaths) {
applyHMREntry({
compiler,
clientPaths,
clientConfig,
resolvedClientConfig,
liveReload,
});
}
Expand Down
75 changes: 75 additions & 0 deletions packages/core/src/server/hmrFallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { promises as dns } from 'node:dns';
import { DevConfig, ServerConfig } from 'src/types/config';
Copy link
Member

Choose a reason for hiding this comment

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

Prefer to use relative path


type Hostname = {
host?: string;
name: string;
};

const wildcardHosts = new Set([
'0.0.0.0',
'::',
'0000:0000:0000:0000:0000:0000:0000:0000',
]);

export async function resolveHostname(
optionsHost: string | boolean | undefined,
Copy link
Member

Choose a reason for hiding this comment

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

Rsbuild's server.host only supports string type, maybe we can remove boolean type here.

): Promise<Hostname> {
let host: string | undefined;
if (optionsHost === undefined || optionsHost === false) {
// Use a secure default
host = 'localhost';
} else if (optionsHost === true) {
// If passed --host in the CLI without arguments
host = undefined; // undefined typically means 0.0.0.0 or :: (listen on all IPs)
} else {
host = optionsHost;
}

// Set host name to localhost when possible
let name = host === undefined || wildcardHosts.has(host) ? 'localhost' : host;

if (host === 'localhost') {
// See #8647 for more details.
Copy link
Member

Choose a reason for hiding this comment

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

Do we need to update this comment?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Lets remove it

const localhostAddr = await getLocalhostAddressIfDiffersFromDNS();
if (localhostAddr) {
name = localhostAddr;
}
}

return { host, name };
}

/**
* Returns resolved localhost address when `dns.lookup` result differs from DNS
*
* `dns.lookup` result is same when defaultResultOrder is `verbatim`.
* Even if defaultResultOrder is `ipv4first`, `dns.lookup` result maybe same.
* For example, when IPv6 is not supported on that machine/network.
*/
export async function getLocalhostAddressIfDiffersFromDNS(): Promise<
string | undefined
> {
const [nodeResult, dnsResult] = await Promise.all([
dns.lookup('localhost'),
dns.lookup('localhost', { verbatim: true }),
]);
const isSame =
nodeResult.family === dnsResult.family &&
nodeResult.address === dnsResult.address;
return isSame ? undefined : nodeResult.address;
}
Copy link
Contributor Author

@wxiaoyun wxiaoyun Feb 2, 2025

Choose a reason for hiding this comment

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

Copy link
Member

Choose a reason for hiding this comment

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

It would be nice if we could do some refactoring based on Rsbuild's needs.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Is there any special needs you would like?

Copy link
Member

Choose a reason for hiding this comment

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

the current version is LGTM 👍


export async function getResolvedClientConfig(
clientConfig: DevConfig['client'],
serverConfig: ServerConfig,
): Promise<DevConfig['client']> {
const resolvedServerHostname = (await resolveHostname(serverConfig.host))
.name;
const resolvedServerPort = serverConfig.port!;
return {
...clientConfig,
host: resolvedServerHostname,
port: resolvedServerPort,
};
}