Skip to content

feat(tanstackstart-react): Serve the managed tunnel from the server entry - #25408

Open
logaretm wants to merge 14 commits into
awad/tunnel-browser-managedfrom
awad/tunnel-tanstackstart
Open

logaretm wants to merge 14 commits into
awad/tunnel-browser-managedfrom
awad/tunnel-tanstackstart

Conversation

@logaretm

@logaretm logaretm commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

TanStack Start picked a random tunnel path per build and patched the generated route tree to serve it.

Because the new approach relies less on build-time, the logic was changed to wrap the server entrypoint instead and only route the matching tunnel requests to our handler.

This isn't a breaking change, previously we injected a single endpoint handler, now we inject a middleware since the DSN is only known at runtime.

The base path comes from router.basepath (or Vite's base), and the tunnel-basepath e2e variants cover it in both dev and prod.

For reviewers: if you need an overview of what this is, refer to #25199.

closes #25461

@logaretm
logaretm added this pull request to stack #25201 October 15, 2026 15:13
@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

size-limit report 📦

Compared: 287ad6a (PR test merge using develop at f9f8adc)

Against: ca4fc32 (develop) · base run #62950

⚠️ Baseline mismatch: 49 develop commit(s) in the PR build are missing from the baseline. Their size changes also affect this comparison. Compare commits. Re-run once develop's baseline build for f9f8adc finishes.

Path Size % Change Change
@sentry/browser 30.56 kB +2.18% +651 B 🔺
@sentry/browser - with treeshaking flags 28.54 kB +1.76% +491 B 🔺
@sentry/browser - with treeshaking flags tracing without tracing 28.42 kB +1.69% +470 B 🔺
@sentry/browser (incl. Tracing) 52.8 kB +1.35% +702 B 🔺
@sentry/browser (incl. Tracing + Span Streaming) 52.8 kB +1.35% +699 B 🔺
@sentry/browser (incl. Tracing, Profiling) 55.77 kB +1.31% +720 B 🔺
@sentry/browser (incl. Tracing, Replay) 92.47 kB +0.68% +621 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 81.21 kB +0.61% +492 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas) 97.21 kB +0.66% +633 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback) 110.2 kB +0.63% +687 B 🔺
@sentry/browser (incl. Feedback) 48.12 kB +1.49% +703 B 🔺
@sentry/browser (incl. sendFeedback) 35.63 kB +1.9% +664 B 🔺
@sentry/browser (incl. FeedbackAsync) 40.76 kB +1.71% +684 B 🔺
@sentry/browser (incl. Metrics) 31.57 kB +2.05% +634 B 🔺
@sentry/browser (incl. Logs) 31.84 kB +2.02% +629 B 🔺
@sentry/browser (incl. Metrics & Logs) 32.52 kB +2.12% +673 B 🔺
@sentry/react 32.37 kB +1.99% +630 B 🔺
@sentry/react (incl. Tracing) 55.05 kB +1.17% +635 B 🔺
@sentry/vue 38.71 kB +1.7% +646 B 🔺
@sentry/vue (incl. Tracing) 55.75 kB +1.3% +715 B 🔺
@sentry/svelte 30.59 kB +2.2% +656 B 🔺
@sentry/remix (Remix 3 client bundle) 57.84 kB +1.27% +722 B 🔺
CDN Bundle 32.14 kB +1.5% +474 B 🔺
CDN Bundle (incl. Tracing) 53.09 kB +0.92% +480 B 🔺
CDN Bundle (incl. Logs, Metrics) 34.34 kB +1.4% +471 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) 55.04 kB +0.89% +484 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) 75.29 kB +0.67% +499 B 🔺
CDN Bundle (incl. Tracing, Replay) 90.75 kB +0.53% +476 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 92.69 kB +0.52% +479 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) 96.89 kB +0.49% +470 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 98.86 kB +0.5% +486 B 🔺
CDN Bundle - uncompressed 94.44 kB +1.24% +1.15 kB 🔺
CDN Bundle (incl. Tracing) - uncompressed 157.29 kB +0.74% +1.15 kB 🔺
CDN Bundle (incl. Logs, Metrics) - uncompressed 100.99 kB +1.16% +1.15 kB 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 163.26 kB +0.72% +1.15 kB 🔺
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 231.03 kB +0.51% +1.15 kB 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed 277.46 kB +0.42% +1.15 kB 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 283.42 kB +0.41% +1.15 kB 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 291.16 kB +0.4% +1.15 kB 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 297.11 kB +0.39% +1.15 kB 🔺
@sentry/nextjs (client) 57.91 kB +1.05% +601 B 🔺
@sentry/sveltekit (client) 53.11 kB +1.21% +634 B 🔺
@sentry/core/server 43.52 kB +4.13% +1.73 kB 🔺
@sentry/core/browser 13.81 kB - -
@sentry/node 153.29 kB +0.44% +662 B 🔺
@sentry/node/import (ESM hook with diagnostics-channel injection) 81.58 kB - -
@sentry/node - without tracing 94.9 kB +0.69% +649 B 🔺
@sentry/node - without channel injection 133.92 kB +0.47% +616 B 🔺
@sentry/aws-serverless 103.15 kB +0.7% +710 B 🔺
@sentry/cloudflare (withSentry) - minified 213.13 kB +0.96% +2.02 kB 🔺
@sentry/cloudflare (withSentry) 528.73 kB +0.94% +4.9 kB 🔺
@sentry/nextjs/cloudflare (withSentry) - minified 232.75 kB +0.88% +2.01 kB 🔺

View base workflow run

@logaretm
logaretm force-pushed the awad/tunnel-tanstackstart branch from 2202cda to 46e486c Compare October 15, 2026 15:36
@logaretm
logaretm force-pushed the awad/tunnel-tanstackstart branch 3 times, most recently from 9069686 to b589186 Compare October 15, 2026 17:15
@logaretm
logaretm force-pushed the awad/tunnel-tanstackstart branch 3 times, most recently from a5fcc1b to f2a6beb Compare October 15, 2026 18:16
@logaretm
logaretm force-pushed the awad/tunnel-tanstackstart branch 2 times, most recently from 722f4c6 to eec9ddf Compare October 15, 2026 18:28
@logaretm

Copy link
Copy Markdown
Member Author

bugbot run

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

@logaretm
logaretm force-pushed the awad/tunnel-tanstackstart branch from eec9ddf to 8d3dee9 Compare October 16, 2026 01:51
@logaretm

Copy link
Copy Markdown
Member Author

bugbot run

1 similar comment
@logaretm

Copy link
Copy Markdown
Member Author

bugbot run

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

@logaretm
logaretm force-pushed the awad/tunnel-tanstackstart branch from 8d3dee9 to 8c6c1fa Compare October 16, 2026 02:25
@logaretm
logaretm force-pushed the awad/tunnel-tanstackstart branch 2 times, most recently from 6b24a7b to 64c909f Compare October 16, 2026 14:00
@logaretm
logaretm requested review from JPeer264, Lms24 and mydea and removed request for a team October 19, 2026 20:07
@logaretm
logaretm force-pushed the awad/tunnel-tanstackstart branch from 58c71ae to cdbac44 Compare October 19, 2026 20:08

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit cdbac44. Configure here.

Comment thread packages/tanstackstart-react/test/vite/tunnelRoute.test.ts
@github-actions

Copy link
Copy Markdown
Contributor

One or more gzipped bundles increased by more than 500 bytes. If this increase is intentional, add the Accept Bundlesize Increase label to this PR to rerun and accept the check.

@github-actions

Copy link
Copy Markdown
Contributor

👋 @isaacs, @mydea, @Lms24, @JPeer264, @chargome, @s1gr1d — Please review this PR when you get a chance!

@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Optional E2E tests

All optional checks were successful. 2 successful. See the run.

Successful checks
Check Status
✅ CI: Optional E2E Tests / E2E tanstackstart-react-cloudflare Test (optional) Successful in 20s Details
✅ CI: Optional E2E Tests / E2E tanstackstart-react-sourcemaps Test (optional) Successful in 32s Details

…ntry

The Vite plugin now wraps the app's server entry, so `Sentry.init({ tunnel: true })`
works with no build option and tunnel requests are answered before Start's
request middleware and routing. Every `tunnelRoute` form is served the same way:
`true` uses the DSN-derived path instead of a random one per build, and a static
path no longer patches the generated route tree.
…equests

The tunnel handler now answers a request without one with a 411.

@isaacs isaacs left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Some modest suggestions, but this overall is good. Replacing build-time route injection with a runtime matcher in front of the server entry is a nice improvement. Several of the comments below can be addressed by updating the PR description and changelog entry.

const plugins: Plugin[] = [
makeRoutePatternPlugin(),
tunnelBasePath.plugin,
makeServerEntryTunnelPlugin(options.tunnelRoute, tunnelBasePath.getBasePath),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is always added, so every TanStack Start app that uses sentryTanstackStart() gets its server entry replaced by a generated module, with the original loaded through a ?sentry-tunnel-original query id. Before this PR, users without tunnelRoute got no change to their entry at all. The reason is valid, to support Sentry.init({ tunnel: true }) at runtime with no build option, but the cost falls on everyone:

  • Any other Vite plugin that matches the server entry by exact id (eg with transform(code, id) filters) now sees /src/server.ts?sentry-tunnel-original instead of /src/server.ts. That includes TanStack's own compiler plugins and Cloudflare's plugin. The e2e apps pass, but they cover a narrow set of setups.
  • The generated module adds a build-time dependency on TanStack internals for users who never asked for a tunnel. If TanStack changes the alias or default entry path, the wrapper silently stops applying, which is harmless. But if a future Start version wraps or transforms the entry differently, the query-id indirection is a new failure point.
  • Per request, it adds a getServedTunnel() lookup. That cost is small.

Suggestion: state the tradeoff in the PR body, because the description reads as "only routes matching tunnel requests".

We could also consider an opt-out (eg tunnelRoute: false to disable wrapping). At minimum, add an e2e or unit check that an app with no tunnel and a user src/server.ts with named exports still builds and serves the same as before, so that if TanStack changes something in a way that breaks us, we'll catch it early.

},
});
return `import entry from ${original};
import { wrapServerEntryWithSentryTunnel } from '@sentry/tanstackstart-react';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

For the default entry, the wrapper module's id is .../node_modules/@tanstack/react-start/dist/default-entry/esm/server.js. Vite resolves the bare import { wrapServerEntryWithSentryTunnel } from '@sentry/tanstackstart-react' relative to that file. @tanstack/react-start does not depend on @sentry/tanstackstart-react, so this only works because of hoisting: pnpm's default private hoist into node_modules/.pnpm/node_modules, or a flat node_modules. But in YarnPnP or pnpm with hoist=false, this can fail.

Suggestion: resolve the import from the project, not from the importer. For example, in load, call this.resolve('@sentry/tanstackstart-react', path.join(config.root, 'index.html')) (or resolve once in configResolved) and emit the resolved absolute id in the generated code. Or resolve the module from this plugin's own package.


expect(response.status()).toBe(405);
expect(response.headers()['allow']).toBe('POST');
expect(response.status()).toBe(404);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hm, #24615 added the ANY handler that returned 405 Allow: POST, because without it "a non-POST (or rather GET) request to the route would cause TSS to render a page" (running root loaders). With the wrapper, only POSTs match, so a GET to /monitor falls through to Start, which SSR-renders the not-found page. The e2e test was changed from expecting 405 to expecting 404, and the test name now describes the new behavior as intended. The PR body does not mention this reversal.

Suggestion: either keep the 405 for non-POST requests on a fixed tunnelRoute path in wrapServerEntryWithSentryTunnel (the path is known there), or state in the PR body that this reverts #24615 on purpose and why. Note that for the DSN-derived path the old behavior can't hold anyway, since the path is no longer a route, so reverting may well be the right call.

Comment on lines +116 to +130
setManagedTunnelDefaults({ tunnel: tunnelRoute, basePath });

// A class entry, like Cloudflare's `WorkerEntrypoint`, can't be spread into an object. The tunnel is then served
// by the wrapped default server entry it calls.
if (typeof entry === 'function') {
return entry;
}

return {
...entry,
// Cloudflare Workers pass `env` and `ctx` after the request, and `withSentry` needs `ctx` to flush.
fetch(request: Request, ...rest: unknown[]) {
return handleTunnelRequestIfMatched(request) ?? entry.fetch(request, ...rest);
},
};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

{ ...entry, fetch } copies only own enumerable properties. A default export that is a class instance (methods on the prototype, such as scheduled or queue) loses those methods. That is unusual for Start, but a Worker that does export default new MyHandler() would break at runtime only for the non-fetch events.

Suggestion: add a short comment that on Cloudflare the inner wrap serves the tunnel because the client is per-request. For the spread, use Object.create(entry) with a fetch override, or Object.assign(Object.create(Object.getPrototypeOf(entry)), entry, { fetch }), so prototype methods survive.

* to the app's server entry automatically, with its `tunnelRoute` and the base path Start routes on.
*
* `tunnelRoute` enables the managed tunnel on every server client the same way `Sentry.init({ tunnel })` does, unless
* that `init` already enabled one, and the tunnel is then served from the client's config like in every other SDK.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Recommend marking this @internal, and possibly exporting with a _INTERNAL_ prefix, since we're exposing some internal positional params. Or, if users should call this themselves, we could consider an options object like wrapServerEntryWithSentryTunnel(entry, { tunnelRoute, basePath })

*/
export function makeServerEntryTunnelPlugin(
tunnelRoute: TunnelRouteOptions | undefined,
getBasePath: () => string = () => '',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This default only exists for tests. It'd be better to make it required, and pass in () => '' in the tests that need it.

Suggested change
getBasePath: () => string = () => '',
getBasePath: () => string,

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TanStack Start managed tunnel

2 participants