diff --git a/packages/remix/package.json b/packages/remix/package.json index 2b8365f1f43f..5399dcbd29c3 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -51,6 +51,23 @@ "import": { "default": "./build/import-hook.mjs" } + }, + "./v3": { + "import": { + "types": "./build/types/v3/index.server.d.ts", + "default": "./build/esm/v3/index.server.js" + } + }, + "./v3/client": { + "import": { + "types": "./build/types/v3/index.client.d.ts", + "default": "./build/esm/v3/index.client.js" + } + }, + "./v3/node": { + "import": { + "default": "./build/v3-node.mjs" + } } }, "publishConfig": { @@ -58,6 +75,7 @@ }, "dependencies": { "@remix-run/router": "^1.23.4", + "@sentry/browser": "11.0.0", "@sentry/bundler-plugins": "11.0.0", "@sentry/conventions": "^0.24.0", "@sentry/core": "11.0.0", @@ -81,7 +99,8 @@ "@remix-run/node": "2.x", "@remix-run/react": "2.x", "@remix-run/server-runtime": "2.x", - "react": "18.x" + "react": "18.x", + "remix": ">=3.0.0-rc.1 <4" }, "scripts": { "build": "run-p build:transpile build:types", @@ -115,9 +134,27 @@ "{projectRoot}/build/cjs", "{projectRoot}/build/npm/esm", "{projectRoot}/build/npm/cjs", - "{projectRoot}/build/import-hook.mjs" + "{projectRoot}/build/import-hook.mjs", + "{projectRoot}/build/v3-node.mjs" ] } } + }, + "peerDependenciesMeta": { + "@remix-run/node": { + "optional": true + }, + "@remix-run/react": { + "optional": true + }, + "@remix-run/server-runtime": { + "optional": true + }, + "react": { + "optional": true + }, + "remix": { + "optional": true + } } } diff --git a/packages/remix/rollup.npm.config.mjs b/packages/remix/rollup.npm.config.mjs index 9bf3f04f3ee3..d6992abc3302 100644 --- a/packages/remix/rollup.npm.config.mjs +++ b/packages/remix/rollup.npm.config.mjs @@ -1,10 +1,19 @@ +import { defineConfig } from 'rollup'; import { makeBaseNPMConfig, makeNPMConfigVariants, makeOrchestrionLoader } from '@sentry-internal/rollup-utils'; +// `external: /.*/` keeps the `remix` import a runtime resolution against the installed package. +const v3NodeEntry = defineConfig({ + input: 'src/v3/node.mjs', + external: /.*/, + output: { format: 'esm', file: 'build/v3-node.mjs' }, +}); + // We rely on esbuild's defaults for JSX (`jsx: 'transform'` = classic runtime, no // __self/__source attributes). React 19 prefers the new automatic transform, but switching // to it would break React 17 support — so we intentionally stay on classic for now. // https://legacy.reactjs.org/blog/2020/09/22/introducing-the-new-jsx-transform.html export default [ + v3NodeEntry, ...makeNPMConfigVariants( makeBaseNPMConfig({ entrypoints: [ @@ -14,12 +23,17 @@ export default [ 'src/server/index.ts', 'src/cloudflare/index.ts', 'src/vite/index.ts', + 'src/v3/index.server.ts', + 'src/v3/index.client.ts', ], packageSpecificConfig: { external: ['react-router', 'react-router-dom', 'react', 'react/jsx-runtime'], output: { // make it so Rollup calms down about the fact that we're combining default and named exports exports: 'named', + // Without this, Rollup infers a common root from the whole module graph, which here reaches + // into sibling workspace packages, and the v3 entries land outside `build/esm/v3/`. + preserveModulesRoot: 'src', }, }, }), diff --git a/packages/remix/src/v3/index.client.ts b/packages/remix/src/v3/index.client.ts new file mode 100644 index 000000000000..7cdc7036be5a --- /dev/null +++ b/packages/remix/src/v3/index.client.ts @@ -0,0 +1,8 @@ +// A named list rather than `export * from '@sentry/browser'`: Remix 3 has no bundler, so a wildcard +// makes every export reachable and nothing can be tree shaken out of the served module graph. +// +// `init` is withheld until the Remix 3 browser SDK lands. Re-exporting `@sentry/browser`'s would give +// an app History based tracing, which yields no navigation spans in Remix 3, so it would look +// configured while reporting nothing. +export { captureException, captureMessage } from '@sentry/browser'; +export type { BrowserOptions } from '@sentry/browser'; diff --git a/packages/remix/src/v3/index.server.ts b/packages/remix/src/v3/index.server.ts new file mode 100644 index 000000000000..107a51657aca --- /dev/null +++ b/packages/remix/src/v3/index.server.ts @@ -0,0 +1,4 @@ +// Placeholder until the Remix 3 server instrumentation lands. `@sentry/node`'s `init` already emits +// `http.server` spans, so this is useful on its own; route parameterisation and router error capture +// are what is still missing. +export * from '@sentry/node'; diff --git a/packages/remix/src/v3/node.mjs b/packages/remix/src/v3/node.mjs new file mode 100644 index 000000000000..4923bc078d97 --- /dev/null +++ b/packages/remix/src/v3/node.mjs @@ -0,0 +1,3 @@ +// Replaces `--import remix/node-tsx` rather than adding a second flag. Sentry's module hook is +// registered here once the server instrumentation lands, so for now nothing is instrumented. +await import('remix/node-tsx'); diff --git a/packages/remix/test/v3-exports.test.ts b/packages/remix/test/v3-exports.test.ts new file mode 100644 index 000000000000..96e2c033ae76 --- /dev/null +++ b/packages/remix/test/v3-exports.test.ts @@ -0,0 +1,66 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as semver from 'semver'; +import { describe, expect, it } from 'vitest'; + +import packageJson from '../package.json'; + +const packageRoot = path.resolve(__dirname, '..'); + +// `exports` mixes shapes (`"./package.json"` is a bare string), so it does not fit one index signature. +const exportsMap = packageJson.exports as unknown as Record }>; + +/** + * A mismatch between the `exports` map and the build output is invisible: the build and lint both + * pass, and the package is broken only for whoever installs it. Rollup infers its output layout from + * the module graph, so an unrelated change can relocate these files. + */ +describe('Remix 3 exports', () => { + const subpaths = ['./v3', './v3/client', './v3/node']; + + it.each(subpaths)('%s is declared in the exports map', subpath => { + expect(packageJson.exports).toHaveProperty([subpath]); + }); + + it.each(subpaths)('%s only exposes an import condition', subpath => { + // Remix 3's asset server rejects CommonJS outright, so a `require` condition could never load. + expect(Object.keys(exportsMap[subpath] ?? {})).toEqual(['import']); + }); + + it.each(subpaths)('%s points at files that exist in the build output', subpath => { + const targets = Object.values(exportsMap[subpath]?.import ?? {}); + + expect(targets.length).toBeGreaterThan(0); + + for (const target of targets) { + expect(fs.existsSync(path.join(packageRoot, target)), `${subpath} -> ${target} is missing`).toBe(true); + } + }); + + describe('the Remix 3 peer range', () => { + const range = packageJson.peerDependencies.remix; + + // Asserted by behaviour, so the range can change after Remix 3 reaches GA without failing here. + it('accepts release candidates', () => { + // `3.x` does not match `3.0.0-rc.1`: semver ranges exclude prereleases unless they name one. + expect(semver.satisfies('3.0.0-rc.1', range)).toBe(true); + expect(semver.satisfies('3.0.0-rc.2', range)).toBe(true); + }); + + it('accepts stable 3.x', () => { + expect(semver.satisfies('3.0.0', range)).toBe(true); + expect(semver.satisfies('3.4.2', range)).toBe(true); + }); + + it('rejects the next major', () => { + expect(semver.satisfies('4.0.0', range)).toBe(false); + }); + }); + + it('marks every peer dependency optional', () => { + // A Remix 3 app must not have the Remix 2 packages or React installed on its behalf. + for (const name of Object.keys(packageJson.peerDependencies)) { + expect(packageJson.peerDependenciesMeta, `${name} should be optional`).toHaveProperty([name, 'optional'], true); + } + }); +});