Skip to content

Commit e1e5bd7

Browse files
authored
fix(middleware): scope Rozenite middleware to /rozenite (#230)
## Summary - scope Rozenite's middleware to `/rozenite` instead of wrapping the full Metro/Re.Pack middleware chain - add a guarded middleware wrapper that stops propagation when a delegated handler incorrectly calls `next()` after ending the response - cover the scoped routing and `ERR_HTTP_HEADERS_SENT` regression with middleware tests ## Testing - pnpm --filter @rozenite/middleware test - pnpm --filter @rozenite/middleware typecheck - pnpm --filter @rozenite/metro typecheck - pnpm --filter @rozenite/repack typecheck - pnpm --filter @rozenite/middleware lint - pnpm --filter @rozenite/metro lint - pnpm --filter @rozenite/repack lint Fixes #229
1 parent 90e7fb6 commit e1e5bd7

6 files changed

Lines changed: 258 additions & 26 deletions

File tree

.changeset/bright-crabs-joke.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@rozenite/middleware': patch
3+
---
4+
5+
Fix an issue where opening a stack frame from Rozenite could land in the wrong place.

packages/metro/src/index.ts

Lines changed: 48 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import { type MetroConfig } from '@react-native/metro-config';
2-
import { initializeRozenite, type RozeniteConfig } from '@rozenite/middleware';
2+
import {
3+
createScopedMiddleware,
4+
initializeRozenite,
5+
type MiddlewareHandler,
6+
type MiddlewareNext,
7+
type MiddlewareRequest,
8+
type RozeniteConfig,
9+
} from '@rozenite/middleware';
310
import { logger } from '@rozenite/tools';
411
import path from 'node:path';
512
import { isBundling } from './is-bundling.js';
@@ -19,24 +26,24 @@ export type RozeniteMetroConfig<TMetroConfig = unknown> = Omit<
1926
* This option allows you to modify the Metro config in a way that is safe to do when bundling.
2027
*/
2128
enhanceMetroConfig?: (
22-
config: TMetroConfig
29+
config: TMetroConfig,
2330
) => Promise<TMetroConfig> | TMetroConfig;
2431
};
2532

2633
export const withRozenite = <T extends MetroConfig>(
2734
config: T | Promise<T>,
28-
options: RozeniteMetroConfig<T> = {}
29-
): () => Promise<T> => {
35+
options: RozeniteMetroConfig<T> = {},
36+
): (() => Promise<T>) => {
3037
return async () => {
3138
const resolvedConfig = await config;
3239
const projectRoot = resolvedConfig.projectRoot ?? process.cwd();
3340

3441
if (options.enabled === undefined) {
3542
logger.info(
36-
'Rozenite will no longer be enabled by default in the next version.'
43+
'Rozenite will no longer be enabled by default in the next version.',
3744
);
3845
logger.info(
39-
'To continue using Rozenite, please set `enabled` in the options.'
46+
'To continue using Rozenite, please set `enabled` in the options.',
4047
);
4148
logger.info('Remember to make it conditional to avoid bundling issues.');
4249

@@ -49,12 +56,11 @@ export const withRozenite = <T extends MetroConfig>(
4956
return resolvedConfig;
5057
}
5158

52-
const { devModePackage, middleware: rozeniteMiddleware } = initializeRozenite(
53-
{
59+
const { devModePackage, middleware: rozeniteMiddleware } =
60+
initializeRozenite({
5461
projectRoot,
5562
...options,
56-
}
57-
);
63+
});
5864

5965
const rozeniteMetroConfig = {
6066
...resolvedConfig,
@@ -73,12 +79,12 @@ export const withRozenite = <T extends MetroConfig>(
7379
// Rozenite package should use the same versions of React and React Native as the app.
7480
// Using dirname as sometimes developers use deep imports for react-native.
7581
react: path.dirname(
76-
require.resolve('react', { paths: [projectRoot] })
82+
require.resolve('react', { paths: [projectRoot] }),
7783
),
7884
'react-native': path.dirname(
7985
require.resolve('react-native', {
8086
paths: [projectRoot],
81-
})
87+
}),
8288
),
8389
}
8490
: resolvedConfig.resolver?.extraNodeModules,
@@ -87,7 +93,8 @@ export const withRozenite = <T extends MetroConfig>(
8793
// This is currently the only module that we need to mock, but it may change in the future.
8894
if (
8995
platform === 'web' &&
90-
moduleName === 'react-native/Libraries/WebSocket/WebSocketInterceptor'
96+
moduleName ===
97+
'react-native/Libraries/WebSocket/WebSocketInterceptor'
9198
) {
9299
return {
93100
type: 'empty',
@@ -98,7 +105,7 @@ export const withRozenite = <T extends MetroConfig>(
98105
resolvedConfig.resolver?.resolveRequest?.(
99106
context,
100107
moduleName,
101-
platform
108+
platform,
102109
) ?? context.resolveRequest(context, moduleName, platform)
103110
);
104111
},
@@ -107,21 +114,41 @@ export const withRozenite = <T extends MetroConfig>(
107114
...resolvedConfig.server,
108115
enhanceMiddleware: (metroMiddleware, server) => {
109116
const prevMiddleware =
110-
resolvedConfig.server?.enhanceMiddleware?.(metroMiddleware, server) ??
111-
metroMiddleware;
117+
resolvedConfig.server?.enhanceMiddleware?.(
118+
metroMiddleware,
119+
server,
120+
) ?? metroMiddleware;
121+
const delegatedMetroMiddleware = prevMiddleware as MiddlewareHandler;
122+
123+
const scopedRozeniteMiddleware = createScopedMiddleware(
124+
'/rozenite',
125+
rozeniteMiddleware,
126+
);
112127

113-
return rozeniteMiddleware.use(prevMiddleware);
128+
return (
129+
req: MiddlewareRequest,
130+
res: Parameters<MiddlewareHandler>[1],
131+
next: MiddlewareNext,
132+
) => {
133+
scopedRozeniteMiddleware(req, res, (error) => {
134+
if (error) {
135+
next(error);
136+
return;
137+
}
138+
139+
delegatedMetroMiddleware(req, res, next);
140+
});
141+
};
114142
},
115143
},
116144
} satisfies MetroConfig;
117145

118146
if (options.enhanceMetroConfig) {
119-
const enhancedConfig = await options.enhanceMetroConfig(
120-
rozeniteMetroConfig
121-
);
147+
const enhancedConfig =
148+
await options.enhanceMetroConfig(rozeniteMetroConfig);
122149
return enhancedConfig;
123150
}
124151

125152
return rozeniteMetroConfig;
126-
}
153+
};
127154
};

packages/middleware/src/__tests__/middleware.test.ts

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,76 @@
1-
import { describe, expect, it } from 'vitest';
1+
import { createServer, get } from 'node:http';
2+
import { afterEach, describe, expect, it, vi } from 'vitest';
23
import { getNormalizedRequestUrl } from '../middleware.js';
4+
import {
5+
createScopedMiddleware,
6+
type MiddlewareHandler,
7+
} from '../scoped-middleware.js';
8+
9+
let activeServer: ReturnType<typeof createServer> | null = null;
10+
11+
afterEach(async () => {
12+
await new Promise<void>((resolve, reject) => {
13+
if (!activeServer) {
14+
resolve();
15+
return;
16+
}
17+
18+
activeServer.close((error) => {
19+
activeServer = null;
20+
21+
if (error) {
22+
reject(error);
23+
return;
24+
}
25+
26+
resolve();
27+
});
28+
});
29+
});
30+
31+
const runRequest = async (
32+
middleware: MiddlewareHandler,
33+
url: string,
34+
): Promise<{ status: number; body: string }> => {
35+
activeServer = createServer((req, res) => {
36+
middleware(req, res, () => {
37+
res.statusCode = 404;
38+
res.end('not found');
39+
});
40+
});
41+
42+
await new Promise<void>((resolve) => {
43+
activeServer!.listen(0, resolve);
44+
});
45+
46+
const address = activeServer.address();
47+
48+
if (!address || typeof address === 'string') {
49+
throw new Error('Expected an ephemeral TCP port');
50+
}
51+
52+
return new Promise((resolve, reject) => {
53+
const request = get(
54+
`http://127.0.0.1:${address.port}${url}`,
55+
(response) => {
56+
let body = '';
57+
58+
response.setEncoding('utf8');
59+
response.on('data', (chunk) => {
60+
body += chunk;
61+
});
62+
response.on('end', () => {
63+
resolve({
64+
status: response.statusCode ?? 0,
65+
body,
66+
});
67+
});
68+
},
69+
);
70+
71+
request.on('error', reject);
72+
});
73+
};
374

475
describe('middleware request normalization', () => {
576
it('preserves agent routes under /rozenite', () => {
@@ -20,3 +91,50 @@ describe('middleware request normalization', () => {
2091
);
2192
});
2293
});
94+
95+
describe('scoped middleware', () => {
96+
it('delegates only requests within the configured prefix', async () => {
97+
const handler = vi.fn<MiddlewareHandler>((req, res) => {
98+
res.end(req.url);
99+
});
100+
const middleware = createScopedMiddleware('/rozenite', handler);
101+
102+
const insideResponse = await runRequest(
103+
middleware,
104+
'/rozenite/plugins/demo/index.js',
105+
);
106+
const outsideResponse = await runRequest(middleware, '/open-stack-frame');
107+
108+
expect(insideResponse).toEqual({
109+
status: 200,
110+
body: '/plugins/demo/index.js',
111+
});
112+
expect(outsideResponse).toEqual({
113+
status: 404,
114+
body: 'not found',
115+
});
116+
expect(handler).toHaveBeenCalledTimes(1);
117+
});
118+
119+
it('stops propagation when delegated middleware ends the response and still calls next', async () => {
120+
const downstream = vi.fn();
121+
const buggyMiddleware = createScopedMiddleware(
122+
'/rozenite',
123+
(_req, res, next) => {
124+
res.statusCode = 204;
125+
res.end();
126+
next();
127+
},
128+
);
129+
130+
const response = await runRequest((req, res, next) => {
131+
buggyMiddleware(req, res, () => {
132+
downstream();
133+
next();
134+
});
135+
}, '/rozenite/rn_fusebox.html');
136+
137+
expect(response).toEqual({ status: 204, body: '' });
138+
expect(downstream).not.toHaveBeenCalled();
139+
});
140+
});

packages/middleware/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { getMiddleware } from './middleware.js';
44
import { logger } from './logger.js';
55
import { getPackageJSON } from './package-json.js';
66
import { getInstalledPlugins } from './auto-discovery.js';
7+
import { createScopedMiddleware } from './scoped-middleware.js';
78
import type { RozeniteConfig } from './config.js';
89
import { getDevModePackage } from './dev-mode.js';
910
import { verifyReactNativeVersion } from './verify-react-native-version.js';
@@ -17,6 +18,13 @@ export type RozeniteInstance = {
1718
dispose: () => Promise<void>;
1819
};
1920

21+
export { createScopedMiddleware };
22+
export type {
23+
MiddlewareHandler,
24+
MiddlewareNext,
25+
MiddlewareRequest,
26+
} from './scoped-middleware.js';
27+
2028
export const initializeRozenite = (
2129
options: RozeniteConfig,
2230
): RozeniteInstance => {
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import type { IncomingMessage, ServerResponse } from 'node:http';
2+
3+
export type MiddlewareRequest = IncomingMessage & {
4+
url?: string;
5+
};
6+
7+
export type MiddlewareNext = (error?: unknown) => void;
8+
9+
export type MiddlewareHandler = (
10+
req: MiddlewareRequest,
11+
res: ServerResponse,
12+
next: MiddlewareNext,
13+
) => void;
14+
15+
const matchesPrefix = (url: string, prefix: string): boolean => {
16+
return url === prefix || url.startsWith(prefix + '/');
17+
};
18+
19+
const withFinishedResponseGuard = (
20+
middleware: MiddlewareHandler,
21+
): MiddlewareHandler => {
22+
return (req, res, next) => {
23+
middleware(req, res, (error) => {
24+
if (error) {
25+
next(error);
26+
return;
27+
}
28+
29+
// Some upstream middleware incorrectly calls next() after it already ended
30+
// the response. Stop the chain here so later middleware does not try to
31+
// write headers again and crash with ERR_HTTP_HEADERS_SENT.
32+
if (res.headersSent || res.writableEnded) {
33+
return;
34+
}
35+
36+
next();
37+
});
38+
};
39+
};
40+
41+
export const createScopedMiddleware = (
42+
prefix: string,
43+
middleware: MiddlewareHandler,
44+
): MiddlewareHandler => {
45+
const guardedMiddleware = withFinishedResponseGuard(middleware);
46+
47+
return (req, res, next) => {
48+
if (!req.url || !matchesPrefix(req.url, prefix)) {
49+
next();
50+
return;
51+
}
52+
53+
const originalUrl = req.url;
54+
const scopedUrl = req.url.slice(prefix.length) || '/';
55+
req.url = scopedUrl.startsWith('/') ? scopedUrl : '/' + scopedUrl;
56+
57+
guardedMiddleware(req, res, (error) => {
58+
req.url = originalUrl;
59+
60+
if (error) {
61+
next(error);
62+
return;
63+
}
64+
65+
next();
66+
});
67+
};
68+
};

0 commit comments

Comments
 (0)