diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 690e076ce309..b4991cf84103 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -871,6 +871,9 @@ jobs: 'sveltekit', 'generic-ts3.8', 'node-experimental-fastify-app', + 'remix-cloudflare-workers', + 'remix-cloudflare-pages', + 'remix-hydrogen', ] build-command: - false diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-pages/.eslintrc.cjs b/packages/e2e-tests/test-applications/remix-cloudflare-pages/.eslintrc.cjs new file mode 100644 index 000000000000..f2faf1470fd8 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-pages/.eslintrc.cjs @@ -0,0 +1,4 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + extends: ['@remix-run/eslint-config', '@remix-run/eslint-config/node'], +}; diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-pages/.gitignore b/packages/e2e-tests/test-applications/remix-cloudflare-pages/.gitignore new file mode 100644 index 000000000000..e630e0c4561b --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-pages/.gitignore @@ -0,0 +1,10 @@ +node_modules + +/.cache +/functions/\[\[path\]\].js +/functions/\[\[path\]\].js.map +/functions/metafile.* +/functions/version.txt +/public/build +.dev.vars +env.ts diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-pages/.npmrc b/packages/e2e-tests/test-applications/remix-cloudflare-pages/.npmrc new file mode 100644 index 000000000000..c6b3ef9b3eaa --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-pages/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://localhost:4873 +@sentry-internal:registry=http://localhost:4873 diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-pages/app/entry.client.tsx b/packages/e2e-tests/test-applications/remix-cloudflare-pages/app/entry.client.tsx new file mode 100644 index 000000000000..386994e92a13 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-pages/app/entry.client.tsx @@ -0,0 +1,50 @@ +/** + * By default, Remix will handle hydrating your app on the client for you. + * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ + * For more information, see https://remix.run/file-conventions/entry.client + */ + +import { RemixBrowser, useLocation, useMatches } from '@remix-run/react'; +import { startTransition, StrictMode, useEffect } from 'react'; +import { hydrateRoot } from 'react-dom/client'; +import * as Sentry from '@sentry/remix'; +import { env } from '../env'; + +Sentry.init({ + dsn: env.SENTRY_DSN, + integrations: [ + new Sentry.BrowserTracing({ + routingInstrumentation: Sentry.remixRouterInstrumentation(useEffect, useLocation, useMatches), + }), + new Sentry.Replay(), + ], + // Performance Monitoring + tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production! + // Session Replay + 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. + replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur. +}); + +Sentry.addGlobalEventProcessor(event => { + if ( + event.type === 'transaction' && + (event.contexts?.trace?.op === 'pageload' || event.contexts?.trace?.op === 'navigation') + ) { + const eventId = event.event_id; + if (eventId) { + window.recordedTransactions = window.recordedTransactions || []; + window.recordedTransactions.push(eventId); + } + } + + return event; +}); + +startTransition(() => { + hydrateRoot( + document, + + + , + ); +}); diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-pages/app/entry.server.tsx b/packages/e2e-tests/test-applications/remix-cloudflare-pages/app/entry.server.tsx new file mode 100644 index 000000000000..c2348037ce8d --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-pages/app/entry.server.tsx @@ -0,0 +1,50 @@ +/** + * By default, Remix will handle generating the HTTP Response for you. + * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ + * For more information, see https://remix.run/file-conventions/entry.server + */ + +import type { AppLoadContext, EntryContext, DataFunctionArgs } from '@remix-run/cloudflare'; +import { RemixServer } from '@remix-run/react'; +import isbot from 'isbot'; +import { renderToReadableStream } from 'react-dom/server'; +import * as Sentry from '@sentry/remix'; +import { env } from '../env'; + +Sentry.workerInit({ + dsn: env.SENTRY_DSN, + // Performance Monitoring + tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production! + debug: true, +}); + +export function handleError(error: unknown, { request }: DataFunctionArgs): void { + Sentry.captureRemixServerException(error, 'remix.server', request); +} + +export default async function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, + loadContext: AppLoadContext, +) { + const body = await renderToReadableStream(, { + signal: request.signal, + onError(error: unknown) { + // Log streaming rendering errors from inside the shell + console.error(error); + responseStatusCode = 500; + }, + }); + + if (isbot(request.headers.get('user-agent'))) { + await body.allReady; + } + + responseHeaders.set('Content-Type', 'text/html'); + return new Response(body, { + headers: responseHeaders, + status: responseStatusCode, + }); +} diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-pages/app/root.tsx b/packages/e2e-tests/test-applications/remix-cloudflare-pages/app/root.tsx new file mode 100644 index 000000000000..6c15b9f011d4 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-pages/app/root.tsx @@ -0,0 +1,27 @@ +import { LinksFunction } from '@remix-run/cloudflare'; +import { cssBundleHref } from '@remix-run/css-bundle'; +import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration, useLoaderData } from '@remix-run/react'; +import { withSentry } from '@sentry/remix'; + +export const links: LinksFunction = () => [...(cssBundleHref ? [{ rel: 'stylesheet', href: cssBundleHref }] : [])]; + +function App() { + return ( + + + + + + + + + + + + + + + ); +} + +export default withSentry(App); diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-pages/app/routes/_index.tsx b/packages/e2e-tests/test-applications/remix-cloudflare-pages/app/routes/_index.tsx new file mode 100644 index 000000000000..4b62c1ae3df6 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-pages/app/routes/_index.tsx @@ -0,0 +1,21 @@ +import * as Sentry from '@sentry/remix'; +import { Link, useRouteError } from '@remix-run/react'; + +export default function Index() { + return ( +
+ { + const eventId = Sentry.captureException(new Error('I am an error!')); + window.capturedExceptionId = eventId; + }} + /> + + navigate + +
+ ); +} diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-pages/app/routes/client-error.tsx b/packages/e2e-tests/test-applications/remix-cloudflare-pages/app/routes/client-error.tsx new file mode 100644 index 000000000000..d3507d6b23cc --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-pages/app/routes/client-error.tsx @@ -0,0 +1,27 @@ +import { useRouteError } from '@remix-run/react'; +import { captureRemixErrorBoundaryError } from '@sentry/remix'; +import { useState } from 'react'; + +export function ErrorBoundary() { + const error = useRouteError(); + const eventId = captureRemixErrorBoundaryError(error); + + return ( +
+ ErrorBoundary Error + {eventId} +
+ ); +} + +export default function ErrorBoundaryCapture() { + const [count, setCount] = useState(0); + + if (count > 0) { + throw new Error('Sentry React Component Error'); + } else { + setTimeout(() => setCount(count + 1), 0); + } + + return
{count}
; +} diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-pages/app/routes/navigate.tsx b/packages/e2e-tests/test-applications/remix-cloudflare-pages/app/routes/navigate.tsx new file mode 100644 index 000000000000..c7dcea798501 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-pages/app/routes/navigate.tsx @@ -0,0 +1,20 @@ +import { LoaderFunction } from '@remix-run/node'; +import { useLoaderData } from '@remix-run/react'; + +export const loader: LoaderFunction = async ({ params: { id } }) => { + if (id === '-1') { + throw new Error('Unexpected Server Error'); + } + + return null; +}; + +export default function LoaderError() { + const data = useLoaderData(); + + return ( +
+

{data && data.test ? data.test : 'Not Found'}

+
+ ); +} diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-pages/app/routes/server-error.tsx b/packages/e2e-tests/test-applications/remix-cloudflare-pages/app/routes/server-error.tsx new file mode 100644 index 000000000000..626238f52210 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-pages/app/routes/server-error.tsx @@ -0,0 +1,19 @@ +import { json } from '@remix-run/cloudflare'; +import { useLoaderData } from '@remix-run/react'; +import * as Sentry from '@sentry/remix'; + +export function loader() { + const id = Sentry.captureException(new Error('Sentry Server Error')); + + return json({ id }); +} + +export default function ServerError() { + const { id } = useLoaderData(); + + return ( +
+
{id}
+
+ ); +} diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-pages/app/routes/user.$id.tsx b/packages/e2e-tests/test-applications/remix-cloudflare-pages/app/routes/user.$id.tsx new file mode 100644 index 000000000000..13b2e0a34d1e --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-pages/app/routes/user.$id.tsx @@ -0,0 +1,3 @@ +export default function User() { + return
I am a blank page
; +} diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-pages/createEnvFile.sh b/packages/e2e-tests/test-applications/remix-cloudflare-pages/createEnvFile.sh new file mode 100755 index 000000000000..92975c1c5097 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-pages/createEnvFile.sh @@ -0,0 +1,11 @@ +# export environment variables from .env file + +# exit if any command fails +set -e + +# create environment variables file +cat >./env.ts <=18.0.0" + } +} diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-pages/playwright.config.ts b/packages/e2e-tests/test-applications/remix-cloudflare-pages/playwright.config.ts new file mode 100644 index 000000000000..7ef88f0e0f9a --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-pages/playwright.config.ts @@ -0,0 +1,57 @@ +import type { PlaywrightTestConfig } from '@playwright/test'; +import { devices } from '@playwright/test'; + +const port = 3030; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +const config: PlaywrightTestConfig = { + testDir: './tests', + /* Maximum time one test can run for. */ + timeout: 150_000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 5000, + }, + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: 0, + /* Opt out of parallel tests on CI. */ + workers: 1, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'list', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ + actionTimeout: 0, + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + // For now we only test Chrome! + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: `pnpm start --port=${port}`, + port, + }, +}; + +export default config; diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-pages/public/_headers b/packages/e2e-tests/test-applications/remix-cloudflare-pages/public/_headers new file mode 100644 index 000000000000..c5129f35cd35 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-pages/public/_headers @@ -0,0 +1,4 @@ +/favicon.ico + Cache-Control: public, max-age=3600, s-maxage=3600 +/build/* + Cache-Control: public, max-age=31536000, immutable diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-pages/public/_routes.json b/packages/e2e-tests/test-applications/remix-cloudflare-pages/public/_routes.json new file mode 100644 index 000000000000..4b57270dae98 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-pages/public/_routes.json @@ -0,0 +1,5 @@ +{ + "version": 1, + "include": ["/*"], + "exclude": ["/favicon.ico", "/build/*"] +} diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-pages/public/favicon.ico b/packages/e2e-tests/test-applications/remix-cloudflare-pages/public/favicon.ico new file mode 100644 index 000000000000..8830cf6821b3 Binary files /dev/null and b/packages/e2e-tests/test-applications/remix-cloudflare-pages/public/favicon.ico differ diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-pages/remix.config.js b/packages/e2e-tests/test-applications/remix-cloudflare-pages/remix.config.js new file mode 100644 index 000000000000..7586a1ee28a8 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-pages/remix.config.js @@ -0,0 +1,15 @@ +/** @type {import('@remix-run/dev').AppConfig} */ +export default { + ignoredRouteFiles: ['**/.*'], + server: './server.ts', + serverBuildPath: 'functions/[[path]].js', + serverConditions: ['workerd', 'worker', 'browser'], + serverDependenciesToBundle: 'all', + serverMainFields: ['browser', 'module', 'main'], + serverMinify: true, + serverModuleFormat: 'esm', + serverPlatform: 'neutral', + // appDirectory: "app", + // assetsBuildDirectory: "public/build", + // publicPath: "/build/", +}; diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-pages/remix.env.d.ts b/packages/e2e-tests/test-applications/remix-cloudflare-pages/remix.env.d.ts new file mode 100644 index 000000000000..425870ae632e --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-pages/remix.env.d.ts @@ -0,0 +1,3 @@ +/// +/// +/// diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-pages/server.ts b/packages/e2e-tests/test-applications/remix-cloudflare-pages/server.ts new file mode 100644 index 000000000000..2c9f9b805e58 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-pages/server.ts @@ -0,0 +1,13 @@ +import { logDevReady } from '@remix-run/cloudflare'; +import { createPagesFunctionHandler } from '@remix-run/cloudflare-pages'; +import * as build from '@remix-run/dev/server-build'; + +if (process.env.NODE_ENV === 'development') { + logDevReady(build); +} + +export const onRequest = createPagesFunctionHandler({ + build, + getLoadContext: context => ({ env: context.env }), + mode: build.mode, +}); diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-pages/tests/behaviour-client.test.ts b/packages/e2e-tests/test-applications/remix-cloudflare-pages/tests/behaviour-client.test.ts new file mode 100644 index 000000000000..8e2f78a1fb67 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-pages/tests/behaviour-client.test.ts @@ -0,0 +1,208 @@ +import { test, expect } from '@playwright/test'; +import axios, { AxiosError } from 'axios'; + +const EVENT_POLLING_TIMEOUT = 60_000; + +const authToken = process.env.E2E_TEST_AUTH_TOKEN; +const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; +const sentryTestProject = process.env.E2E_TEST_SENTRY_TEST_PROJECT; + +test('Sends a client-side exception to Sentry', async ({ page }) => { + await page.goto('/'); + + const exceptionButton = page.locator('id=exception-button'); + await exceptionButton.click(); + + const exceptionIdHandle = await page.waitForFunction(() => window.capturedExceptionId); + const exceptionEventId = await exceptionIdHandle.jsonValue(); + + console.log(`Polling for error eventId: ${exceptionEventId}`); + + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${exceptionEventId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); +}); + +test('Sends a pageload transaction to Sentry', async ({ page }) => { + await page.goto('/'); + + const recordedTransactionsHandle = await page.waitForFunction(() => { + if (window.recordedTransactions && window.recordedTransactions?.length >= 1) { + return window.recordedTransactions; + } else { + return undefined; + } + }); + const recordedTransactionEventIds = await recordedTransactionsHandle.jsonValue(); + + if (recordedTransactionEventIds === undefined) { + throw new Error("Application didn't record any transaction event IDs."); + } + + let hadPageLoadTransaction = false; + + console.log(`Polling for transaction eventIds: ${JSON.stringify(recordedTransactionEventIds)}`); + + await Promise.all( + recordedTransactionEventIds.map(async transactionEventId => { + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + + if (response.data.contexts.trace.op === 'pageload') { + hadPageLoadTransaction = true; + } + + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); + }), + ); + + expect(hadPageLoadTransaction).toBe(true); +}); + +test('Sends a navigation transaction to Sentry', async ({ page }) => { + await page.goto('/'); + + // Give pageload transaction time to finish + await page.waitForTimeout(4000); + + const linkElement = page.locator('id=navigation'); + await linkElement.click(); + + const recordedTransactionsHandle = await page.waitForFunction(() => { + if (window.recordedTransactions && window.recordedTransactions?.length >= 2) { + return window.recordedTransactions; + } else { + return undefined; + } + }); + const recordedTransactionEventIds = await recordedTransactionsHandle.jsonValue(); + + if (recordedTransactionEventIds === undefined) { + throw new Error("Application didn't record any transaction event IDs."); + } + + let hadPageNavigationTransaction = false; + + console.log(`Polling for transaction eventIds: ${JSON.stringify(recordedTransactionEventIds)}`); + + await Promise.all( + recordedTransactionEventIds.map(async transactionEventId => { + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + + if (response.data.contexts.trace.op === 'navigation') { + hadPageNavigationTransaction = true; + } + + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); + }), + ); + + expect(hadPageNavigationTransaction).toBe(true); +}); + +test('Sends a client-side ErrorBoundary exception to Sentry', async ({ page }) => { + await page.goto('/client-error'); + + const exceptionIdHandle = await page.waitForSelector('#event-id'); + const exceptionEventId = await exceptionIdHandle.textContent(); + + console.log(`Polling for error eventId: ${exceptionEventId}`); + + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${exceptionEventId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); +}); diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-pages/tests/behaviour-server.test.ts b/packages/e2e-tests/test-applications/remix-cloudflare-pages/tests/behaviour-server.test.ts new file mode 100644 index 000000000000..7b0ff8a67374 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-pages/tests/behaviour-server.test.ts @@ -0,0 +1,44 @@ +import { test, expect } from '@playwright/test'; +import axios, { AxiosError } from 'axios'; + +const EVENT_POLLING_TIMEOUT = 60_000; + +const authToken = process.env.E2E_TEST_AUTH_TOKEN; +const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; +const sentryTestProject = process.env.E2E_TEST_SENTRY_TEST_PROJECT; + +test('Sends a server-side captured error to Sentry', async ({ page }) => { + await page.goto('/server-error'); + + const exceptionIdHandle = await page.waitForSelector('#event-id'); + const exceptionEventId = await exceptionIdHandle.textContent(); + + console.log(`Polling for error eventId: ${exceptionEventId}`); + + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${exceptionEventId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); +}); diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-pages/tsconfig.json b/packages/e2e-tests/test-applications/remix-cloudflare-pages/tsconfig.json new file mode 100644 index 000000000000..cfd5a95578e0 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-pages/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "target": "ES2022", + "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + "skipLibCheck": true, + + // Remix takes care of building everything in `remix build`. + "noEmit": true + } +} diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-workers/.dev.vars b/packages/e2e-tests/test-applications/remix-cloudflare-workers/.dev.vars new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-workers/.eslintrc.cjs b/packages/e2e-tests/test-applications/remix-cloudflare-workers/.eslintrc.cjs new file mode 100644 index 000000000000..f2faf1470fd8 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-workers/.eslintrc.cjs @@ -0,0 +1,4 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + extends: ['@remix-run/eslint-config', '@remix-run/eslint-config/node'], +}; diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-workers/.gitignore b/packages/e2e-tests/test-applications/remix-cloudflare-workers/.gitignore new file mode 100644 index 000000000000..0a2591659ac2 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-workers/.gitignore @@ -0,0 +1,8 @@ +node_modules + +/.cache +/.wrangler +/build +/public/build +.env +env.ts diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-workers/.npmrc b/packages/e2e-tests/test-applications/remix-cloudflare-workers/.npmrc new file mode 100644 index 000000000000..c6b3ef9b3eaa --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-workers/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://localhost:4873 +@sentry-internal:registry=http://localhost:4873 diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-workers/app/entry.client.tsx b/packages/e2e-tests/test-applications/remix-cloudflare-workers/app/entry.client.tsx new file mode 100644 index 000000000000..386994e92a13 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-workers/app/entry.client.tsx @@ -0,0 +1,50 @@ +/** + * By default, Remix will handle hydrating your app on the client for you. + * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ + * For more information, see https://remix.run/file-conventions/entry.client + */ + +import { RemixBrowser, useLocation, useMatches } from '@remix-run/react'; +import { startTransition, StrictMode, useEffect } from 'react'; +import { hydrateRoot } from 'react-dom/client'; +import * as Sentry from '@sentry/remix'; +import { env } from '../env'; + +Sentry.init({ + dsn: env.SENTRY_DSN, + integrations: [ + new Sentry.BrowserTracing({ + routingInstrumentation: Sentry.remixRouterInstrumentation(useEffect, useLocation, useMatches), + }), + new Sentry.Replay(), + ], + // Performance Monitoring + tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production! + // Session Replay + 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. + replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur. +}); + +Sentry.addGlobalEventProcessor(event => { + if ( + event.type === 'transaction' && + (event.contexts?.trace?.op === 'pageload' || event.contexts?.trace?.op === 'navigation') + ) { + const eventId = event.event_id; + if (eventId) { + window.recordedTransactions = window.recordedTransactions || []; + window.recordedTransactions.push(eventId); + } + } + + return event; +}); + +startTransition(() => { + hydrateRoot( + document, + + + , + ); +}); diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-workers/app/entry.server.tsx b/packages/e2e-tests/test-applications/remix-cloudflare-workers/app/entry.server.tsx new file mode 100644 index 000000000000..e1ee1aa7fbdf --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-workers/app/entry.server.tsx @@ -0,0 +1,49 @@ +/** + * By default, Remix will handle generating the HTTP Response for you. + * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ + * For more information, see https://remix.run/file-conventions/entry.server + */ + +import type { AppLoadContext, EntryContext, DataFunctionArgs } from '@remix-run/cloudflare'; +import { RemixServer } from '@remix-run/react'; +import isbot from 'isbot'; +import { renderToReadableStream } from 'react-dom/server'; +import * as Sentry from '@sentry/remix'; +import { env } from '../env'; + +Sentry.workerInit({ + dsn: env.SENTRY_DSN, + // Performance Monitoring + tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production! +}); + +export function handleError(error: unknown, { request }: DataFunctionArgs): void { + Sentry.captureRemixServerException(error, 'remix.server', request); +} + +export default async function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, + loadContext: AppLoadContext, +) { + const body = await renderToReadableStream(, { + signal: request.signal, + onError(error: unknown) { + // Log streaming rendering errors from inside the shell + console.error(error); + responseStatusCode = 500; + }, + }); + + if (isbot(request.headers.get('user-agent'))) { + await body.allReady; + } + + responseHeaders.set('Content-Type', 'text/html'); + return new Response(body, { + headers: responseHeaders, + status: responseStatusCode, + }); +} diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-workers/app/root.tsx b/packages/e2e-tests/test-applications/remix-cloudflare-workers/app/root.tsx new file mode 100644 index 000000000000..6c15b9f011d4 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-workers/app/root.tsx @@ -0,0 +1,27 @@ +import { LinksFunction } from '@remix-run/cloudflare'; +import { cssBundleHref } from '@remix-run/css-bundle'; +import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration, useLoaderData } from '@remix-run/react'; +import { withSentry } from '@sentry/remix'; + +export const links: LinksFunction = () => [...(cssBundleHref ? [{ rel: 'stylesheet', href: cssBundleHref }] : [])]; + +function App() { + return ( + + + + + + + + + + + + + + + ); +} + +export default withSentry(App); diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-workers/app/routes/_index.tsx b/packages/e2e-tests/test-applications/remix-cloudflare-workers/app/routes/_index.tsx new file mode 100644 index 000000000000..4b62c1ae3df6 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-workers/app/routes/_index.tsx @@ -0,0 +1,21 @@ +import * as Sentry from '@sentry/remix'; +import { Link, useRouteError } from '@remix-run/react'; + +export default function Index() { + return ( +
+ { + const eventId = Sentry.captureException(new Error('I am an error!')); + window.capturedExceptionId = eventId; + }} + /> + + navigate + +
+ ); +} diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-workers/app/routes/client-error.tsx b/packages/e2e-tests/test-applications/remix-cloudflare-workers/app/routes/client-error.tsx new file mode 100644 index 000000000000..d3507d6b23cc --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-workers/app/routes/client-error.tsx @@ -0,0 +1,27 @@ +import { useRouteError } from '@remix-run/react'; +import { captureRemixErrorBoundaryError } from '@sentry/remix'; +import { useState } from 'react'; + +export function ErrorBoundary() { + const error = useRouteError(); + const eventId = captureRemixErrorBoundaryError(error); + + return ( +
+ ErrorBoundary Error + {eventId} +
+ ); +} + +export default function ErrorBoundaryCapture() { + const [count, setCount] = useState(0); + + if (count > 0) { + throw new Error('Sentry React Component Error'); + } else { + setTimeout(() => setCount(count + 1), 0); + } + + return
{count}
; +} diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-workers/app/routes/navigate.tsx b/packages/e2e-tests/test-applications/remix-cloudflare-workers/app/routes/navigate.tsx new file mode 100644 index 000000000000..c7dcea798501 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-workers/app/routes/navigate.tsx @@ -0,0 +1,20 @@ +import { LoaderFunction } from '@remix-run/node'; +import { useLoaderData } from '@remix-run/react'; + +export const loader: LoaderFunction = async ({ params: { id } }) => { + if (id === '-1') { + throw new Error('Unexpected Server Error'); + } + + return null; +}; + +export default function LoaderError() { + const data = useLoaderData(); + + return ( +
+

{data && data.test ? data.test : 'Not Found'}

+
+ ); +} diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-workers/app/routes/server-error.tsx b/packages/e2e-tests/test-applications/remix-cloudflare-workers/app/routes/server-error.tsx new file mode 100644 index 000000000000..626238f52210 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-workers/app/routes/server-error.tsx @@ -0,0 +1,19 @@ +import { json } from '@remix-run/cloudflare'; +import { useLoaderData } from '@remix-run/react'; +import * as Sentry from '@sentry/remix'; + +export function loader() { + const id = Sentry.captureException(new Error('Sentry Server Error')); + + return json({ id }); +} + +export default function ServerError() { + const { id } = useLoaderData(); + + return ( +
+
{id}
+
+ ); +} diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-workers/app/routes/user.$id.tsx b/packages/e2e-tests/test-applications/remix-cloudflare-workers/app/routes/user.$id.tsx new file mode 100644 index 000000000000..13b2e0a34d1e --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-workers/app/routes/user.$id.tsx @@ -0,0 +1,3 @@ +export default function User() { + return
I am a blank page
; +} diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-workers/createEnvFile.sh b/packages/e2e-tests/test-applications/remix-cloudflare-workers/createEnvFile.sh new file mode 100755 index 000000000000..92975c1c5097 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-workers/createEnvFile.sh @@ -0,0 +1,11 @@ +# export environment variables from .env file + +# exit if any command fails +set -e + +# create environment variables file +cat >./env.ts <=18.0.0" + } +} diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-workers/playwright.config.ts b/packages/e2e-tests/test-applications/remix-cloudflare-workers/playwright.config.ts new file mode 100644 index 000000000000..7ef88f0e0f9a --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-workers/playwright.config.ts @@ -0,0 +1,57 @@ +import type { PlaywrightTestConfig } from '@playwright/test'; +import { devices } from '@playwright/test'; + +const port = 3030; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +const config: PlaywrightTestConfig = { + testDir: './tests', + /* Maximum time one test can run for. */ + timeout: 150_000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 5000, + }, + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: 0, + /* Opt out of parallel tests on CI. */ + workers: 1, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'list', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ + actionTimeout: 0, + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + // For now we only test Chrome! + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: `pnpm start --port=${port}`, + port, + }, +}; + +export default config; diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-workers/public/favicon.ico b/packages/e2e-tests/test-applications/remix-cloudflare-workers/public/favicon.ico new file mode 100644 index 000000000000..8830cf6821b3 Binary files /dev/null and b/packages/e2e-tests/test-applications/remix-cloudflare-workers/public/favicon.ico differ diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-workers/remix.config.js b/packages/e2e-tests/test-applications/remix-cloudflare-workers/remix.config.js new file mode 100644 index 000000000000..e2aee7ab8de7 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-workers/remix.config.js @@ -0,0 +1,18 @@ +/** @type {import('@remix-run/dev').AppConfig} */ +export default { + ignoredRouteFiles: ['**/.*'], + server: './server.ts', + serverConditions: ['workerd', 'worker', 'browser'], + serverDependenciesToBundle: [ + // bundle everything except the virtual module for the static content manifest provided by wrangler + /^(?!.*\b__STATIC_CONTENT_MANIFEST\b).*$/, + ], + serverMainFields: ['browser', 'module', 'main'], + serverMinify: true, + serverModuleFormat: 'esm', + serverPlatform: 'neutral', + // appDirectory: "app", + // assetsBuildDirectory: "public/build", + // publicPath: "/build/", + // serverBuildPath: "build/index.js", +}; diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-workers/remix.env.d.ts b/packages/e2e-tests/test-applications/remix-cloudflare-workers/remix.env.d.ts new file mode 100644 index 000000000000..27875ac7cd55 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-workers/remix.env.d.ts @@ -0,0 +1,8 @@ +/// +/// +/// + +declare module '__STATIC_CONTENT_MANIFEST' { + const manifest: string; + export default manifest; +} diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-workers/server.ts b/packages/e2e-tests/test-applications/remix-cloudflare-workers/server.ts new file mode 100644 index 000000000000..b60209de3e3c --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-workers/server.ts @@ -0,0 +1,54 @@ +import { getAssetFromKV } from '@cloudflare/kv-asset-handler'; +import type { AppLoadContext } from '@remix-run/cloudflare'; +import { createRequestHandler, logDevReady } from '@remix-run/cloudflare'; +import * as build from '@remix-run/dev/server-build'; +import __STATIC_CONTENT_MANIFEST from '__STATIC_CONTENT_MANIFEST'; + +const MANIFEST = JSON.parse(__STATIC_CONTENT_MANIFEST); + +const handleRemixRequest = createRequestHandler(build, process.env.NODE_ENV); + +if (process.env.NODE_ENV === 'development') { + logDevReady(build); +} + +export default { + async fetch( + request: Request, + env: { + __STATIC_CONTENT: Fetcher; + }, + ctx: ExecutionContext, + ): Promise { + try { + const url = new URL(request.url); + const ttl = url.pathname.startsWith('/build/') + ? 60 * 60 * 24 * 365 // 1 year + : 60 * 5; // 5 minutes + return await getAssetFromKV( + { + request, + waitUntil: ctx.waitUntil.bind(ctx), + } as FetchEvent, + { + ASSET_NAMESPACE: env.__STATIC_CONTENT, + ASSET_MANIFEST: MANIFEST, + cacheControl: { + browserTTL: ttl, + edgeTTL: ttl, + }, + }, + ); + } catch (error) {} + + try { + const loadContext: AppLoadContext = { + env, + }; + return await handleRemixRequest(request, loadContext); + } catch (error) { + console.log(error); + return new Response('An unexpected error occurred', { status: 500 }); + } + }, +}; diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-workers/tests/behaviour-client.test.ts b/packages/e2e-tests/test-applications/remix-cloudflare-workers/tests/behaviour-client.test.ts new file mode 100644 index 000000000000..8e2f78a1fb67 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-workers/tests/behaviour-client.test.ts @@ -0,0 +1,208 @@ +import { test, expect } from '@playwright/test'; +import axios, { AxiosError } from 'axios'; + +const EVENT_POLLING_TIMEOUT = 60_000; + +const authToken = process.env.E2E_TEST_AUTH_TOKEN; +const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; +const sentryTestProject = process.env.E2E_TEST_SENTRY_TEST_PROJECT; + +test('Sends a client-side exception to Sentry', async ({ page }) => { + await page.goto('/'); + + const exceptionButton = page.locator('id=exception-button'); + await exceptionButton.click(); + + const exceptionIdHandle = await page.waitForFunction(() => window.capturedExceptionId); + const exceptionEventId = await exceptionIdHandle.jsonValue(); + + console.log(`Polling for error eventId: ${exceptionEventId}`); + + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${exceptionEventId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); +}); + +test('Sends a pageload transaction to Sentry', async ({ page }) => { + await page.goto('/'); + + const recordedTransactionsHandle = await page.waitForFunction(() => { + if (window.recordedTransactions && window.recordedTransactions?.length >= 1) { + return window.recordedTransactions; + } else { + return undefined; + } + }); + const recordedTransactionEventIds = await recordedTransactionsHandle.jsonValue(); + + if (recordedTransactionEventIds === undefined) { + throw new Error("Application didn't record any transaction event IDs."); + } + + let hadPageLoadTransaction = false; + + console.log(`Polling for transaction eventIds: ${JSON.stringify(recordedTransactionEventIds)}`); + + await Promise.all( + recordedTransactionEventIds.map(async transactionEventId => { + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + + if (response.data.contexts.trace.op === 'pageload') { + hadPageLoadTransaction = true; + } + + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); + }), + ); + + expect(hadPageLoadTransaction).toBe(true); +}); + +test('Sends a navigation transaction to Sentry', async ({ page }) => { + await page.goto('/'); + + // Give pageload transaction time to finish + await page.waitForTimeout(4000); + + const linkElement = page.locator('id=navigation'); + await linkElement.click(); + + const recordedTransactionsHandle = await page.waitForFunction(() => { + if (window.recordedTransactions && window.recordedTransactions?.length >= 2) { + return window.recordedTransactions; + } else { + return undefined; + } + }); + const recordedTransactionEventIds = await recordedTransactionsHandle.jsonValue(); + + if (recordedTransactionEventIds === undefined) { + throw new Error("Application didn't record any transaction event IDs."); + } + + let hadPageNavigationTransaction = false; + + console.log(`Polling for transaction eventIds: ${JSON.stringify(recordedTransactionEventIds)}`); + + await Promise.all( + recordedTransactionEventIds.map(async transactionEventId => { + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + + if (response.data.contexts.trace.op === 'navigation') { + hadPageNavigationTransaction = true; + } + + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); + }), + ); + + expect(hadPageNavigationTransaction).toBe(true); +}); + +test('Sends a client-side ErrorBoundary exception to Sentry', async ({ page }) => { + await page.goto('/client-error'); + + const exceptionIdHandle = await page.waitForSelector('#event-id'); + const exceptionEventId = await exceptionIdHandle.textContent(); + + console.log(`Polling for error eventId: ${exceptionEventId}`); + + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${exceptionEventId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); +}); diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-workers/tests/behaviour-server.test.ts b/packages/e2e-tests/test-applications/remix-cloudflare-workers/tests/behaviour-server.test.ts new file mode 100644 index 000000000000..7b0ff8a67374 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-workers/tests/behaviour-server.test.ts @@ -0,0 +1,44 @@ +import { test, expect } from '@playwright/test'; +import axios, { AxiosError } from 'axios'; + +const EVENT_POLLING_TIMEOUT = 60_000; + +const authToken = process.env.E2E_TEST_AUTH_TOKEN; +const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; +const sentryTestProject = process.env.E2E_TEST_SENTRY_TEST_PROJECT; + +test('Sends a server-side captured error to Sentry', async ({ page }) => { + await page.goto('/server-error'); + + const exceptionIdHandle = await page.waitForSelector('#event-id'); + const exceptionEventId = await exceptionIdHandle.textContent(); + + console.log(`Polling for error eventId: ${exceptionEventId}`); + + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${exceptionEventId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); +}); diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-workers/tsconfig.json b/packages/e2e-tests/test-applications/remix-cloudflare-workers/tsconfig.json new file mode 100644 index 000000000000..28cce918b89a --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-workers/tsconfig.json @@ -0,0 +1,22 @@ +{ + "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "target": "ES2022", + "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + + // Remix takes care of building everything in `remix build`. + "noEmit": true + } +} diff --git a/packages/e2e-tests/test-applications/remix-cloudflare-workers/wrangler.toml b/packages/e2e-tests/test-applications/remix-cloudflare-workers/wrangler.toml new file mode 100644 index 000000000000..e52f6c2e546b --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-cloudflare-workers/wrangler.toml @@ -0,0 +1,12 @@ +name = "remix-cloudflare-workers" + +workers_dev = true +main = "./build/index.js" +# https://developers.cloudflare.com/workers/platform/compatibility-dates +compatibility_date = "2023-04-20" + +[site] + bucket = "./public" + +[build] + command = "npm run build" diff --git a/packages/e2e-tests/test-applications/remix-hydrogen/.env b/packages/e2e-tests/test-applications/remix-hydrogen/.env new file mode 100644 index 000000000000..70c3c48cf0a2 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-hydrogen/.env @@ -0,0 +1,4 @@ +# These variables are only available locally in MiniOxygen + +SESSION_SECRET="foobar" +PUBLIC_STORE_DOMAIN="mock.shop" diff --git a/packages/e2e-tests/test-applications/remix-hydrogen/.eslintignore b/packages/e2e-tests/test-applications/remix-hydrogen/.eslintignore new file mode 100644 index 000000000000..a362bcaa13b5 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-hydrogen/.eslintignore @@ -0,0 +1,5 @@ +build +node_modules +bin +*.d.ts +dist diff --git a/packages/e2e-tests/test-applications/remix-hydrogen/.eslintrc.js b/packages/e2e-tests/test-applications/remix-hydrogen/.eslintrc.js new file mode 100644 index 000000000000..b596477cb67e --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-hydrogen/.eslintrc.js @@ -0,0 +1,6 @@ +/** + * @type {import("@types/eslint").Linter.BaseConfig} + */ +module.exports = { + extends: ['plugin:hydrogen/recommended', 'plugin:hydrogen/typescript'], +}; diff --git a/packages/e2e-tests/test-applications/remix-hydrogen/.gitignore b/packages/e2e-tests/test-applications/remix-hydrogen/.gitignore new file mode 100644 index 000000000000..458e91254b96 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-hydrogen/.gitignore @@ -0,0 +1,9 @@ +node_modules +/.cache +/build +/dist +/public/build +/.mf +!.env +.shopify +env.ts diff --git a/packages/e2e-tests/test-applications/remix-hydrogen/.graphqlrc.yml b/packages/e2e-tests/test-applications/remix-hydrogen/.graphqlrc.yml new file mode 100644 index 000000000000..bd38d076bc54 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-hydrogen/.graphqlrc.yml @@ -0,0 +1 @@ +schema: node_modules/@shopify/hydrogen-react/storefront.schema.json diff --git a/packages/e2e-tests/test-applications/remix-hydrogen/.npmrc b/packages/e2e-tests/test-applications/remix-hydrogen/.npmrc new file mode 100644 index 000000000000..fa32c08341a9 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-hydrogen/.npmrc @@ -0,0 +1,4 @@ +@shopify:registry=https://registry.npmjs.com +progress=false +@sentry:registry=http://localhost:4873 +@sentry-internal:registry=http://localhost:4873 diff --git a/packages/e2e-tests/test-applications/remix-hydrogen/README.md b/packages/e2e-tests/test-applications/remix-hydrogen/README.md new file mode 100644 index 000000000000..510b0c778f16 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-hydrogen/README.md @@ -0,0 +1,44 @@ +# Hydrogen template: Hello World + +Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/), +Shopify’s full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get +started with Hydrogen. + +[Check out Hydrogen docs](https://shopify.dev/custom-storefronts/hydrogen) +[Get familiar with Remix](https://remix.run/docs/en/v1) + +## What's included + +- Remix +- Hydrogen +- Oxygen +- Shopify CLI +- ESLint +- Prettier +- GraphQL generator +- TypeScript and JavaScript flavors +- Minimal setup of components and routes + +## Getting started + +**Requirements:** + +- Node.js version 16.14.0 or higher + +```bash +npm create @shopify/hydrogen@latest -- --template hello-world +``` + +Remember to update `.env` with your shop's domain and Storefront API token! + +## Building for production + +```bash +npm run build +``` + +## Local development + +```bash +npm run dev +``` diff --git a/packages/e2e-tests/test-applications/remix-hydrogen/app/entry.client.tsx b/packages/e2e-tests/test-applications/remix-hydrogen/app/entry.client.tsx new file mode 100644 index 000000000000..edeaea46b77c --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-hydrogen/app/entry.client.tsx @@ -0,0 +1,44 @@ +import { RemixBrowser, useLocation, useMatches } from '@remix-run/react'; +import { startTransition, StrictMode, useEffect } from 'react'; +import { hydrateRoot } from 'react-dom/client'; +import * as Sentry from '@sentry/remix'; +import { env } from '../env'; + +Sentry.init({ + dsn: env.SENTRY_DSN, + integrations: [ + new Sentry.BrowserTracing({ + routingInstrumentation: Sentry.remixRouterInstrumentation(useEffect, useLocation, useMatches), + }), + new Sentry.Replay(), + ], + // Performance Monitoring + tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production! + // Session Replay + 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. + replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur. + debug: true, +}); + +Sentry.addGlobalEventProcessor(event => { + if ( + event.type === 'transaction' && + (event.contexts?.trace?.op === 'pageload' || event.contexts?.trace?.op === 'navigation') + ) { + const eventId = event.event_id; + if (eventId) { + window.recordedTransactions = window.recordedTransactions || []; + window.recordedTransactions.push(eventId); + } + } + + return event; +}); +startTransition(() => { + hydrateRoot( + document, + + + , + ); +}); diff --git a/packages/e2e-tests/test-applications/remix-hydrogen/app/entry.server.tsx b/packages/e2e-tests/test-applications/remix-hydrogen/app/entry.server.tsx new file mode 100644 index 000000000000..b4be52fdee6d --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-hydrogen/app/entry.server.tsx @@ -0,0 +1,52 @@ +import type { DataFunctionArgs, EntryContext } from '@shopify/remix-oxygen'; +import { RemixServer } from '@remix-run/react'; +import isbot from 'isbot'; +import { renderToReadableStream } from 'react-dom/server'; +import { createContentSecurityPolicy } from '@shopify/hydrogen'; +import * as Sentry from '@sentry/remix'; +import { env } from '../env'; + +Sentry.workerInit({ + dsn: env.SENTRY_DSN, + // Performance Monitoring + tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production! +}); + +export function handleError(error: unknown, { request }: DataFunctionArgs): void { + Sentry.captureRemixServerException(error, 'remix.server', request); +} + +export default async function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, +) { + const { nonce, header, NonceProvider } = createContentSecurityPolicy(); + + const body = await renderToReadableStream( + + + , + { + nonce, + signal: request.signal, + onError(error) { + // eslint-disable-next-line no-console + console.error(error); + responseStatusCode = 500; + }, + }, + ); + + if (isbot(request.headers.get('user-agent'))) { + await body.allReady; + } + + responseHeaders.set('Content-Type', 'text/html'); + responseHeaders.set('Content-Security-Policy', header); + return new Response(body, { + headers: responseHeaders, + status: responseStatusCode, + }); +} diff --git a/packages/e2e-tests/test-applications/remix-hydrogen/app/root.tsx b/packages/e2e-tests/test-applications/remix-hydrogen/app/root.tsx new file mode 100644 index 000000000000..e8db2579e5ce --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-hydrogen/app/root.tsx @@ -0,0 +1,103 @@ +import { type LinksFunction, type LoaderArgs } from '@shopify/remix-oxygen'; +import { + Links, + Meta, + Outlet, + Scripts, + LiveReload, + ScrollRestoration, + useLoaderData, + type ShouldRevalidateFunction, + useRouteError, +} from '@remix-run/react'; +import type { Shop } from '@shopify/hydrogen/storefront-api-types'; +import appStyles from './styles/app.css'; +import favicon from '../public/favicon.svg'; +import { useNonce } from '@shopify/hydrogen'; +import * as Sentry from '@sentry/remix'; + +// This is important to avoid re-fetching root queries on sub-navigations +export const shouldRevalidate: ShouldRevalidateFunction = ({ formMethod, currentUrl, nextUrl }) => { + // revalidate when a mutation is performed e.g add to cart, login... + if (formMethod && formMethod !== 'GET') { + return true; + } + + // revalidate when manually revalidating via useRevalidator + if (currentUrl.toString() === nextUrl.toString()) { + return true; + } + + return false; +}; + +export const links: LinksFunction = () => { + return [ + { rel: 'stylesheet', href: appStyles }, + { + rel: 'preconnect', + href: 'https://cdn.shopify.com', + }, + { + rel: 'preconnect', + href: 'https://shop.app', + }, + { rel: 'icon', type: 'image/svg+xml', href: favicon }, + ]; +}; + +export async function loader({ context }: LoaderArgs) { + const layout = await context.storefront.query<{ shop: Shop }>(LAYOUT_QUERY); + return { + layout, + }; +} + +export function ErrorBoundary() { + const error = useRouteError(); + const eventId = Sentry.captureRemixErrorBoundaryError(error); + + return ( +
+ ErrorBoundary Error + {eventId} +
+ ); +} + +function App() { + const nonce = useNonce(); + const data = useLoaderData(); + + const { name } = data.layout.shop; + + return ( + + + + + + + + +

Hello, {name}

+

This is a custom storefront powered by Hydrogen

+ + + + + + + ); +} + +export default Sentry.withSentry(App); + +const LAYOUT_QUERY = `#graphql + query layout { + shop { + name + description + } + } +`; diff --git a/packages/e2e-tests/test-applications/remix-hydrogen/app/routes/_index.tsx b/packages/e2e-tests/test-applications/remix-hydrogen/app/routes/_index.tsx new file mode 100644 index 000000000000..c3fcc989f49b --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-hydrogen/app/routes/_index.tsx @@ -0,0 +1,21 @@ +import * as Sentry from '@sentry/remix'; +import { Link } from '@remix-run/react'; + +export default function Index() { + return ( +
+ { + const eventId = Sentry.captureException(new Error('I am an error!')); + window.capturedExceptionId = eventId; + }} + /> + + navigate + +
+ ); +} diff --git a/packages/e2e-tests/test-applications/remix-hydrogen/app/routes/client-error.tsx b/packages/e2e-tests/test-applications/remix-hydrogen/app/routes/client-error.tsx new file mode 100644 index 000000000000..d452ac8ae62f --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-hydrogen/app/routes/client-error.tsx @@ -0,0 +1,15 @@ +import { useEffect, useState } from 'react'; + +export default function ErrorBoundaryCapture() { + const [count, setCount] = useState(0); + + useEffect(() => { + if (count > 0) { + throw new Error('Sentry React Component Error'); + } else { + setTimeout(() => setCount(count + 1), 10); + } + }, [count]); + + return
{count}
; +} diff --git a/packages/e2e-tests/test-applications/remix-hydrogen/app/routes/navigate.tsx b/packages/e2e-tests/test-applications/remix-hydrogen/app/routes/navigate.tsx new file mode 100644 index 000000000000..c7dcea798501 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-hydrogen/app/routes/navigate.tsx @@ -0,0 +1,20 @@ +import { LoaderFunction } from '@remix-run/node'; +import { useLoaderData } from '@remix-run/react'; + +export const loader: LoaderFunction = async ({ params: { id } }) => { + if (id === '-1') { + throw new Error('Unexpected Server Error'); + } + + return null; +}; + +export default function LoaderError() { + const data = useLoaderData(); + + return ( +
+

{data && data.test ? data.test : 'Not Found'}

+
+ ); +} diff --git a/packages/e2e-tests/test-applications/remix-hydrogen/app/routes/server-error.tsx b/packages/e2e-tests/test-applications/remix-hydrogen/app/routes/server-error.tsx new file mode 100644 index 000000000000..719ebb3da821 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-hydrogen/app/routes/server-error.tsx @@ -0,0 +1,19 @@ +import { json } from '@shopify/remix-oxygen'; +import { useLoaderData } from '@remix-run/react'; +import * as Sentry from '@sentry/remix'; + +export function loader() { + const id = Sentry.captureException(new Error('Sentry Server Error')); + + return json({ id }); +} + +export default function ServerError() { + const { id } = useLoaderData(); + + return ( +
+
{id}
+
+ ); +} diff --git a/packages/e2e-tests/test-applications/remix-hydrogen/app/routes/user.$id.tsx b/packages/e2e-tests/test-applications/remix-hydrogen/app/routes/user.$id.tsx new file mode 100644 index 000000000000..13b2e0a34d1e --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-hydrogen/app/routes/user.$id.tsx @@ -0,0 +1,3 @@ +export default function User() { + return
I am a blank page
; +} diff --git a/packages/e2e-tests/test-applications/remix-hydrogen/app/styles/app.css b/packages/e2e-tests/test-applications/remix-hydrogen/app/styles/app.css new file mode 100644 index 000000000000..5051f135703c --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-hydrogen/app/styles/app.css @@ -0,0 +1,31 @@ +body { + margin: 0; + background: #ffffff; + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', + 'Helvetica Neue', sans-serif; + padding: 20px; +} + +h1, +h2, +p { + margin: 0; + padding: 0; +} + +h1 { + font-size: 3rem; + font-weight: 700; + line-height: 1.4; +} + +h2 { + font-size: 1.2rem; + font-weight: 700; + line-height: 1.4; +} + +p { + font-size: 1rem; + line-height: 1.4; +} diff --git a/packages/e2e-tests/test-applications/remix-hydrogen/createEnvFile.sh b/packages/e2e-tests/test-applications/remix-hydrogen/createEnvFile.sh new file mode 100755 index 000000000000..92975c1c5097 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-hydrogen/createEnvFile.sh @@ -0,0 +1,11 @@ +# export environment variables from .env file + +# exit if any command fails +set -e + +# create environment variables file +cat >./env.ts <=16.13" + } +} diff --git a/packages/e2e-tests/test-applications/remix-hydrogen/playwright.config.ts b/packages/e2e-tests/test-applications/remix-hydrogen/playwright.config.ts new file mode 100644 index 000000000000..54c7fb9523b5 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-hydrogen/playwright.config.ts @@ -0,0 +1,58 @@ +import type { PlaywrightTestConfig } from '@playwright/test'; +import { devices } from '@playwright/test'; + +const port = 3030; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +const config: PlaywrightTestConfig = { + testDir: './tests', + /* Maximum time one test can run for. */ + timeout: 150_000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 5000, + }, + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: 0, + /* Opt out of parallel tests on CI. */ + workers: 1, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'list', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ + actionTimeout: 0, + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + bypassCSP: true, + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + // For now we only test Chrome! + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: `pnpm preview --port=${port}`, + port, + }, +}; + +export default config; diff --git a/packages/e2e-tests/test-applications/remix-hydrogen/public/favicon.svg b/packages/e2e-tests/test-applications/remix-hydrogen/public/favicon.svg new file mode 100644 index 000000000000..f6c649733d68 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-hydrogen/public/favicon.svg @@ -0,0 +1,28 @@ + + + + + diff --git a/packages/e2e-tests/test-applications/remix-hydrogen/remix.config.js b/packages/e2e-tests/test-applications/remix-hydrogen/remix.config.js new file mode 100644 index 000000000000..7b35d83f9230 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-hydrogen/remix.config.js @@ -0,0 +1,27 @@ +/** @type {import('@remix-run/dev').AppConfig} */ +module.exports = { + appDirectory: 'app', + ignoredRouteFiles: ['**/.*'], + watchPaths: ['./public', './.env'], + server: './server.ts', + /** + * The following settings are required to deploy Hydrogen apps to Oxygen: + */ + publicPath: (process.env.HYDROGEN_ASSET_BASE_URL ?? '/') + 'build/', + assetsBuildDirectory: 'dist/client/build', + serverBuildPath: 'dist/worker/index.js', + serverMainFields: ['browser', 'module', 'main'], + serverConditions: ['worker', process.env.NODE_ENV], + serverDependenciesToBundle: 'all', + serverModuleFormat: 'esm', + serverPlatform: 'neutral', + serverMinify: process.env.NODE_ENV === 'production', + future: { + v2_dev: true, + v2_meta: true, + v2_headers: true, + v2_errorBoundary: true, + v2_routeConvention: true, + v2_normalizeFormMethod: true, + }, +}; diff --git a/packages/e2e-tests/test-applications/remix-hydrogen/remix.env.d.ts b/packages/e2e-tests/test-applications/remix-hydrogen/remix.env.d.ts new file mode 100644 index 000000000000..8ca8b5e33610 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-hydrogen/remix.env.d.ts @@ -0,0 +1,39 @@ +/// +/// +/// + +// Enhance TypeScript's built-in typings. +import '@total-typescript/ts-reset'; + +import type { Storefront } from '@shopify/hydrogen'; +import type { HydrogenSession } from './server'; + +declare global { + /** + * A global `process` object is only available during build to access NODE_ENV. + */ + const process: { env: { NODE_ENV: 'production' | 'development' } }; + + /** + * Declare expected Env parameter in fetch handler. + */ + interface Env { + SESSION_SECRET: string; + PUBLIC_STOREFRONT_API_TOKEN: string; + PRIVATE_STOREFRONT_API_TOKEN: string; + PUBLIC_STORE_DOMAIN: string; + PUBLIC_STOREFRONT_ID: string; + } +} + +/** + * Declare local additions to `AppLoadContext` to include the session utilities we injected in `server.ts`. + */ +declare module '@shopify/remix-oxygen' { + export interface AppLoadContext { + env: Env; + storefront: Storefront; + session: HydrogenSession; + waitUntil: ExecutionContext['waitUntil']; + } +} diff --git a/packages/e2e-tests/test-applications/remix-hydrogen/server.ts b/packages/e2e-tests/test-applications/remix-hydrogen/server.ts new file mode 100644 index 000000000000..ce0faa1e3f58 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-hydrogen/server.ts @@ -0,0 +1,126 @@ +// Virtual entry point for the app +import * as remixBuild from '@remix-run/dev/server-build'; +import { createStorefrontClient, storefrontRedirect } from '@shopify/hydrogen'; +import { + createRequestHandler, + getStorefrontHeaders, + createCookieSessionStorage, + type SessionStorage, + type Session, +} from '@shopify/remix-oxygen'; + +/** + * Export a fetch handler in module format. + */ +export default { + async fetch(request: Request, env: Env, executionContext: ExecutionContext): Promise { + try { + /** + * Open a cache instance in the worker and a custom session instance. + */ + if (!env?.SESSION_SECRET) { + throw new Error('SESSION_SECRET environment variable is not set'); + } + + const waitUntil = (p: Promise) => executionContext.waitUntil(p); + const [cache, session] = await Promise.all([ + caches.open('hydrogen'), + HydrogenSession.init(request, [env.SESSION_SECRET]), + ]); + + /** + * Create Hydrogen's Storefront client. + */ + const { storefront } = createStorefrontClient({ + cache, + waitUntil, + i18n: { language: 'EN', country: 'US' }, + publicStorefrontToken: env.PUBLIC_STOREFRONT_API_TOKEN, + privateStorefrontToken: env.PRIVATE_STOREFRONT_API_TOKEN, + storeDomain: env.PUBLIC_STORE_DOMAIN, + storefrontId: env.PUBLIC_STOREFRONT_ID, + storefrontHeaders: getStorefrontHeaders(request), + }); + + /** + * Create a Remix request handler and pass + * Hydrogen's Storefront client to the loader context. + */ + const handleRequest = createRequestHandler({ + build: remixBuild, + mode: process.env.NODE_ENV, + getLoadContext: () => ({ session, storefront, env, waitUntil }), + }); + + const response = await handleRequest(request); + + if (response.status === 404) { + /** + * Check for redirects only when there's a 404 from the app. + * If the redirect doesn't exist, then `storefrontRedirect` + * will pass through the 404 response. + */ + return storefrontRedirect({ request, response, storefront }); + } + + return response; + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + return new Response('An unexpected error occurred', { status: 500 }); + } + }, +}; + +/** + * This is a custom session implementation for your Hydrogen shop. + * Feel free to customize it to your needs, add helper methods, or + * swap out the cookie-based implementation with something else! + */ +export class HydrogenSession { + constructor(private sessionStorage: SessionStorage, private session: Session) {} + + static async init(request: Request, secrets: string[]) { + const storage = createCookieSessionStorage({ + cookie: { + name: 'session', + httpOnly: true, + path: '/', + sameSite: 'lax', + secrets, + }, + }); + + const session = await storage.getSession(request.headers.get('Cookie')); + + return new this(storage, session); + } + + has(key: string) { + return this.session.has(key); + } + + get(key: string) { + return this.session.get(key); + } + + destroy() { + return this.sessionStorage.destroySession(this.session); + } + + flash(key: string, value: any) { + this.session.flash(key, value); + } + + unset(key: string) { + this.session.unset(key); + } + + set(key: string, value: any) { + this.session.set(key, value); + } + + commit() { + return this.sessionStorage.commitSession(this.session); + } +} diff --git a/packages/e2e-tests/test-applications/remix-hydrogen/tests/behaviour-client.test.ts b/packages/e2e-tests/test-applications/remix-hydrogen/tests/behaviour-client.test.ts new file mode 100644 index 000000000000..8e2f78a1fb67 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-hydrogen/tests/behaviour-client.test.ts @@ -0,0 +1,208 @@ +import { test, expect } from '@playwright/test'; +import axios, { AxiosError } from 'axios'; + +const EVENT_POLLING_TIMEOUT = 60_000; + +const authToken = process.env.E2E_TEST_AUTH_TOKEN; +const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; +const sentryTestProject = process.env.E2E_TEST_SENTRY_TEST_PROJECT; + +test('Sends a client-side exception to Sentry', async ({ page }) => { + await page.goto('/'); + + const exceptionButton = page.locator('id=exception-button'); + await exceptionButton.click(); + + const exceptionIdHandle = await page.waitForFunction(() => window.capturedExceptionId); + const exceptionEventId = await exceptionIdHandle.jsonValue(); + + console.log(`Polling for error eventId: ${exceptionEventId}`); + + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${exceptionEventId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); +}); + +test('Sends a pageload transaction to Sentry', async ({ page }) => { + await page.goto('/'); + + const recordedTransactionsHandle = await page.waitForFunction(() => { + if (window.recordedTransactions && window.recordedTransactions?.length >= 1) { + return window.recordedTransactions; + } else { + return undefined; + } + }); + const recordedTransactionEventIds = await recordedTransactionsHandle.jsonValue(); + + if (recordedTransactionEventIds === undefined) { + throw new Error("Application didn't record any transaction event IDs."); + } + + let hadPageLoadTransaction = false; + + console.log(`Polling for transaction eventIds: ${JSON.stringify(recordedTransactionEventIds)}`); + + await Promise.all( + recordedTransactionEventIds.map(async transactionEventId => { + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + + if (response.data.contexts.trace.op === 'pageload') { + hadPageLoadTransaction = true; + } + + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); + }), + ); + + expect(hadPageLoadTransaction).toBe(true); +}); + +test('Sends a navigation transaction to Sentry', async ({ page }) => { + await page.goto('/'); + + // Give pageload transaction time to finish + await page.waitForTimeout(4000); + + const linkElement = page.locator('id=navigation'); + await linkElement.click(); + + const recordedTransactionsHandle = await page.waitForFunction(() => { + if (window.recordedTransactions && window.recordedTransactions?.length >= 2) { + return window.recordedTransactions; + } else { + return undefined; + } + }); + const recordedTransactionEventIds = await recordedTransactionsHandle.jsonValue(); + + if (recordedTransactionEventIds === undefined) { + throw new Error("Application didn't record any transaction event IDs."); + } + + let hadPageNavigationTransaction = false; + + console.log(`Polling for transaction eventIds: ${JSON.stringify(recordedTransactionEventIds)}`); + + await Promise.all( + recordedTransactionEventIds.map(async transactionEventId => { + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + + if (response.data.contexts.trace.op === 'navigation') { + hadPageNavigationTransaction = true; + } + + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); + }), + ); + + expect(hadPageNavigationTransaction).toBe(true); +}); + +test('Sends a client-side ErrorBoundary exception to Sentry', async ({ page }) => { + await page.goto('/client-error'); + + const exceptionIdHandle = await page.waitForSelector('#event-id'); + const exceptionEventId = await exceptionIdHandle.textContent(); + + console.log(`Polling for error eventId: ${exceptionEventId}`); + + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${exceptionEventId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); +}); diff --git a/packages/e2e-tests/test-applications/remix-hydrogen/tests/behaviour-server.test.ts b/packages/e2e-tests/test-applications/remix-hydrogen/tests/behaviour-server.test.ts new file mode 100644 index 000000000000..7b0ff8a67374 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-hydrogen/tests/behaviour-server.test.ts @@ -0,0 +1,44 @@ +import { test, expect } from '@playwright/test'; +import axios, { AxiosError } from 'axios'; + +const EVENT_POLLING_TIMEOUT = 60_000; + +const authToken = process.env.E2E_TEST_AUTH_TOKEN; +const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; +const sentryTestProject = process.env.E2E_TEST_SENTRY_TEST_PROJECT; + +test('Sends a server-side captured error to Sentry', async ({ page }) => { + await page.goto('/server-error'); + + const exceptionIdHandle = await page.waitForSelector('#event-id'); + const exceptionEventId = await exceptionIdHandle.textContent(); + + console.log(`Polling for error eventId: ${exceptionEventId}`); + + await expect + .poll( + async () => { + try { + const response = await axios.get( + `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${exceptionEventId}/`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + return response.status; + } catch (e) { + if (e instanceof AxiosError && e.response) { + if (e.response.status !== 404) { + throw e; + } else { + return e.response.status; + } + } else { + throw e; + } + } + }, + { + timeout: EVENT_POLLING_TIMEOUT, + }, + ) + .toBe(200); +}); diff --git a/packages/e2e-tests/test-applications/remix-hydrogen/tsconfig.json b/packages/e2e-tests/test-applications/remix-hydrogen/tsconfig.json new file mode 100644 index 000000000000..dcd7c7237a90 --- /dev/null +++ b/packages/e2e-tests/test-applications/remix-hydrogen/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["./**/*.d.ts", "./**/*.ts", "./**/*.tsx"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "module": "ES2022", + "target": "ES2022", + "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "baseUrl": ".", + "types": ["@shopify/oxygen-workers-types"], + "paths": { + "~/*": ["app/*"] + }, + "noEmit": true + } +} diff --git a/packages/remix/package.json b/packages/remix/package.json index 19eec703b3e9..32ca8615a2be 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -34,11 +34,13 @@ "@sentry/types": "7.81.1", "@sentry/utils": "7.81.1", "glob": "^10.3.4", + "is-ip": "^3.1.0", + "tslib": "^2.4.1 || ^1.9.3", "yargs": "^17.6.0" }, "devDependencies": { - "@remix-run/node": "^1.4.3", - "@remix-run/react": "^1.4.3", + "@remix-run/node": "^1.19.3", + "@remix-run/react": "^1.19.3", "@types/express": "^4.17.14" }, "peerDependencies": { diff --git a/packages/remix/src/client/errors.tsx b/packages/remix/src/client/errors.tsx index a6afefbc0ef7..a36d0050777d 100644 --- a/packages/remix/src/client/errors.tsx +++ b/packages/remix/src/client/errors.tsx @@ -1,6 +1,7 @@ import { captureException } from '@sentry/core'; -import { isNodeEnv, isString } from '@sentry/utils'; +import { isString } from '@sentry/utils'; +import { isBrowser } from '../utils/futureFlags'; import { isRouteErrorResponse } from '../utils/vendor/response'; import type { ErrorResponse } from '../utils/vendor/types'; @@ -12,7 +13,7 @@ import type { ErrorResponse } from '../utils/vendor/types'; */ export function captureRemixErrorBoundaryError(error: unknown): string | undefined { let eventId: string | undefined; - const isClientSideRuntimeError = !isNodeEnv() && error instanceof Error; + const isClientSideRuntimeError = isBrowser() && error instanceof Error; const isRemixErrorResponse = isRouteErrorResponse(error); // Server-side errors apart from `ErrorResponse`s also appear here without their stacktraces. // So, we only capture: diff --git a/packages/remix/src/client/performance.tsx b/packages/remix/src/client/performance.tsx index 97c455a80a94..6fa4e4393a42 100644 --- a/packages/remix/src/client/performance.tsx +++ b/packages/remix/src/client/performance.tsx @@ -1,10 +1,10 @@ import type { ErrorBoundaryProps } from '@sentry/react'; import { WINDOW, withErrorBoundary } from '@sentry/react'; import type { Transaction, TransactionContext } from '@sentry/types'; -import { isNodeEnv, logger } from '@sentry/utils'; +import { logger } from '@sentry/utils'; import * as React from 'react'; -import { getFutureFlagsBrowser, readRemixVersionFromLoader } from '../utils/futureFlags'; +import { getFutureFlagsBrowser, isBrowser, readRemixVersionFromLoader } from '../utils/futureFlags'; const DEFAULT_TAGS = { 'routing.instrumentation': 'remix-router', @@ -109,7 +109,7 @@ export function withSentry

, R extends React.Co // Early return when any of the required functions is not available. if (!_useEffect || !_useLocation || !_useMatches || !_customStartTransaction) { __DEBUG_BUILD__ && - !isNodeEnv() && + isBrowser() && logger.warn('Remix SDK was unable to wrap your root because of one or more missing parameters.'); // @ts-expect-error Setting more specific React Component typing for `R` generic above diff --git a/packages/remix/src/index.client.tsx b/packages/remix/src/index.client.tsx index 64951a3f10cd..fa9f596f3db6 100644 --- a/packages/remix/src/index.client.tsx +++ b/packages/remix/src/index.client.tsx @@ -1,11 +1,25 @@ /* eslint-disable import/export */ -import { configureScope, init as reactInit } from '@sentry/react'; +import { init as reactInit } from '@sentry/react'; import { buildMetadata } from './utils/metadata'; import type { RemixOptions } from './utils/remixOptions'; export { remixRouterInstrumentation, withSentry } from './client/performance'; export { captureRemixErrorBoundaryError } from './client/errors'; export * from '@sentry/react'; +import type { ServerRuntimeClientOptions } from '@sentry/core'; +import { configureScope, getCurrentHub, getIntegrationsToSetup, initAndBind, ServerRuntimeClient } from '@sentry/core'; +import { createStackParser, logger, nodeStackLineParser, stackParserFromStackParserOptions } from '@sentry/utils'; + +import { makeWorkerTransport } from './worker/transport'; + +export { captureRemixServerException } from './utils/instrumentServer'; +export { ErrorBoundary, withErrorBoundary } from '@sentry/react'; +const nodeStackParser = createStackParser(nodeStackLineParser()); + +function sdkAlreadyInitialized(): boolean { + const hub = getCurrentHub(); + return !!hub.getClient(); +} export function init(options: RemixOptions): void { buildMetadata(options, ['remix', 'react']); @@ -17,3 +31,27 @@ export function init(options: RemixOptions): void { scope.setTag('runtime', 'browser'); }); } + +/** Initializes Sentry Remix SDK on Worker Environments. */ +export function workerInit(options: RemixOptions): void { + buildMetadata(options, ['remix', 'worker']); + + if (sdkAlreadyInitialized()) { + __DEBUG_BUILD__ && logger.log('SDK already initialized'); + + return; + } + + const clientOptions: ServerRuntimeClientOptions = { + ...options, + stackParser: stackParserFromStackParserOptions(options.stackParser || nodeStackParser), + integrations: getIntegrationsToSetup(options), + transport: options.transport || makeWorkerTransport, + }; + + initAndBind(ServerRuntimeClient, clientOptions); + + configureScope(scope => { + scope.setTag('runtime', 'worker'); + }); +} diff --git a/packages/remix/src/utils/futureFlags.ts b/packages/remix/src/utils/futureFlags.ts index 03d03270a73e..91afbf658ea3 100644 --- a/packages/remix/src/utils/futureFlags.ts +++ b/packages/remix/src/utils/futureFlags.ts @@ -1,4 +1,4 @@ -import { GLOBAL_OBJ } from '@sentry/utils'; +import { GLOBAL_OBJ, isNodeEnv } from '@sentry/utils'; import type { FutureConfig, ServerBuild } from './vendor/types'; @@ -65,3 +65,15 @@ export function readRemixVersionFromLoader(): number | undefined { return window.__remixContext?.state?.loaderData?.root?.remixVersion; } + +/** + * Check if we are in the browser + * Checking the existence of document instead of window + * See:https://remix.run/docs/en/1.19.3/pages/gotchas#typeof-window-checks + * + * @returns True if we are in the browser + */ +export function isBrowser(): boolean { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions, no-restricted-globals + return typeof document !== 'undefined' && !isNodeEnv(); +} diff --git a/packages/remix/src/utils/instrumentServer.ts b/packages/remix/src/utils/instrumentServer.ts index 935e24124b49..c027fd4986f6 100644 --- a/packages/remix/src/utils/instrumentServer.ts +++ b/packages/remix/src/utils/instrumentServer.ts @@ -1,19 +1,23 @@ /* eslint-disable max-lines */ -import { getActiveTransaction, hasTracingEnabled, runWithAsyncContext } from '@sentry/core'; -import type { Hub } from '@sentry/node'; -import { captureException, getCurrentHub } from '@sentry/node'; +import type { Hub } from '@sentry/core'; +import { + captureException, + getActiveTransaction, + getCurrentHub, + hasTracingEnabled, + runWithAsyncContext, +} from '@sentry/core'; import type { Transaction, TransactionSource, WrappedFunction } from '@sentry/types'; import { addExceptionMechanism, dynamicSamplingContextToSentryBaggageHeader, fill, - isNodeEnv, loadModule, logger, tracingContextFromHeaders, } from '@sentry/utils'; -import { getFutureFlagsServer, getRemixVersionFromBuild } from './futureFlags'; +import { getFutureFlagsServer, getRemixVersionFromBuild, isBrowser } from './futureFlags'; import { extractData, getRequestMatch, @@ -236,7 +240,7 @@ function getTraceAndBaggage(): { sentryTrace?: string; sentryBaggage?: string } const transaction = getActiveTransaction(); const currentScope = getCurrentHub().getScope(); - if (isNodeEnv() && hasTracingEnabled()) { + if (!isBrowser() && hasTracingEnabled()) { const span = currentScope.getSpan(); if (span && transaction) { diff --git a/packages/remix/src/utils/vendor/getIpAddress.ts b/packages/remix/src/utils/vendor/getIpAddress.ts index d63e31779aac..52fef0db1f7f 100644 --- a/packages/remix/src/utils/vendor/getIpAddress.ts +++ b/packages/remix/src/utils/vendor/getIpAddress.ts @@ -23,8 +23,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import { isIP } from 'net'; - +import isIP from 'is-ip'; /** * Get the IP address of the client sending a request. * diff --git a/packages/remix/src/utils/vendor/types.ts b/packages/remix/src/utils/vendor/types.ts index faaa7e5f6f60..da5c2a19b595 100644 --- a/packages/remix/src/utils/vendor/types.ts +++ b/packages/remix/src/utils/vendor/types.ts @@ -202,7 +202,7 @@ export interface DataFunction { } export interface ReactRouterDomPkg { - matchRoutes: (routes: ServerRoute[], pathname: string) => RouteMatch[] | null; + matchRoutes: (routes: any[], pathname: string) => RouteMatch[] | null; } // Taken from Remix Implementation diff --git a/packages/remix/src/worker/transport.ts b/packages/remix/src/worker/transport.ts new file mode 100644 index 000000000000..810a828214d3 --- /dev/null +++ b/packages/remix/src/worker/transport.ts @@ -0,0 +1,41 @@ +import { createTransport } from '@sentry/core'; +import type { BaseTransportOptions, Transport, TransportMakeRequestResponse, TransportRequest } from '@sentry/types'; + +export type WorkersTransportOptions = BaseTransportOptions & { + headers?: Record; + context?: Record; + fetcher?: typeof fetch; +}; + +/** + * Creates a Transport that uses the Cloudflare Workers' or Shopify Oxygen's fetch API to send events to Sentry. + */ +export function makeWorkerTransport(options: WorkersTransportOptions): Transport { + function makeRequest(request: TransportRequest): PromiseLike { + const requestOptions: RequestInit = { + body: request.body, + method: 'POST', + headers: options.headers, + }; + + const fetchRequest = fetch(options.url, requestOptions).then(response => { + return { + statusCode: response.status, + headers: { + 'x-sentry-rate-limits': response.headers.get('X-Sentry-Rate-Limits'), + 'retry-after': response.headers.get('Retry-After'), + }, + }; + }); + + // If we're in a Cloudflare Worker, wait for the fetch to complete + // before returning. This ensures that the Worker doesn't shut down + if (options.context && options.context.waitUntil) { + options.context.waitUntil(fetchRequest); + } + + return fetchRequest; + } + + return createTransport(options, makeRequest); +} diff --git a/yarn.lock b/yarn.lock index d32acb0b89ae..d1e6f43f1900 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4740,85 +4740,91 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== -"@remix-run/node@^1.4.3": - version "1.5.1" - resolved "https://registry.yarnpkg.com/@remix-run/node/-/node-1.5.1.tgz#1c367d4035baaef8f0ea66962a826456d62f0030" - integrity sha512-yl4bd1nl7MiJp4tI3+4ygObeMU3txM4Uo09IdHLRa4NMdBQnacUJ47kqCahny01MerC2JL2d9NPjdVPwRCRZvQ== - dependencies: - "@remix-run/server-runtime" "1.5.1" - "@remix-run/web-fetch" "^4.1.3" - "@remix-run/web-file" "^3.0.2" - "@remix-run/web-stream" "^1.0.3" +"@remix-run/node@^1.19.3": + version "1.19.3" + resolved "https://registry.yarnpkg.com/@remix-run/node/-/node-1.19.3.tgz#d27e2f742fc45379525cb3fca466a883ca06d6c9" + integrity sha512-z5qrVL65xLXIUpU4mkR4MKlMeKARLepgHAk4W5YY3IBXOreRqOGUC70POViYmY7x38c2Ia1NwqL80H+0h7jbMw== + dependencies: + "@remix-run/server-runtime" "1.19.3" + "@remix-run/web-fetch" "^4.3.6" + "@remix-run/web-file" "^3.0.3" + "@remix-run/web-stream" "^1.0.4" "@web3-storage/multipart-parser" "^1.0.0" abort-controller "^3.0.0" cookie-signature "^1.1.0" source-map-support "^0.5.21" stream-slice "^0.1.2" -"@remix-run/react@^1.4.3": - version "1.5.1" - resolved "https://registry.yarnpkg.com/@remix-run/react/-/react-1.5.1.tgz#372e5e80f3f10a638b0567c4e03307dfb0a28dc0" - integrity sha512-p4t6tC/WyPeLW7DO4g7ZSyH9EpWO37c4wD2np3rDwtv3WtsTZ70bU/+NOWE9nv74mH8i1C50eJ3/OR+8Ll8UbA== +"@remix-run/react@^1.19.3": + version "1.19.3" + resolved "https://registry.yarnpkg.com/@remix-run/react/-/react-1.19.3.tgz#00efcc583bf05b434566e56381d51df86575d8b0" + integrity sha512-iP37MZ+oG1n4kv4rX77pKT/knra51lNwKo5tinPPF0SuNJhF3+XjWo5nwEjvisKTXLZ/OHeicinhgX2JHHdDvA== dependencies: - history "^5.3.0" - react-router-dom "^6.2.2" + "@remix-run/router" "1.7.2" + react-router-dom "6.14.2" "@remix-run/router@1.0.2": version "1.0.2" resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.0.2.tgz#1c17eadb2fa77f80a796ad5ea9bf108e6993ef06" integrity sha512-GRSOFhJzjGN+d4sKHTMSvNeUPoZiDHWmRnXfzaxrqe7dE/Nzlc8BiMSJdLDESZlndM7jIUrZ/F4yWqVYlI0rwQ== -"@remix-run/server-runtime@1.5.1": - version "1.5.1" - resolved "https://registry.yarnpkg.com/@remix-run/server-runtime/-/server-runtime-1.5.1.tgz#5272b01e6dce109dc10bd68447ceae2d039315b2" - integrity sha512-FQbCCdW+qzE3wpoCwUKdwcL8yZVYNPiyHS9JS/6r6qmd/yvZfbj44E48wEQ6trbWE2TUiEh/EQqNMyrZWEs4bw== +"@remix-run/router@1.7.2": + version "1.7.2" + resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.7.2.tgz#cba1cf0a04bc04cb66027c51fa600e9cbc388bc8" + integrity sha512-7Lcn7IqGMV+vizMPoEl5F0XDshcdDYtMI6uJLQdQz5CfZAwy3vvGKYSUk789qndt5dEC4HfSjviSYlSoHGL2+A== + +"@remix-run/server-runtime@1.19.3": + version "1.19.3" + resolved "https://registry.yarnpkg.com/@remix-run/server-runtime/-/server-runtime-1.19.3.tgz#206b55337c266c5bc254878f8ff3cd5677cc60fb" + integrity sha512-KzQ+htUsKqpBgKE2tWo7kIIGy3MyHP58Io/itUPvV+weDjApwr9tQr9PZDPA3yAY6rAzLax7BU0NMSYCXWFY5A== dependencies: - "@types/cookie" "^0.4.0" + "@remix-run/router" "1.7.2" + "@types/cookie" "^0.4.1" "@web3-storage/multipart-parser" "^1.0.0" cookie "^0.4.1" - jsesc "^3.0.1" - react-router-dom "^6.2.2" set-cookie-parser "^2.4.8" source-map "^0.7.3" -"@remix-run/web-blob@^3.0.3", "@remix-run/web-blob@^3.0.4": - version "3.0.4" - resolved "https://registry.yarnpkg.com/@remix-run/web-blob/-/web-blob-3.0.4.tgz#99c67b9d0fb641bd0c07d267fd218ae5aa4ae5ed" - integrity sha512-AfegzZvSSDc+LwnXV+SwROTrDtoLiPxeFW+jxgvtDAnkuCX1rrzmVJ6CzqZ1Ai0bVfmJadkG5GxtAfYclpPmgw== +"@remix-run/web-blob@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@remix-run/web-blob/-/web-blob-3.1.0.tgz#e0c669934c1eb6028960047e57a13ed38bbfb434" + integrity sha512-owGzFLbqPH9PlKb8KvpNJ0NO74HWE2euAn61eEiyCXX/oteoVzTVSN8mpLgDjaxBf2btj5/nUllSUgpyd6IH6g== dependencies: - "@remix-run/web-stream" "^1.0.0" + "@remix-run/web-stream" "^1.1.0" web-encoding "1.1.5" -"@remix-run/web-fetch@^4.1.3": - version "4.1.3" - resolved "https://registry.yarnpkg.com/@remix-run/web-fetch/-/web-fetch-4.1.3.tgz#8ad3077c1b5bd9fe2a8813d0ad3c84970a495c04" - integrity sha512-D3KXAEkzhR248mu7wCHReQrMrIo3Y9pDDa7TrlISnsOEvqkfWkJJF+PQWmOIKpOSHAhDg7TCb2tzvW8lc/MfHw== +"@remix-run/web-fetch@^4.3.6": + version "4.4.1" + resolved "https://registry.yarnpkg.com/@remix-run/web-fetch/-/web-fetch-4.4.1.tgz#1ea34e6f1c660a52e7582007917a552f0efdc58b" + integrity sha512-xMceEGn2kvfeWS91nHSOhEQHPGgjFnmDVpWFZrbWPVdiTByMZIn421/tdSF6Kd1RsNsY+5Iwt3JFEKZHAcMQHw== dependencies: - "@remix-run/web-blob" "^3.0.4" - "@remix-run/web-form-data" "^3.0.2" - "@remix-run/web-stream" "^1.0.3" + "@remix-run/web-blob" "^3.1.0" + "@remix-run/web-file" "^3.1.0" + "@remix-run/web-form-data" "^3.1.0" + "@remix-run/web-stream" "^1.1.0" "@web3-storage/multipart-parser" "^1.0.0" + abort-controller "^3.0.0" data-uri-to-buffer "^3.0.1" mrmime "^1.0.0" -"@remix-run/web-file@^3.0.2": - version "3.0.2" - resolved "https://registry.yarnpkg.com/@remix-run/web-file/-/web-file-3.0.2.tgz#1a6cc0900a1310ede4bc96abad77ac6eb27a2131" - integrity sha512-eFC93Onh/rZ5kUNpCQersmBtxedGpaXK2/gsUl49BYSGK/DvuPu3l06vmquEDdcPaEuXcsdGP0L7zrmUqrqo4A== +"@remix-run/web-file@^3.0.3", "@remix-run/web-file@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@remix-run/web-file/-/web-file-3.1.0.tgz#07219021a2910e90231bc30ca1ce693d0e9d3825" + integrity sha512-dW2MNGwoiEYhlspOAXFBasmLeYshyAyhIdrlXBi06Duex5tDr3ut2LFKVj7tyHLmn8nnNwFf1BjNbkQpygC2aQ== dependencies: - "@remix-run/web-blob" "^3.0.3" + "@remix-run/web-blob" "^3.1.0" -"@remix-run/web-form-data@^3.0.2": - version "3.0.2" - resolved "https://registry.yarnpkg.com/@remix-run/web-form-data/-/web-form-data-3.0.2.tgz#733a4c8f8176523b7b60a8bd0dc6704fd4d498f3" - integrity sha512-F8tm3iB1sPxMpysK6Js7lV3gvLfTNKGmIW38t/e6dtPEB5L1WdbRG1cmLyhsonFc7rT1x1JKdz+2jCtoSdnIUw== +"@remix-run/web-form-data@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@remix-run/web-form-data/-/web-form-data-3.1.0.tgz#47f9ad8ce8bf1c39ed83eab31e53967fe8e3df6a" + integrity sha512-NdeohLMdrb+pHxMQ/Geuzdp0eqPbea+Ieo8M8Jx2lGC6TBHsgHzYcBvr0LyPdPVycNRDEpWpiDdCOdCryo3f9A== dependencies: web-encoding "1.1.5" -"@remix-run/web-stream@^1.0.0", "@remix-run/web-stream@^1.0.3": - version "1.0.3" - resolved "https://registry.yarnpkg.com/@remix-run/web-stream/-/web-stream-1.0.3.tgz#3284a6a45675d1455c4d9c8f31b89225c9006438" - integrity sha512-wlezlJaA5NF6SsNMiwQnnAW6tnPzQ5I8qk0Y0pSohm0eHKa2FQ1QhEKLVVcDDu02TmkfHgnux0igNfeYhDOXiA== +"@remix-run/web-stream@^1.0.4", "@remix-run/web-stream@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@remix-run/web-stream/-/web-stream-1.1.0.tgz#b93a8f806c2c22204930837c44d81fdedfde079f" + integrity sha512-KRJtwrjRV5Bb+pM7zxcTJkhIqWWSy+MYsIxHK+0m5atcznsf15YwUBWHWulZerV2+vvHH1Lp1DD7pw6qKW8SgA== dependencies: web-streams-polyfill "^3.1.1" @@ -5559,7 +5565,7 @@ resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.5.2.tgz#9bf9d62c838c85a07c92fdf2334c2c14fd9c59a9" integrity sha512-DBpRoJGKJZn7RY92dPrgoMew8xCWc2P71beqsjyhEI/Ds9mOyVmBwtekyfhpwFIVt1WrxTonFifiOZ62V8CnNA== -"@types/cookie@^0.4.0", "@types/cookie@^0.4.1": +"@types/cookie@^0.4.1": version "0.4.1" resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.1.tgz#bfd02c1f2224567676c1545199f87c3a861d878d" integrity sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q== @@ -16806,7 +16812,7 @@ history@^4.6.0, history@^4.9.0: tiny-warning "^1.0.0" value-equal "^1.0.1" -history@^5.2.0, history@^5.3.0: +history@^5.2.0: version "5.3.0" resolved "https://registry.yarnpkg.com/history/-/history-5.3.0.tgz#1548abaa245ba47992f063a0783db91ef201c73b" integrity sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ== @@ -17551,6 +17557,11 @@ ip-regex@^2.1.0: resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9" integrity sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk= +ip-regex@^4.0.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-4.3.0.tgz#687275ab0f57fa76978ff8f4dddc8a23d5990db5" + integrity sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q== + ip@1.1.5, ip@^1.1.0, ip@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a" @@ -17852,6 +17863,13 @@ is-interactive@^2.0.0: resolved "https://registry.yarnpkg.com/is-interactive/-/is-interactive-2.0.0.tgz#40c57614593826da1100ade6059778d597f16e90" integrity sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ== +is-ip@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/is-ip/-/is-ip-3.1.0.tgz#2ae5ddfafaf05cb8008a62093cf29734f657c5d8" + integrity sha512-35vd5necO7IitFPjd/YBeqwWnyDWbuLH9ZXQdMfDA8TEo7pv5X8yfrvVO3xbJbLUlERCMvf6X0hTUamQxCYJ9Q== + dependencies: + ip-regex "^4.0.0" + is-lambda@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/is-lambda/-/is-lambda-1.0.1.tgz#3d9877899e6a53efc0160504cde15f82e6f061d5" @@ -19010,11 +19028,6 @@ jsesc@^2.5.1: resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== -jsesc@^3.0.1: - version "3.0.2" - resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.0.2.tgz#bb8b09a6597ba426425f2e4a07245c3d00b9343e" - integrity sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g== - jsesc@~0.3.x: version "0.3.0" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.3.0.tgz#1bf5ee63b4539fe2e26d0c1e99c240b97a457972" @@ -25725,21 +25738,27 @@ react-refresh@0.8.3: dependencies: "@remix-run/router" "1.0.2" -"react-router-6@npm:react-router@6.3.0", react-router@6.3.0: - name react-router-6 +"react-router-6@npm:react-router@6.3.0": version "6.3.0" resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.3.0.tgz#3970cc64b4cb4eae0c1ea5203a80334fdd175557" integrity sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ== dependencies: history "^5.2.0" -react-router-dom@^6.2.2: - version "6.3.0" - resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.3.0.tgz#a0216da813454e521905b5fa55e0e5176123f43d" - integrity sha512-uaJj7LKytRxZNQV8+RbzJWnJ8K2nPsOOEuX7aQstlMZKQT0164C+X2w6bnkqU3sjtLvpd5ojrezAyfZ1+0sStw== +react-router-dom@6.14.2: + version "6.14.2" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.14.2.tgz#88f520118b91aa60233bd08dbd3fdcaea3a68488" + integrity sha512-5pWX0jdKR48XFZBuJqHosX3AAHjRAzygouMTyimnBPOLdY3WjzUSKhus2FVMihUFWzeLebDgr4r8UeQFAct7Bg== dependencies: - history "^5.2.0" - react-router "6.3.0" + "@remix-run/router" "1.7.2" + react-router "6.14.2" + +react-router@6.14.2: + version "6.14.2" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.14.2.tgz#1f60994d8c369de7b8ba7a78d8f7ec23df76b300" + integrity sha512-09Zss2dE2z+T1D03IheqAFtK4UzQyX8nFPWx6jkwdYzGLXd5ie06A6ezS2fO6zJfEb/SpG6UocN2O1hfD+2urQ== + dependencies: + "@remix-run/router" "1.7.2" react@^18.0.0: version "18.0.0" @@ -29722,6 +29741,11 @@ tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3 resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.2.tgz#1b6f07185c881557b0ffa84b111a0106989e8338" integrity sha512-5svOrSA2w3iGFDs1HibEVBGbDrAY82bFQ3HZ3ixB+88nsbsWQoKqDRb5UBYAUPEzbBn6dAp5gRNXglySbx1MlA== +"tslib@^2.4.1 || ^1.9.3": + version "2.6.2" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" + integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== + tsutils@^3.17.1, tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"