Skip to content

Commit 96c52f0

Browse files
antoniskrystofwoldrichclaude
authored
feat(playground): Open Sentry in desktop browser from Expo apps (#5947)
* feat(playground): Open Sentry in desktop browser * fix(playground): Harden open-url middleware and add tests - Return 405 for non-POST requests instead of hanging - Validate URL scheme (http/https only) - Improve error messages when `open` package is unavailable - Fix inconsistent oxlint disable comments - Add tests for openURLMiddleware and open-url routing Co-Authored-By: Krystof Woldrich <[email protected]> * docs(changelog): Add changelog for playground open-url in Expo Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * refactor(playground): Make SENTRY_MIDDLEWARE_PATH non-exported Only the derived constants are used externally. * fix(playground): Only auto-open sentry.io URLs, log others to console For non-sentry.io hosts, the URL is printed to the Metro console instead of being opened automatically, so users can decide whether to trust it. This prevents the middleware from being used to open arbitrary URLs while still supporting all *.sentry.io subdomains. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(playground): Handle ESM open package, sanitize logged URLs - Handle both CJS and ESM exports from the `open` package (v8+ is ESM-only and `require` returns `{ default: fn }` instead of `fn`) - Sanitize URLs before logging to prevent terminal escape sequence injection via control characters Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Krystof Woldrich <[email protected]> Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
1 parent ad68d6c commit 96c52f0

File tree

9 files changed

+306
-46
lines changed

9 files changed

+306
-46
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66
> make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first.
77
<!-- prettier-ignore-end -->
88
9+
## Unreleased
10+
11+
### Features
12+
13+
- Enable "Open Sentry" button in Playground for Expo apps ([#5947](https://github.com/getsentry/sentry-react-native/pull/5947))
14+
915
## 8.7.0
1016

1117
### Features
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
const SENTRY_MIDDLEWARE_PATH = '__sentry';
2+
export const SENTRY_CONTEXT_REQUEST_PATH = `${SENTRY_MIDDLEWARE_PATH}/context`;
3+
export const SENTRY_OPEN_URL_REQUEST_PATH = `${SENTRY_MIDDLEWARE_PATH}/open-url`;
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { IncomingMessage } from 'http';
2+
3+
/**
4+
* Get the raw body of a request.
5+
*/
6+
export function getRawBody(request: IncomingMessage): Promise<string> {
7+
return new Promise((resolve, reject) => {
8+
let data = '';
9+
request.on('data', chunk => {
10+
data += chunk;
11+
});
12+
request.on('end', () => {
13+
resolve(data);
14+
});
15+
request.on('error', reject);
16+
});
17+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { debug } from '@sentry/core';
2+
3+
import { getDevServer } from '../integrations/debugsymbolicatorutils';
4+
import { SENTRY_OPEN_URL_REQUEST_PATH } from './constants';
5+
6+
/**
7+
* Send request to the Metro Development Server Middleware to open a URL in the system browser.
8+
*/
9+
export function openURLInBrowser(url: string): void {
10+
// oxlint-disable-next-line typescript-eslint(no-floating-promises)
11+
fetch(`${getDevServer()?.url || '/'}${SENTRY_OPEN_URL_REQUEST_PATH}`, {
12+
method: 'POST',
13+
body: JSON.stringify({ url }),
14+
}).catch(e => {
15+
debug.error('Error opening URL:', e);
16+
});
17+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import type { IncomingMessage, ServerResponse } from 'http';
2+
3+
import { getRawBody } from './getRawBody';
4+
5+
/*
6+
* Prefix for Sentry Metro logs to make them stand out to the user.
7+
*/
8+
const S = '\u001b[45;1m SENTRY \u001b[0m';
9+
10+
let open: ((url: string) => Promise<void>) | undefined = undefined;
11+
12+
/**
13+
* Open a URL in the system browser.
14+
*
15+
* Inspired by https://github.com/react-native-community/cli/blob/a856ce027a6b25f9363a8689311cdd4416c0fc89/packages/cli-server-api/src/openURLMiddleware.ts#L17
16+
*/
17+
export async function openURLMiddleware(req: IncomingMessage, res: ServerResponse): Promise<void> {
18+
if (req.method !== 'POST') {
19+
res.writeHead(405);
20+
res.end('Method not allowed. Use POST.');
21+
return;
22+
}
23+
24+
if (!open) {
25+
try {
26+
// oxlint-disable-next-line import/no-extraneous-dependencies
27+
const imported = require('open');
28+
// Handle both CJS (`module.exports = fn`) and ESM default export (`{ default: fn }`)
29+
// oxlint-disable-next-line typescript-eslint(no-unsafe-member-access)
30+
open = typeof imported === 'function' ? imported : imported?.default;
31+
} catch (e) {
32+
// noop
33+
}
34+
}
35+
36+
const body = await getRawBody(req);
37+
let url: string | undefined = undefined;
38+
39+
try {
40+
const parsedBody = JSON.parse(body) as { url?: string };
41+
url = parsedBody.url;
42+
} catch (e) {
43+
res.writeHead(400);
44+
res.end('Invalid request body. Expected a JSON object with a url key.');
45+
return;
46+
}
47+
48+
if (!url) {
49+
res.writeHead(400);
50+
res.end('Invalid request body. Expected a JSON object with a url key.');
51+
return;
52+
}
53+
54+
if (!url.startsWith('https://') && !url.startsWith('http://')) {
55+
res.writeHead(400);
56+
res.end('Invalid URL scheme. Only http:// and https:// URLs are allowed.');
57+
return;
58+
}
59+
60+
if (!isTrustedSentryHost(url)) {
61+
// oxlint-disable-next-line no-console
62+
console.log(
63+
`${S} Untrusted host, not opening automatically. Open manually if you trust this URL: ${sanitizeForLog(url)}`,
64+
);
65+
res.writeHead(200);
66+
res.end();
67+
return;
68+
}
69+
70+
if (!open) {
71+
// oxlint-disable-next-line no-console
72+
console.log(`${S} Could not open URL automatically. Open manually: ${sanitizeForLog(url)}`);
73+
res.writeHead(500);
74+
res.end('Failed to open URL. The "open" package is not available. Install it or open the URL manually.');
75+
return;
76+
}
77+
78+
try {
79+
await open(url);
80+
} catch (e) {
81+
// oxlint-disable-next-line no-console
82+
console.log(`${S} Failed to open URL automatically. Open manually: ${sanitizeForLog(url)}`);
83+
res.writeHead(500);
84+
res.end('Failed to open URL.');
85+
return;
86+
}
87+
88+
// oxlint-disable-next-line no-console
89+
console.log(`${S} Opened URL: ${sanitizeForLog(url)}`);
90+
res.writeHead(200);
91+
res.end();
92+
}
93+
94+
/**
95+
* Strip control characters to prevent terminal escape sequence injection when logging URLs.
96+
*/
97+
function sanitizeForLog(value: string): string {
98+
// oxlint-disable-next-line no-control-regex
99+
return value.replace(/[\x00-\x1f\x7f]/g, '');
100+
}
101+
102+
function isTrustedSentryHost(url: string): boolean {
103+
try {
104+
const { hostname } = new URL(url);
105+
return hostname === 'sentry.io' || hostname.endsWith('.sentry.io');
106+
} catch (e) {
107+
return false;
108+
}
109+
}

packages/core/src/js/playground/modal.tsx

Lines changed: 9 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
import { debug } from '@sentry/core';
21
import * as React from 'react';
32
import { Animated, Image, Modal, Platform, Pressable, Text, useColorScheme, View } from 'react-native';
43

5-
import { getDevServer } from '../integrations/debugsymbolicatorutils';
6-
import { isExpo, isExpoGo, isWeb } from '../utils/environment';
4+
import { openURLInBrowser } from '../metro/openUrlInBrowser';
5+
import { isExpoGo, isWeb } from '../utils/environment';
76
import { bug as bugAnimation, hi as hiAnimation, thumbsup as thumbsupAnimation } from './animations';
87
import { nativeCrashExample, tryCatchExample, uncaughtErrorExample } from './examples';
98
import { bug as bugImage, hi as hiImage, thumbsup as thumbsupImage } from './images';
@@ -71,7 +70,6 @@ export const SentryPlayground = ({
7170
}
7271
};
7372

74-
const showOpenSentryButton = !isExpo();
7573
const isNativeCrashDisabled = isWeb() || isExpoGo() || __DEV__;
7674

7775
const animationContainerYPosition = React.useRef(new Animated.Value(0)).current;
@@ -158,15 +156,13 @@ export const SentryPlayground = ({
158156
justifyContent: 'space-evenly', // Space between buttons
159157
}}
160158
>
161-
{showOpenSentryButton && (
162-
<Button
163-
secondary
164-
title={'Open Sentry'}
165-
onPress={() => {
166-
openURLInBrowser(issuesStreamUrl);
167-
}}
168-
/>
169-
)}
159+
<Button
160+
secondary
161+
title={'Open Sentry'}
162+
onPress={() => {
163+
openURLInBrowser(issuesStreamUrl);
164+
}}
165+
/>
170166
<Button
171167
title={'Go to my App'}
172168
onPress={() => {
@@ -269,19 +265,3 @@ const Button = ({
269265
</View>
270266
);
271267
};
272-
273-
function openURLInBrowser(url: string): void {
274-
const devServer = getDevServer();
275-
if (devServer?.url) {
276-
// This doesn't work for Expo project with Web enabled
277-
// oxlint-disable-next-line typescript-eslint(no-floating-promises)
278-
fetch(`${devServer.url}open-url`, {
279-
method: 'POST',
280-
body: JSON.stringify({ url }),
281-
}).catch(e => {
282-
debug.error('Error opening URL:', e);
283-
});
284-
} else {
285-
debug.error('Dev server URL not available');
286-
}
287-
}

packages/core/src/js/tools/metroMiddleware.ts

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import { addContextToFrame, debug } from '@sentry/core';
66
import { readFile } from 'fs';
77
import { promisify } from 'util';
88

9+
import { SENTRY_CONTEXT_REQUEST_PATH, SENTRY_OPEN_URL_REQUEST_PATH } from '../metro/constants';
10+
import { getRawBody } from '../metro/getRawBody';
11+
import { openURLMiddleware } from '../metro/openUrlMiddleware';
12+
913
const readFileAsync = promisify(readFile);
1014

1115
/**
@@ -71,29 +75,15 @@ function badRequest(response: ServerResponse, message: string): void {
7175
response.end(message);
7276
}
7377

74-
function getRawBody(request: IncomingMessage): Promise<string> {
75-
return new Promise((resolve, reject) => {
76-
let data = '';
77-
request.on('data', chunk => {
78-
data += chunk;
79-
});
80-
request.on('end', () => {
81-
resolve(data);
82-
});
83-
request.on('error', reject);
84-
});
85-
}
86-
87-
const SENTRY_MIDDLEWARE_PATH = '/__sentry';
88-
const SENTRY_CONTEXT_REQUEST_PATH = `${SENTRY_MIDDLEWARE_PATH}/context`;
89-
9078
/**
9179
* Creates a middleware that adds source context to the Sentry formatted stack frames.
9280
*/
9381
export const createSentryMetroMiddleware = (middleware: Middleware): Middleware => {
9482
return (request: IncomingMessage, response: ServerResponse, next: () => void) => {
95-
if (request.url?.startsWith(SENTRY_CONTEXT_REQUEST_PATH)) {
83+
if (request.url?.startsWith(`/${SENTRY_CONTEXT_REQUEST_PATH}`)) {
9684
return stackFramesContextMiddleware(request, response, next);
85+
} else if (request.url?.startsWith(`/${SENTRY_OPEN_URL_REQUEST_PATH}`)) {
86+
return openURLMiddleware(request, response);
9787
}
9888
return (middleware as (req: IncomingMessage, res: ServerResponse, next: () => void) => void)(
9989
request,

packages/core/test/tools/metroMiddleware.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { StackFrame } from '@sentry/core';
33
import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
44
import * as fs from 'fs';
55

6+
import * as openUrlMiddlewareModule from '../../src/js/metro/openUrlMiddleware';
67
import * as metroMiddleware from '../../src/js/tools/metroMiddleware';
78

89
const { withSentryMiddleware, createSentryMetroMiddleware, stackFramesContextMiddleware } = metroMiddleware;
@@ -83,6 +84,24 @@ describe('metroMiddleware', () => {
8384
expect(spiedStackFramesContextMiddleware).toHaveBeenCalledWith(sentryRequest, response, next);
8485
});
8586

87+
it('should call openURLMiddleware for sentry open-url requests', () => {
88+
const spiedOpenURLMiddleware = jest
89+
.spyOn(openUrlMiddlewareModule, 'openURLMiddleware')
90+
.mockReturnValue(undefined as any);
91+
92+
const testedMiddleware = createSentryMetroMiddleware(defaultMiddleware);
93+
94+
const openUrlRequest = {
95+
url: '/__sentry/open-url',
96+
} as any;
97+
testedMiddleware(openUrlRequest, response, next);
98+
expect(defaultMiddleware).not.toHaveBeenCalled();
99+
expect(spiedStackFramesContextMiddleware).not.toHaveBeenCalled();
100+
expect(spiedOpenURLMiddleware).toHaveBeenCalledWith(openUrlRequest, response);
101+
102+
spiedOpenURLMiddleware.mockRestore();
103+
});
104+
86105
it('should call default middleware for non-sentry requests', () => {
87106
const testedMiddleware = createSentryMetroMiddleware(defaultMiddleware);
88107

0 commit comments

Comments
 (0)