(null);
39 |
40 | // This form works without JavaScript, but when we have JavaScript we can make
41 | // the experience better by selecting the input on wrong answers! Go ahead, disable
42 | // JavaScript in your browser and see what happens.
43 | useEffect(() => {
44 | if (actionMessage && answerRef.current) {
45 | answerRef.current.select();
46 | }
47 | }, [actionMessage]);
48 |
49 | return (
50 |
51 |
52 | Actions!
53 |
54 | This form submission will send a post request that we handle in our
55 | `action` export. Any route can export an action to handle data
56 | mutations.
57 |
58 |
76 |
77 |
78 |
79 | Additional Resources
80 |
98 |
99 |
100 | );
101 | }
102 |
--------------------------------------------------------------------------------
/examples/v1.0.5/app/routes/demos/correct.tsx:
--------------------------------------------------------------------------------
1 | export default function NiceWork() {
2 | return You got it right! ;
3 | }
4 |
--------------------------------------------------------------------------------
/examples/v1.0.5/app/routes/demos/params.tsx:
--------------------------------------------------------------------------------
1 | import { useCatch, Link, json, useLoaderData, Outlet } from "remix";
2 |
3 | export function meta() {
4 | return { title: "Boundaries Demo" };
5 | }
6 |
7 | export default function Boundaries() {
8 | return (
9 |
10 |
11 |
12 |
13 |
14 |
15 | Click these Links
16 |
17 |
18 | Start over
19 |
20 |
21 |
22 | Param: one
23 |
24 |
25 |
26 |
27 | Param: two
28 |
29 |
30 |
31 | This will be a 404
32 |
33 |
34 | And this will be 401 Unauthorized
35 |
36 |
37 | This one will throw an error
38 |
39 |
40 |
41 |
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/examples/v1.0.5/app/routes/demos/params/$id.tsx:
--------------------------------------------------------------------------------
1 | import { useCatch, Link, json, useLoaderData } from "remix";
2 | import type { LoaderFunction, MetaFunction } from "remix";
3 |
4 | // The `$` in route filenames becomes a pattern that's parsed from the URL and
5 | // passed to your loaders so you can look up data.
6 | // - https://remix.run/api/conventions#loader-params
7 | export let loader: LoaderFunction = async ({ params }) => {
8 | // pretend like we're using params.id to look something up in the db
9 |
10 | if (params.id === "this-record-does-not-exist") {
11 | // If the record doesn't exist we can't render the route normally, so
12 | // instead we throw a 404 reponse to stop running code here and show the
13 | // user the catch boundary.
14 | throw new Response("Not Found", { status: 404 });
15 | }
16 |
17 | // now pretend like the record exists but the user just isn't authorized to
18 | // see it.
19 | if (params.id === "shh-its-a-secret") {
20 | // Again, we can't render the component if the user isn't authorized. You
21 | // can even put data in the response that might help the user rectify the
22 | // issue! Like emailing the webmaster for access to the page. (Oh, right,
23 | // `json` is just a Response helper that makes it easier to send JSON
24 | // responses).
25 | throw json({ webmasterEmail: "hello@remix.run" }, { status: 401 });
26 | }
27 |
28 | // Sometimes your code just blows up and you never anticipated it. Remix will
29 | // automatically catch it and send the UI to the error boundary.
30 | if (params.id === "kaboom") {
31 | lol();
32 | }
33 |
34 | // but otherwise the record was found, user has access, so we can do whatever
35 | // else we needed to in the loader and return the data. (This is boring, we're
36 | // just gonna return the params.id).
37 | return { param: params.id };
38 | };
39 |
40 | export default function ParamDemo() {
41 | let data = useLoaderData();
42 | return (
43 |
44 | The param is {data.param}
45 |
46 | );
47 | }
48 |
49 | // https://remix.run/api/conventions#catchboundary
50 | // https://remix.run/api/remix#usecatch
51 | // https://remix.run/api/guides/not-found
52 | export function CatchBoundary() {
53 | let caught = useCatch();
54 |
55 | let message: React.ReactNode;
56 | switch (caught.status) {
57 | case 401:
58 | message = (
59 |
60 | Looks like you tried to visit a page that you do not have access to.
61 | Maybe ask the webmaster ({caught.data.webmasterEmail}) for access.
62 |
63 | );
64 | case 404:
65 | message = (
66 | Looks like you tried to visit a page that does not exist.
67 | );
68 | default:
69 | message = (
70 |
71 | There was a problem with your request!
72 |
73 | {caught.status} {caught.statusText}
74 |
75 | );
76 | }
77 |
78 | return (
79 | <>
80 | Oops!
81 | {message}
82 |
83 | (Isn't it cool that the user gets to stay in context and try a different
84 | link in the parts of the UI that didn't blow up?)
85 |
86 | >
87 | );
88 | }
89 |
90 | // https://remix.run/api/conventions#errorboundary
91 | // https://remix.run/api/guides/not-found
92 | export function ErrorBoundary({ error }: { error: Error }) {
93 | console.error(error);
94 | return (
95 | <>
96 | Error!
97 | {error.message}
98 |
99 | (Isn't it cool that the user gets to stay in context and try a different
100 | link in the parts of the UI that didn't blow up?)
101 |
102 | >
103 | );
104 | }
105 |
106 | export let meta: MetaFunction = ({ data }) => {
107 | return {
108 | title: data ? `Param: ${data.param}` : "Oops...",
109 | };
110 | };
111 |
--------------------------------------------------------------------------------
/examples/v1.0.5/app/routes/demos/params/index.tsx:
--------------------------------------------------------------------------------
1 | import { useCatch, Link, json, useLoaderData, Outlet } from "remix";
2 | import type { LoaderFunction } from "remix";
3 |
4 | export default function Boundaries() {
5 | return (
6 | <>
7 | Params
8 |
9 | When you name a route segment with $ like{" "}
10 | routes/users/$userId.js
, the $ segment will be parsed from
11 | the URL and sent to your loaders and actions by the same name.
12 |
13 | Errors
14 |
15 | When a route throws and error in it's action, loader, or component,
16 | Remix automatically catches it, won't even try to render the component,
17 | but it will render the route's ErrorBoundary instead. If the route
18 | doesn't have one, it will bubble up to the routes above it until it hits
19 | the root.
20 |
21 | So be as granular as you want with your error handling.
22 | Not Found
23 |
24 | (and other{" "}
25 |
26 | client errors
27 |
28 | )
29 |
30 |
31 | Loaders and Actions can throw a Response
instead of an
32 | error and Remix will render the CatchBoundary instead of the component.
33 | This is great when loading data from a database isn't found. As soon as
34 | you know you can't render the component normally, throw a 404 response
35 | and send your app into the catch boundary. Just like error boundaries,
36 | catch boundaries bubble, too.
37 |
38 | >
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/examples/v1.0.5/app/routes/index.tsx:
--------------------------------------------------------------------------------
1 | import type { MetaFunction, LoaderFunction } from "remix";
2 | import { useLoaderData, json, Link } from "remix";
3 |
4 | type IndexData = {
5 | resources: Array<{ name: string; url: string }>;
6 | demos: Array<{ name: string; to: string }>;
7 | };
8 |
9 | // Loaders provide data to components and are only ever called on the server, so
10 | // you can connect to a database or run any server side code you want right next
11 | // to the component that renders it.
12 | // https://remix.run/api/conventions#loader
13 | export let loader: LoaderFunction = () => {
14 | user.test();
15 | let data: IndexData = {
16 | resources: [
17 | {
18 | name: "Remix Docs",
19 | url: "https://remix.run/docs",
20 | },
21 | {
22 | name: "React Router Docs",
23 | url: "https://reactrouter.com/docs",
24 | },
25 | {
26 | name: "Remix Discord",
27 | url: "https://discord.gg/VBePs6d",
28 | },
29 | ],
30 | demos: [
31 | {
32 | to: "demos/actions",
33 | name: "Actions",
34 | },
35 | {
36 | to: "demos/about",
37 | name: "Nested Routes, CSS loading/unloading",
38 | },
39 | {
40 | to: "demos/params",
41 | name: "URL Params and Error Boundaries",
42 | },
43 | ],
44 | };
45 |
46 | // https://remix.run/api/remix#json
47 | return json(data);
48 | };
49 |
50 | // https://remix.run/api/conventions#meta
51 | export let meta: MetaFunction = () => {
52 | return {
53 | title: "Remix Starter",
54 | description: "Welcome to remix!",
55 | };
56 | };
57 |
58 | // https://remix.run/guides/routing#index-routes
59 | export default function Index() {
60 | let data = useLoaderData();
61 |
62 | return (
63 |
64 |
65 | Welcome to Remix!
66 | We're stoked that you're here. 🥳
67 |
68 | Feel free to take a look around the code to see how Remix does things,
69 | it might be a bit different than what you’re used to. When you're
70 | ready to dive deeper, we've got plenty of resources to get you
71 | up-and-running quickly.
72 |
73 |
74 | Check out all the demos in this starter, and then just delete the{" "}
75 | app/routes/demos
and app/styles/demos
{" "}
76 | folders when you're ready to turn this into your next project.
77 |
78 |
79 |
80 | Demos In This App
81 |
82 | {data.demos.map((demo) => (
83 |
84 |
85 | {demo.name}
86 |
87 |
88 | ))}
89 |
90 | Resources
91 |
92 | {data.resources.map((resource) => (
93 |
94 | {resource.name}
95 |
96 | ))}
97 |
98 |
99 |
100 | );
101 | }
102 |
--------------------------------------------------------------------------------
/examples/v1.0.5/app/styles/dark.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --color-foreground: hsl(0, 0%, 100%);
3 | --color-background: hsl(0, 0%, 7%);
4 | --color-links: hsl(213, 100%, 73%);
5 | --color-links-hover: hsl(213, 100%, 80%);
6 | --color-border: hsl(0, 0%, 25%);
7 | }
8 |
--------------------------------------------------------------------------------
/examples/v1.0.5/app/styles/demos/about.css:
--------------------------------------------------------------------------------
1 | /*
2 | * Whoa whoa whoa, wait a sec...why are we overriding global CSS selectors?
3 | * Isn't that kind of scary? How do we know this won't have side effects?
4 | *
5 | * In Remix, CSS that is included in a route file will *only* show up on that
6 | * route (and for nested routes, its children). When the user navigates away
7 | * from that route the CSS files linked from those routes will be automatically
8 | * unloaded, making your styles much easier to predict and control.
9 | *
10 | * Read more about styling routes in the docs:
11 | * https://remix.run/guides/styling
12 | */
13 |
14 | :root {
15 | --color-foreground: hsl(0, 0%, 7%);
16 | --color-background: hsl(56, 100%, 50%);
17 | --color-links: hsl(345, 56%, 39%);
18 | --color-links-hover: hsl(345, 51%, 49%);
19 | --color-border: rgb(184, 173, 20);
20 | --font-body: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
21 | Liberation Mono, Courier New, monospace;
22 | }
23 |
24 | .about__intro {
25 | max-width: 500px;
26 | }
27 |
--------------------------------------------------------------------------------
/examples/v1.0.5/app/styles/demos/remix.css:
--------------------------------------------------------------------------------
1 | /*
2 | * You probably want to just delete this file; it's just for the demo pages.
3 | */
4 | .remix-app {
5 | display: flex;
6 | flex-direction: column;
7 | min-height: 100vh;
8 | min-height: calc(100vh - env(safe-area-inset-bottom));
9 | }
10 |
11 | .remix-app > * {
12 | width: 100%;
13 | }
14 |
15 | .remix-app__header {
16 | padding-top: 1rem;
17 | padding-bottom: 1rem;
18 | border-bottom: 1px solid var(--color-border);
19 | }
20 |
21 | .remix-app__header-content {
22 | display: flex;
23 | justify-content: space-between;
24 | align-items: center;
25 | }
26 |
27 | .remix-app__header-home-link {
28 | width: 106px;
29 | height: 30px;
30 | color: var(--color-foreground);
31 | }
32 |
33 | .remix-app__header-nav ul {
34 | list-style: none;
35 | margin: 0;
36 | display: flex;
37 | align-items: center;
38 | gap: 1.5em;
39 | }
40 |
41 | .remix-app__header-nav li {
42 | font-weight: bold;
43 | }
44 |
45 | .remix-app__main {
46 | flex: 1 1 100%;
47 | }
48 |
49 | .remix-app__footer {
50 | padding-top: 1rem;
51 | padding-bottom: 1rem;
52 | border-top: 1px solid var(--color-border);
53 | }
54 |
55 | .remix-app__footer-content {
56 | display: flex;
57 | justify-content: center;
58 | align-items: center;
59 | }
60 |
61 | .remix__page {
62 | --gap: 1rem;
63 | --space: 2rem;
64 | display: grid;
65 | grid-auto-rows: min-content;
66 | gap: var(--gap);
67 | padding-top: var(--space);
68 | padding-bottom: var(--space);
69 | }
70 |
71 | @media print, screen and (min-width: 640px) {
72 | .remix__page {
73 | --gap: 2rem;
74 | grid-auto-rows: unset;
75 | grid-template-columns: repeat(2, 1fr);
76 | }
77 | }
78 |
79 | @media screen and (min-width: 1024px) {
80 | .remix__page {
81 | --gap: 4rem;
82 | }
83 | }
84 |
85 | .remix__page > main > :first-child {
86 | margin-top: 0;
87 | }
88 |
89 | .remix__page > main > :last-child {
90 | margin-bottom: 0;
91 | }
92 |
93 | .remix__page > aside {
94 | margin: 0;
95 | padding: 1.5ch 2ch;
96 | border: solid 1px var(--color-border);
97 | border-radius: 0.5rem;
98 | }
99 |
100 | .remix__page > aside > :first-child {
101 | margin-top: 0;
102 | }
103 |
104 | .remix__page > aside > :last-child {
105 | margin-bottom: 0;
106 | }
107 |
108 | .remix__form {
109 | display: flex;
110 | flex-direction: column;
111 | gap: 1rem;
112 | padding: 1rem;
113 | border: 1px solid var(--color-border);
114 | border-radius: 0.5rem;
115 | }
116 |
117 | .remix__form > * {
118 | margin-top: 0;
119 | margin-bottom: 0;
120 | }
121 |
--------------------------------------------------------------------------------
/examples/v1.0.5/app/styles/global.css:
--------------------------------------------------------------------------------
1 | /*
2 | * You can just delete everything here or keep whatever you like, it's just a
3 | * quick baseline!
4 | */
5 | :root {
6 | --color-foreground: hsl(0, 0%, 7%);
7 | --color-background: hsl(0, 0%, 100%);
8 | --color-links: hsl(213, 100%, 52%);
9 | --color-links-hover: hsl(213, 100%, 43%);
10 | --color-border: hsl(0, 0%, 82%);
11 | --font-body: -apple-system, "Segoe UI", Helvetica Neue, Helvetica, Roboto,
12 | Arial, sans-serif, system-ui, "Apple Color Emoji", "Segoe UI Emoji";
13 | }
14 |
15 | html {
16 | box-sizing: border-box;
17 | }
18 |
19 | *,
20 | *::before,
21 | *::after {
22 | box-sizing: inherit;
23 | }
24 |
25 | :-moz-focusring {
26 | outline: auto;
27 | }
28 |
29 | :focus {
30 | outline: var(--color-links) solid 2px;
31 | outline-offset: 2px;
32 | }
33 |
34 | html,
35 | body {
36 | padding: 0;
37 | margin: 0;
38 | background-color: var(--color-background);
39 | color: var(--color-foreground);
40 | }
41 |
42 | body {
43 | font-family: var(--font-body);
44 | line-height: 1.5;
45 | }
46 |
47 | a {
48 | color: var(--color-links);
49 | text-decoration: none;
50 | }
51 |
52 | a:hover {
53 | color: var(--color-links-hover);
54 | text-decoration: underline;
55 | }
56 |
57 | hr {
58 | display: block;
59 | height: 1px;
60 | border: 0;
61 | background-color: var(--color-border);
62 | margin-top: 2rem;
63 | margin-bottom: 2rem;
64 | }
65 |
66 | input:where([type="text"]),
67 | input:where([type="search"]) {
68 | display: block;
69 | border: 1px solid var(--color-border);
70 | width: 100%;
71 | font: inherit;
72 | line-height: 1;
73 | height: calc(1ch + 1.5em);
74 | padding-right: 0.5em;
75 | padding-left: 0.5em;
76 | background-color: hsl(0 0% 100% / 20%);
77 | color: var(--color-foreground);
78 | }
79 |
80 | .sr-only {
81 | position: absolute;
82 | width: 1px;
83 | height: 1px;
84 | padding: 0;
85 | margin: -1px;
86 | overflow: hidden;
87 | clip: rect(0, 0, 0, 0);
88 | white-space: nowrap;
89 | border-width: 0;
90 | }
91 |
92 | .container {
93 | --gutter: 16px;
94 | width: 1024px;
95 | max-width: calc(100% - var(--gutter) * 2);
96 | margin-right: auto;
97 | margin-left: auto;
98 | }
99 |
--------------------------------------------------------------------------------
/examples/v1.0.5/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "remix-app-template",
4 | "description": "",
5 | "license": "",
6 | "scripts": {
7 | "build": "remix build",
8 | "dev": "remix dev",
9 | "postinstall": "remix setup node"
10 | },
11 | "dependencies": {
12 | "@remix-run/react": "^1.0.5",
13 | "@remix-run/vercel": "^1.0.5",
14 | "react": "^17.0.2",
15 | "react-dom": "^17.0.2",
16 | "remix": "^1.0.5",
17 | "remix-crash": "workspace:^0.1.1"
18 | },
19 | "devDependencies": {
20 | "@remix-run/dev": "^1.0.5",
21 | "@types/react": "^17.0.24",
22 | "@types/react-dom": "^17.0.9",
23 | "typescript": "^4.1.2"
24 | },
25 | "engines": {
26 | "node": ">=14"
27 | },
28 | "sideEffects": false
29 | }
--------------------------------------------------------------------------------
/examples/v1.0.5/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/excessivecoding/remix-crash/f62f486a0b146c7feac922ffa56c4619164bc79f/examples/v1.0.5/public/favicon.ico
--------------------------------------------------------------------------------
/examples/v1.0.5/remix.config.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @type {import('@remix-run/dev/config').AppConfig}
3 | */
4 | module.exports = {
5 | appDirectory: "app",
6 | browserBuildDirectory: "public/build",
7 | publicPath: "/build/",
8 | serverBuildDirectory: "api/build"
9 | };
10 |
--------------------------------------------------------------------------------
/examples/v1.0.5/remix.env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/examples/v1.0.5/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"],
3 | "compilerOptions": {
4 | "lib": ["DOM", "DOM.Iterable", "ES2019"],
5 | "esModuleInterop": true,
6 | "jsx": "react-jsx",
7 | "moduleResolution": "node",
8 | "resolveJsonModule": true,
9 | "target": "ES2019",
10 | "strict": true,
11 | "paths": {
12 | "~/*": ["./app/*"]
13 | },
14 |
15 | // Remix takes care of building everything in `remix build`.
16 | "noEmit": true
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/examples/v1.0.5/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "build": {
3 | "env": {
4 | "ENABLE_FILE_SYSTEM_API": "1",
5 | "VERCEL_BUILD_CLI_PACKAGE": "vercel@canary"
6 | }
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "remix-crash",
3 | "version": "0.1.3",
4 | "license": "MIT",
5 | "files": [
6 | "dist"
7 | ],
8 | "exports": {
9 | ".": {
10 | "import": "./dist/client/remix-crash.es.js",
11 | "require": "./dist/client/remix-crash.umd.js",
12 | "types": "./dist/client/remix-crash.d.ts"
13 | },
14 | "./server": {
15 | "import": "./dist/server/remix-crash.es.js",
16 | "require": "./dist/server/remix-crash.umd.js",
17 | "types": "./dist/server/remix-crash.d.ts"
18 | }
19 | },
20 | "scripts": {
21 | "dev": "vite build --watch",
22 | "build": "tsc && vite build && rollup -c"
23 | },
24 | "devDependencies": {
25 | "@remix-run/dev": "^1.0.5",
26 | "@remix-run/node": "^1.1.3",
27 | "@remix-run/react": "^1.0.5",
28 | "@rollup/plugin-typescript": "^8.3.0",
29 | "@types/babel__core": "^7.1.18",
30 | "@types/node": "^17.0.14",
31 | "@types/node-fetch": "^2.6.1",
32 | "@types/react": "^17.0.33",
33 | "@types/react-dom": "^17.0.10",
34 | "@vitejs/plugin-react": "^1.0.7",
35 | "autoprefixer": "^10.4.2",
36 | "concurrently": "^7.0.0",
37 | "postcss": "^8.4.6",
38 | "react": ">=16.8",
39 | "react-dom": ">=16.8",
40 | "remix": ">=1.0.5",
41 | "rollup": "^2.67.2",
42 | "tailwindcss": "^3.0.18",
43 | "typescript": "^4.4.4",
44 | "vite": "^2.7.2"
45 | },
46 | "peerDependencies": {
47 | "react": ">=16.8",
48 | "react-dom": ">=16.8",
49 | "remix": ">=1.0.5"
50 | },
51 | "dependencies": {
52 | "axios": "^0.26.0",
53 | "highlight.js": "^11.4.0",
54 | "node-fetch": "^3.2.0",
55 | "source-map": "^0.7.3"
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - ./
3 | - examples/**
4 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import typescript from "@rollup/plugin-typescript";
2 |
3 | export default {
4 | input: "src/server/index.ts",
5 | plugins: [typescript()],
6 | external: [
7 | "react",
8 | "react-dom",
9 | "remix",
10 | "source-map",
11 | "fs/promises",
12 | "axios",
13 | ],
14 | output: [
15 | {
16 | file: "dist/server/remix-crash.umd.js",
17 | name: "RemixCrash",
18 | format: "umd",
19 | globals: {
20 | remix: "Remix",
21 | "source-map": "SourceMap",
22 | "fs/promises": "FSPromises",
23 | axios: "Axios",
24 | },
25 | },
26 | {
27 | file: "dist/server/remix-crash.es.js",
28 | format: "esm",
29 | globals: {
30 | remix: "Remix",
31 | "source-map": "SourceMap",
32 | "fs/promises": "FSPromises",
33 | axios: "Axios",
34 | },
35 | },
36 | ],
37 | };
38 |
--------------------------------------------------------------------------------
/src/client/ErrorBoundary.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | createContext,
3 | FunctionComponent,
4 | useContext,
5 | useEffect,
6 | useLayoutEffect,
7 | useMemo,
8 | useState,
9 | } from "react";
10 | import { Links, LiveReload, Meta, Scripts, ScrollRestoration } from "remix";
11 | import codeStyles from "highlight.js/styles/github-dark.css";
12 | import styles from "./index.css";
13 | import hljs from "highlight.js";
14 |
15 | export type Stacktrace = Array;
16 |
17 | export type SanitizedStacktrace = Array<{
18 | methodName: string;
19 | file: string;
20 | line: number;
21 | column: number;
22 | } | null>;
23 |
24 | export type ErrorState = {
25 | loading: boolean;
26 | stacktrace: Stacktrace;
27 | sanitizedStacktrace: SanitizedStacktrace;
28 | convertedStacktrace: Array<{
29 | file: string;
30 | sourceContent: string;
31 | line: number;
32 | }>;
33 | selectedIndex: number | null;
34 | setSelectedIndex: (value: number | null) => void;
35 | };
36 |
37 | export const defaultErrorState: ErrorState = {
38 | stacktrace: [],
39 | sanitizedStacktrace: [],
40 | loading: true,
41 | convertedStacktrace: [],
42 | setSelectedIndex: () => {},
43 | selectedIndex: null,
44 | };
45 |
46 | export const ErrorContext = createContext(defaultErrorState);
47 |
48 | export const ErrorContextProvider: FunctionComponent<{
49 | stacktrace: Stacktrace;
50 | }> = ({ children, stacktrace }) => {
51 | const sanitizedStacktrace = useMemo(() => {
52 | return stacktrace.map((line) => {
53 | const sanitizedLine = line.replace("at", "").trim();
54 |
55 | let result = null;
56 |
57 | result = new RegExp(/(.+) \((.+):(\d+):(\d+)\)/, "g").exec(sanitizedLine);
58 |
59 | if (result) {
60 | const [, methodName, file, line, column] = result;
61 |
62 | return {
63 | methodName,
64 | file,
65 | line,
66 | column,
67 | };
68 | }
69 |
70 | result = new RegExp(/^(\/.+):(\d+):(\d+)/, "g").exec(sanitizedLine);
71 |
72 | if (result) {
73 | const [, file, line, column] = result;
74 |
75 | return {
76 | file,
77 | line,
78 | column,
79 | };
80 | }
81 | });
82 | }, [stacktrace]);
83 |
84 | const [convertedStacktrace, setConvertedStacktrace] = useState([]);
85 | const [loading, setLoading] = useState(true);
86 |
87 | useEffect(() => {
88 | Promise.all(
89 | sanitizedStacktrace.map(async (sanitizedStacktraceLine) => {
90 | if (!sanitizedStacktraceLine) return null;
91 |
92 | // @ts-ignore
93 | const params = new URLSearchParams(sanitizedStacktraceLine);
94 |
95 | return fetch(`/_remix-crash?${params.toString()}`, {
96 | method: "POST",
97 | }).then((response) => {
98 | if (!response.ok) return null;
99 | return response.json();
100 | });
101 | })
102 | )
103 | .then((data) => {
104 | // @ts-ignore
105 | setConvertedStacktrace(data);
106 | })
107 | .finally(() => {
108 | setLoading(false);
109 | });
110 | }, [sanitizedStacktrace]);
111 |
112 | const [selectedIndex, setSelectedIndex] = useState(null);
113 |
114 | useEffect(() => {
115 | setSelectedIndex(stacktrace.length ? 0 : null);
116 | }, [stacktrace]);
117 |
118 | return (
119 |
130 | {children}
131 |
132 | );
133 | };
134 |
135 | export const useError = () => {
136 | return useContext(ErrorContext);
137 | };
138 |
139 | export function ErrorBoundary({ error }: { error: Error }) {
140 | console.error(error);
141 |
142 | if (process.env.NODE_ENV !== "development") {
143 | return ;
144 | }
145 |
146 | return ;
147 | }
148 |
149 | export function ProdErrorBoundary({ error }: { error: Error }) {
150 | return (
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
165 |
175 | 500
176 |
183 | Internal Server Error.
184 |
185 |
186 |
187 |
188 |
189 |
190 |
191 | );
192 | }
193 |
194 | export function DevErrorBoundary({ error }: { error: Error }) {
195 | const [firstLine, ...stacktrace] = useMemo(() => {
196 | if (!error.stack) return [];
197 | return error.stack.split("\n");
198 | }, [error.stack]);
199 |
200 | const [type, message] = useMemo(() => {
201 | return firstLine?.split(": ") || ["", error.message];
202 | }, [firstLine, error.message]);
203 |
204 | return (
205 |
206 |
207 | {`💥 ${type}: ${message}`}
208 |
209 |
210 |
211 |
216 |
220 |
221 |
222 |
223 |
224 |
228 |
229 |
230 |
{type}
231 |
{message}
232 |
233 |
234 |
235 |
236 |
237 |
238 |
239 |
240 |
241 |
242 |
243 |
244 |
245 |
246 |
247 |
248 |
249 |
250 |
251 |
252 | );
253 | }
254 |
255 | const StacktraceList = () => {
256 | const { stacktrace, loading } = useError();
257 |
258 | if (loading) return null;
259 |
260 | return (
261 |
262 | {stacktrace.map((line, index) => {
263 | return (
264 |
265 | {line}
266 |
267 | );
268 | })}
269 |
270 | );
271 | };
272 |
273 | export const StackTraceLine: FunctionComponent<{ index: number }> = ({
274 | index,
275 | children,
276 | }) => {
277 | const { convertedStacktrace, setSelectedIndex, loading, selectedIndex } =
278 | useError();
279 |
280 | if (loading) return null;
281 |
282 | const line = convertedStacktrace[index];
283 |
284 | return (
285 |
286 | {
289 | e.preventDefault();
290 | setSelectedIndex(index);
291 | }}
292 | className={[
293 | "p-8 border border-slate-900 w-full text-left break-words",
294 | selectedIndex === index ? "bg-gray-700 bg-opacity-40" : "",
295 | ].join(" ")}
296 | >
297 | {line ? {line.file} : children}
298 |
299 |
300 | );
301 | };
302 |
303 | const CodeFrame = () => {
304 | const { convertedStacktrace, selectedIndex } = useError();
305 |
306 | const convertedStacktraceLine =
307 | selectedIndex !== null ? convertedStacktrace[selectedIndex] : null;
308 |
309 | useLayoutEffect(() => {
310 | document.querySelector(".selected")?.scrollIntoView({ block: "center" });
311 | }, [convertedStacktraceLine]);
312 |
313 | if (!convertedStacktraceLine) return null;
314 |
315 | return (
316 |
317 |
318 | {hljs
319 | .highlight(convertedStacktraceLine.sourceContent, {
320 | language: "tsx",
321 | })
322 | .value.split("\n")
323 | .map((line, index) => {
324 | const selected = index + 1 === convertedStacktraceLine.line;
325 |
326 | return (
327 |
335 |
342 | {index + 1}
343 |
344 |
351 |
352 | );
353 | })}
354 |
355 |
356 | );
357 | };
358 |
--------------------------------------------------------------------------------
/src/client/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
--------------------------------------------------------------------------------
/src/client/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./ErrorBoundary";
2 |
--------------------------------------------------------------------------------
/src/server/endpoints.ts:
--------------------------------------------------------------------------------
1 | import { ActionFunction, LoaderFunction, json } from "remix";
2 | import { SourceMapConsumer } from "source-map";
3 | import { CORSResponse } from "./utils";
4 | import { readFile } from "fs/promises";
5 | import axios from "axios";
6 |
7 | async function getFileContent(pathOrURL: string): Promise {
8 | const isRemote = new RegExp(/^http(s)?:\/\//).test(pathOrURL);
9 |
10 | if (isRemote) {
11 | return axios.get(pathOrURL).then((response) => response.data);
12 | }
13 |
14 | return readFile(pathOrURL, { encoding: "utf-8" });
15 | }
16 |
17 | async function extractSourceMap(data: string, pathOrURL: string) {
18 | let sourceMapResult;
19 |
20 | sourceMapResult = new RegExp(
21 | /\n\/\/\# sourceMappingURL\=data\:application\/json\;base64\,(.*)/,
22 | "g"
23 | ).exec(data);
24 |
25 | if (sourceMapResult?.length) {
26 | const [_, base64SourceMap] = sourceMapResult;
27 |
28 | return JSON.parse(Buffer.from(base64SourceMap, "base64").toString("utf-8"));
29 | }
30 |
31 | sourceMapResult = new RegExp(
32 | /\/\/\# sourceMappingURL\=(\/(.*)\.map)/,
33 | "g"
34 | ).exec(data);
35 |
36 | if (sourceMapResult?.length) {
37 | const bundledFileURL = new URL(pathOrURL);
38 | const [_, sourceMapPath] = sourceMapResult;
39 |
40 | const rawSourceMap = await axios
41 | .get(sourceMapPath, { baseURL: bundledFileURL.origin })
42 | .then((response) => response.data);
43 |
44 | return rawSourceMap;
45 | }
46 |
47 | return null;
48 | }
49 |
50 | export const loader: LoaderFunction = () => {
51 | if (process.env.NODE_ENV !== "development") {
52 | throw new Response(null, { status: 404 });
53 | }
54 |
55 | return new CORSResponse();
56 | };
57 |
58 | export const action: ActionFunction = async ({ request }) => {
59 | if (process.env.NODE_ENV !== "development") {
60 | throw new Response(null, { status: 404 });
61 | }
62 |
63 | const url = new URL(request.url);
64 | const root = process.cwd();
65 |
66 | const bundledFile = url.searchParams.get("file");
67 | const line = url.searchParams.get("line");
68 | const column = url.searchParams.get("column");
69 |
70 | if (!bundledFile || !line || !column) {
71 | throw new Response(null, { status: 422 });
72 | }
73 |
74 | const data = await getFileContent(bundledFile);
75 |
76 | const rawSourceMap = await extractSourceMap(data, bundledFile);
77 |
78 | if (!rawSourceMap) {
79 | return json({
80 | root,
81 | file: bundledFile.replace(root, ""),
82 | sourceContent: data,
83 | line: line ? parseInt(line) : null,
84 | column: column ? parseInt(column) : null,
85 | });
86 | }
87 |
88 | const consumer = await new SourceMapConsumer(rawSourceMap);
89 |
90 | if (!line || !column) {
91 | throw new Error("Failed to load source map");
92 | }
93 |
94 | const sourcePosition = consumer.originalPositionFor({
95 | line: +line,
96 | column: +column ?? 0,
97 | });
98 |
99 | if (!sourcePosition.source) {
100 | throw new Error("Failed to load source map");
101 | }
102 |
103 | const sourceContent =
104 | consumer.sourceContentFor(
105 | sourcePosition.source,
106 | /* returnNullOnMissing */ true
107 | ) ?? null;
108 |
109 | const file = sourcePosition.source.replace("route-module:", "");
110 |
111 | return json({
112 | root,
113 | file: file.replace(root, ""),
114 | sourceContent,
115 | line: sourcePosition.line,
116 | column: sourcePosition.column,
117 | });
118 | };
119 |
--------------------------------------------------------------------------------
/src/server/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./endpoints";
2 |
--------------------------------------------------------------------------------
/src/server/utils.ts:
--------------------------------------------------------------------------------
1 | export class CORSHeaders extends Headers {
2 | constructor() {
3 | super();
4 |
5 | this.append("Access-Control-Allow-Origin", "*");
6 |
7 | this.append(
8 | "Access-Control-Allow-Methods",
9 | "GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD"
10 | );
11 |
12 | this.append(
13 | "Access-Control-Allow-Headers",
14 | "Content-Type, Accept, Authorization"
15 | );
16 | }
17 | }
18 |
19 | export class CORSResponse extends Response {
20 | constructor() {
21 | super(null, { status: 204, headers: new CORSHeaders() });
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
3 | theme: { extend: {} },
4 | plugins: [],
5 | };
6 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
6 | "allowJs": false,
7 | "skipLibCheck": false,
8 | "esModuleInterop": false,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "module": "ESNext",
13 | "moduleResolution": "Node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "jsx": "react-jsx",
17 | "declaration": true,
18 | "outDir": "dist",
19 | "emitDeclarationOnly": true
20 | },
21 | "include": ["src/client/index.ts", "src/server/index.ts", "src/vite-env.d.ts"]
22 | }
23 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import react from "@vitejs/plugin-react";
3 | import path from "path";
4 |
5 | // https://vitejs.dev/config/
6 | export default defineConfig(({ mode }) => {
7 | return {
8 | plugins: [react()],
9 | define:
10 | mode === "production"
11 | ? { "process.env.NODE_ENV": "process.env.NODE_ENV" }
12 | : {},
13 | build: {
14 | emptyOutDir: false,
15 | outDir: "dist/client",
16 | lib: {
17 | entry: path.resolve(__dirname, "src/client/index.ts"),
18 | name: "RemixCrash",
19 | fileName: (format) => `remix-crash.${format}.js`,
20 | },
21 | rollupOptions: {
22 | // make sure to externalize deps that shouldn't be bundled
23 | // into your library
24 | external: [
25 | "react",
26 | "react-dom",
27 | "remix",
28 | "source-map",
29 | "fs/promises",
30 | "axios",
31 | ],
32 | output: {
33 | // Provide global variables to use in the UMD build
34 | // for externalized deps
35 | globals: {
36 | react: "React",
37 | "react-dom": "ReactDOM",
38 | remix: "Remix",
39 | "source-map": "SourceMap",
40 | "fs/promises": "FSPromises",
41 | axios: "Axios",
42 | },
43 | },
44 | },
45 | },
46 | };
47 | });
48 |
--------------------------------------------------------------------------------