Skip to content

Commit d570594

Browse files
authored
feat(remix): Add Vite dev-mode support to Express instrumentation. (#10784)
Resolves #10724 Related: #9500 Adds dev-mode support to Remix setups with Vite and Express. We currently accept Remix server `build` as an object to instrument. But Remix allows `build` as a synchronous or asynchronous function that returns the build object. Currently, it seems that functions are only used in development servers, and not in production. So, while this update slightly reduces `requestHandler` performance on dev servers, it does not on production builds. We need `build` in 2 places: 1- We instrument the loaders / actions on build, then we pass them down to the original implementations. 2- We use the `routes` inside them to create parameterised transactions. This update adds new internal wrappers around them to make sure that we don't miss out on the returned / resolved values in case `build` is a function, for both cases. This PR also adds a new E2E test application using the latest Remix version and Vite, and it runs the tests on `dev` mode. We also need a documentation update to reflect this, if it gets merged.
1 parent 8ed3598 commit d570594

File tree

22 files changed

+7179
-22
lines changed

22 files changed

+7179
-22
lines changed

.github/workflows/build.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1058,6 +1058,7 @@ jobs:
10581058
'create-next-app',
10591059
'create-remix-app',
10601060
'create-remix-app-v2',
1061+
'create-remix-app-express-vite-dev',
10611062
'debug-id-sourcemaps',
10621063
'nextjs-app-dir',
10631064
'nextjs-14',
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/**
2+
* This is intended to be a basic starting point for linting in your app.
3+
* It relies on recommended configs out of the box for simplicity, but you can
4+
* and should modify this configuration to best suit your team's needs.
5+
*/
6+
7+
/** @type {import('eslint').Linter.Config} */
8+
module.exports = {
9+
root: true,
10+
parserOptions: {
11+
ecmaVersion: 'latest',
12+
sourceType: 'module',
13+
ecmaFeatures: {
14+
jsx: true,
15+
},
16+
},
17+
env: {
18+
browser: true,
19+
commonjs: true,
20+
es6: true,
21+
},
22+
23+
// Base config
24+
extends: ['eslint:recommended'],
25+
26+
overrides: [
27+
// React
28+
{
29+
files: ['**/*.{js,jsx,ts,tsx}'],
30+
plugins: ['react', 'jsx-a11y'],
31+
extends: [
32+
'plugin:react/recommended',
33+
'plugin:react/jsx-runtime',
34+
'plugin:react-hooks/recommended',
35+
'plugin:jsx-a11y/recommended',
36+
],
37+
settings: {
38+
react: {
39+
version: 'detect',
40+
},
41+
formComponents: ['Form'],
42+
linkComponents: [
43+
{ name: 'Link', linkAttribute: 'to' },
44+
{ name: 'NavLink', linkAttribute: 'to' },
45+
],
46+
'import/resolver': {
47+
typescript: {},
48+
},
49+
},
50+
},
51+
52+
// Typescript
53+
{
54+
files: ['**/*.{ts,tsx}'],
55+
plugins: ['@typescript-eslint', 'import'],
56+
parser: '@typescript-eslint/parser',
57+
settings: {
58+
'import/internal-regex': '^~/',
59+
'import/resolver': {
60+
node: {
61+
extensions: ['.ts', '.tsx'],
62+
},
63+
typescript: {
64+
alwaysTryTypes: true,
65+
},
66+
},
67+
},
68+
extends: ['plugin:@typescript-eslint/recommended', 'plugin:import/recommended', 'plugin:import/typescript'],
69+
},
70+
71+
// Node
72+
{
73+
files: ['.eslintrc.js', 'server.mjs'],
74+
env: {
75+
node: true,
76+
},
77+
},
78+
],
79+
};
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
node_modules
2+
3+
/.cache
4+
/build
5+
.env
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
@sentry:registry=http://127.0.0.1:4873
2+
@sentry-internal:registry=http://127.0.0.1:4873
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Welcome to Remix + Vite!
2+
3+
📖 See the [Remix docs](https://remix.run/docs) and the [Remix Vite docs](https://remix.run/docs/en/main/future/vite) for details on supported features.
4+
5+
## Development
6+
7+
Run the Express server with Vite dev middleware:
8+
9+
```shellscript
10+
npm run dev
11+
```
12+
13+
## Deployment
14+
15+
First, build your app for production:
16+
17+
```sh
18+
npm run build
19+
```
20+
21+
Then run the app in production mode:
22+
23+
```sh
24+
npm start
25+
```
26+
27+
Now you'll need to pick a host to deploy it to.
28+
29+
### DIY
30+
31+
If you're familiar with deploying Express applications you should be right at home. Just make sure to deploy the output of `npm run build`
32+
33+
- `build/server`
34+
- `build/client`
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { RemixBrowser, useLocation, useMatches } from '@remix-run/react';
2+
import * as Sentry from '@sentry/remix';
3+
import { StrictMode, startTransition, useEffect } from 'react';
4+
import { hydrateRoot } from 'react-dom/client';
5+
6+
Sentry.init({
7+
environment: 'qa', // dynamic sampling bias to keep transactions
8+
dsn: window.ENV.SENTRY_DSN,
9+
integrations: [
10+
Sentry.browserTracingIntegration({
11+
useEffect,
12+
useLocation,
13+
useMatches,
14+
}),
15+
new Sentry.Replay(),
16+
],
17+
// Performance Monitoring
18+
tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production!
19+
// Session Replay
20+
replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production.
21+
replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur.
22+
});
23+
24+
Sentry.addEventProcessor(event => {
25+
if (
26+
event.type === 'transaction' &&
27+
(event.contexts?.trace?.op === 'pageload' || event.contexts?.trace?.op === 'navigation')
28+
) {
29+
const eventId = event.event_id;
30+
if (eventId) {
31+
window.recordedTransactions = window.recordedTransactions || [];
32+
window.recordedTransactions.push(eventId);
33+
}
34+
}
35+
36+
return event;
37+
});
38+
39+
startTransition(() => {
40+
hydrateRoot(
41+
document,
42+
<StrictMode>
43+
<RemixBrowser />
44+
</StrictMode>,
45+
);
46+
});
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { PassThrough } from 'node:stream';
2+
3+
import type { AppLoadContext, EntryContext } from '@remix-run/node';
4+
import { createReadableStreamFromReadable } from '@remix-run/node';
5+
import { installGlobals } from '@remix-run/node';
6+
import { RemixServer } from '@remix-run/react';
7+
import * as Sentry from '@sentry/remix';
8+
import * as isbotModule from 'isbot';
9+
import { renderToPipeableStream } from 'react-dom/server';
10+
11+
installGlobals();
12+
13+
const ABORT_DELAY = 5_000;
14+
15+
Sentry.init({
16+
environment: 'qa', // dynamic sampling bias to keep transactions
17+
dsn: process.env.E2E_TEST_DSN,
18+
// Performance Monitoring
19+
tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production!
20+
});
21+
22+
export const handleError = Sentry.wrapRemixHandleError;
23+
24+
export default function handleRequest(
25+
request: Request,
26+
responseStatusCode: number,
27+
responseHeaders: Headers,
28+
remixContext: EntryContext,
29+
loadContext: AppLoadContext,
30+
) {
31+
return isBotRequest(request.headers.get('user-agent'))
32+
? handleBotRequest(request, responseStatusCode, responseHeaders, remixContext)
33+
: handleBrowserRequest(request, responseStatusCode, responseHeaders, remixContext);
34+
}
35+
36+
// We have some Remix apps in the wild already running with isbot@3 so we need
37+
// to maintain backwards compatibility even though we want new apps to use
38+
// isbot@4. That way, we can ship this as a minor Semver update to @remix-run/dev.
39+
function isBotRequest(userAgent: string | null) {
40+
if (!userAgent) {
41+
return false;
42+
}
43+
44+
// isbot >= 3.8.0, >4
45+
if ('isbot' in isbotModule && typeof isbotModule.isbot === 'function') {
46+
return isbotModule.isbot(userAgent);
47+
}
48+
49+
// isbot < 3.8.0
50+
if ('default' in isbotModule && typeof isbotModule.default === 'function') {
51+
return isbotModule.default(userAgent);
52+
}
53+
54+
return false;
55+
}
56+
57+
function handleBotRequest(
58+
request: Request,
59+
responseStatusCode: number,
60+
responseHeaders: Headers,
61+
remixContext: EntryContext,
62+
) {
63+
return new Promise((resolve, reject) => {
64+
let shellRendered = false;
65+
const { pipe, abort } = renderToPipeableStream(
66+
<RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />,
67+
{
68+
onAllReady() {
69+
shellRendered = true;
70+
const body = new PassThrough();
71+
const stream = createReadableStreamFromReadable(body);
72+
73+
responseHeaders.set('Content-Type', 'text/html');
74+
75+
resolve(
76+
new Response(stream, {
77+
headers: responseHeaders,
78+
status: responseStatusCode,
79+
}),
80+
);
81+
82+
pipe(body);
83+
},
84+
onShellError(error: unknown) {
85+
reject(error);
86+
},
87+
onError(error: unknown) {
88+
responseStatusCode = 500;
89+
// Log streaming rendering errors from inside the shell. Don't log
90+
// errors encountered during initial shell rendering since they'll
91+
// reject and get logged in handleDocumentRequest.
92+
if (shellRendered) {
93+
console.error(error);
94+
}
95+
},
96+
},
97+
);
98+
99+
setTimeout(abort, ABORT_DELAY);
100+
});
101+
}
102+
103+
function handleBrowserRequest(
104+
request: Request,
105+
responseStatusCode: number,
106+
responseHeaders: Headers,
107+
remixContext: EntryContext,
108+
) {
109+
return new Promise((resolve, reject) => {
110+
let shellRendered = false;
111+
const { pipe, abort } = renderToPipeableStream(
112+
<RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />,
113+
{
114+
onShellReady() {
115+
shellRendered = true;
116+
const body = new PassThrough();
117+
const stream = createReadableStreamFromReadable(body);
118+
119+
responseHeaders.set('Content-Type', 'text/html');
120+
121+
resolve(
122+
new Response(stream, {
123+
headers: responseHeaders,
124+
status: responseStatusCode,
125+
}),
126+
);
127+
128+
pipe(body);
129+
},
130+
onShellError(error: unknown) {
131+
reject(error);
132+
},
133+
onError(error: unknown) {
134+
responseStatusCode = 500;
135+
// Log streaming rendering errors from inside the shell. Don't log
136+
// errors encountered during initial shell rendering since they'll
137+
// reject and get logged in handleDocumentRequest.
138+
if (shellRendered) {
139+
console.error(error);
140+
}
141+
},
142+
},
143+
);
144+
145+
setTimeout(abort, ABORT_DELAY);
146+
});
147+
}

0 commit comments

Comments
 (0)