├── .gitignore
├── app
├── entry.client.tsx
├── routes
│ ├── index.tsx
│ └── _script.ts
├── islands
│ └── counter.tsx
├── root.tsx
├── entry.server.tsx
└── enhancements
│ └── island.tsx
├── .eslintrc
├── remix.env.d.ts
├── public
└── favicon.ico
├── remix.config.js
├── tsconfig.json
├── package.json
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
3 | /.cache
4 | /build
5 | /public/build
6 | .env
7 |
--------------------------------------------------------------------------------
/app/entry.client.tsx:
--------------------------------------------------------------------------------
1 | // We don't actually hydrate the app in this example
2 | export {};
3 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["@remix-run/eslint-config", "@remix-run/eslint-config/node"]
3 | }
4 |
--------------------------------------------------------------------------------
/remix.env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jacob-ebey/remix-preact-mpa-sprinkles/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/remix.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('@remix-run/dev').AppConfig} */
2 | module.exports = {
3 | ignoredRouteFiles: ["**/.*"],
4 | // appDirectory: "app",
5 | // assetsBuildDirectory: "public/build",
6 | // serverBuildPath: "build/index.js",
7 | // publicPath: "/build/",
8 | };
9 |
--------------------------------------------------------------------------------
/app/routes/index.tsx:
--------------------------------------------------------------------------------
1 | import Island from "~/enhancements/island";
2 | import CounterIsland from "~/islands/counter";
3 |
4 | export default function Index() {
5 | return (
6 |
7 |
Welcome to Remix
8 | Counter Island
9 |
10 |
11 |
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/app/islands/counter.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "preact/hooks";
2 |
3 | export default function CounterIsland({
4 | initialCount,
5 | }: {
6 | initialCount?: number;
7 | }) {
8 | const [count, setCount] = useState(initialCount || 0);
9 |
10 | return (
11 |
12 |
13 | {count}
14 |
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/app/root.tsx:
--------------------------------------------------------------------------------
1 | import type { MetaFunction } from "@remix-run/node";
2 | import { Links, LiveReload, Meta, Outlet } from "@remix-run/react";
3 |
4 | export const meta: MetaFunction = () => ({
5 | charset: "utf-8",
6 | title: "New Remix App",
7 | viewport: "width=device-width,initial-scale=1",
8 | });
9 |
10 | export default function App() {
11 | return (
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"],
3 | "compilerOptions": {
4 | "lib": ["DOM", "DOM.Iterable", "ES2019"],
5 | "isolatedModules": true,
6 | "esModuleInterop": true,
7 | "jsx": "react-jsx",
8 | "jsxImportSource": "preact",
9 | "moduleResolution": "node",
10 | "resolveJsonModule": true,
11 | "target": "ES2019",
12 | "strict": true,
13 | "allowJs": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "baseUrl": ".",
16 | "paths": {
17 | "~/*": ["./app/*"]
18 | },
19 |
20 | // Remix takes care of building everything in `remix build`.
21 | "noEmit": true
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/app/entry.server.tsx:
--------------------------------------------------------------------------------
1 | import type { EntryContext } from "@remix-run/node";
2 | import { Response } from "@remix-run/node";
3 | import { RemixServer } from "@remix-run/react";
4 | import renderToString from "preact-render-to-string";
5 |
6 | export default function handleRequest(
7 | request: Request,
8 | responseStatusCode: number,
9 | responseHeaders: Headers,
10 | remixContext: EntryContext
11 | ) {
12 | const html = renderToString(
13 |
14 | );
15 | const headers = new Headers(responseHeaders);
16 | headers.set("content-type", "text/html");
17 |
18 | return new Response("" + html, {
19 | status: responseStatusCode,
20 | headers,
21 | });
22 | }
23 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "sideEffects": false,
4 | "scripts": {
5 | "build": "remix build",
6 | "dev": "remix dev",
7 | "start": "remix-serve build"
8 | },
9 | "resolutions": {
10 | "react": "npm:@preact/compat",
11 | "react-dom": "npm:@preact/compat"
12 | },
13 | "dependencies": {
14 | "@remix-run/node": "^1.7.2",
15 | "@remix-run/react": "^1.7.2",
16 | "@remix-run/serve": "^1.7.2",
17 | "esbuild": "^0.15.9",
18 | "preact": "^10.11.0",
19 | "preact-render-to-string": "^5.2.4",
20 | "react": "npm:@preact/compat",
21 | "react-dom": "npm:@preact/compat"
22 | },
23 | "devDependencies": {
24 | "@remix-run/dev": "^1.7.2",
25 | "@remix-run/eslint-config": "^1.7.2",
26 | "eslint": "^8.23.1",
27 | "typescript": "^4.7.4"
28 | },
29 | "engines": {
30 | "node": ">=14"
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Welcome to Remix!
2 |
3 | - [Remix Docs](https://remix.run/docs)
4 |
5 | ## Development
6 |
7 | From your terminal:
8 |
9 | ```sh
10 | npm run dev
11 | ```
12 |
13 | This starts your app in development mode, rebuilding assets on file changes.
14 |
15 | ## Deployment
16 |
17 | First, build your app for production:
18 |
19 | ```sh
20 | npm run build
21 | ```
22 |
23 | Then run the app in production mode:
24 |
25 | ```sh
26 | npm start
27 | ```
28 |
29 | Now you'll need to pick a host to deploy it to.
30 |
31 | ### DIY
32 |
33 | If you're familiar with deploying node applications, the built-in Remix app server is production-ready.
34 |
35 | Make sure to deploy the output of `remix build`
36 |
37 | - `build/`
38 | - `public/build/`
39 |
40 | ### Using a Template
41 |
42 | When you ran `npx create-remix@latest` there were a few choices for hosting. You can run that again to create a new project, then copy over your `app/` folder to the new project that's pre-configured for your target server.
43 |
44 | ```sh
45 | cd ..
46 | # create a new project, and pick a pre-configured host
47 | npx create-remix@latest
48 | cd my-new-remix-app
49 | # remove the new project's app (not the old one!)
50 | rm -rf app
51 | # copy your app over
52 | cp -R ../my-old-remix-app/app app
53 | ```
54 |
--------------------------------------------------------------------------------
/app/enhancements/island.tsx:
--------------------------------------------------------------------------------
1 | import { type ComponentChildren, toChildArray } from "preact";
2 | import { useId } from "preact/hooks";
3 | import { dependencies } from "../../package.json";
4 |
5 | const sha = process.env.RAILWAY_GIT_COMMIT_SHA;
6 | const preactVersion = dependencies.preact.replace(/^[\^~]/, "");
7 |
8 | export default function Island({
9 | source,
10 | children,
11 | }: {
12 | source: string;
13 | children: ComponentChildren;
14 | }) {
15 | let id = useId();
16 | let childArray = toChildArray(children);
17 | let childProps: unknown;
18 |
19 | if (childArray.length !== 1) {
20 | throw new Error(
21 | `Island expects exactly one child, but received ${childArray.length}`
22 | );
23 | }
24 | let child = childArray[0];
25 | if (typeof child === "object" && child !== null) {
26 | let { children, ...props } = child.props;
27 | childProps = props;
28 | }
29 |
30 | let serialziedProps = childProps ? JSON.stringify(childProps) : "(void 0)";
31 |
32 | let islandScriptParams = new URLSearchParams({ source });
33 | sha && islandScriptParams.set("sha", sha);
34 |
35 | let scriptContent = `import{h,hydrate}from"https://esm.sh/preact@${preactVersion}";`;
36 | scriptContent += `import Island from "/_script?${islandScriptParams}";`;
37 | scriptContent += `let e=document.getElementById(${JSON.stringify(
38 | id
39 | )}).previousElementSibling;`;
40 | scriptContent += `let a=(n)=>e.replaceChild(n,e);`;
41 | scriptContent += `let p=${serialziedProps};`;
42 | scriptContent += `hydrate(h(Island, p), {`;
43 | scriptContent += `childNodes:[e],`;
44 | scriptContent += `firstChild:e,`;
45 | scriptContent += `insertBefore:a,`;
46 | scriptContent += `appendChild:a`;
47 | scriptContent += `});`;
48 |
49 | return (
50 | <>
51 | {child}
52 |
60 | >
61 | );
62 | }
63 |
--------------------------------------------------------------------------------
/app/routes/_script.ts:
--------------------------------------------------------------------------------
1 | import { type LoaderArgs } from "@remix-run/node";
2 | import * as path from "path";
3 | import * as esbuild from "esbuild";
4 | import { dependencies } from "../../package.json";
5 |
6 | const sha = process.env.RAILWAY_GIT_COMMIT_SHA;
7 | const preactVersion = dependencies.preact.replace(/^[\^~]/, "");
8 |
9 | const allowedDirsToBundle = [path.resolve(process.cwd(), "app/islands")];
10 |
11 | const cache: Record = {};
12 |
13 | export async function loader({ request }: LoaderArgs) {
14 | let url = new URL(request.url);
15 | let source = url.searchParams.get("source")!;
16 |
17 | if (!source) {
18 | return new Response("No source provided", { status: 400 });
19 | }
20 | if (source.startsWith("~/")) {
21 | source = source.replace(/^~\//, "app/");
22 | }
23 | try {
24 | source = path.resolve(process.cwd(), source);
25 | } catch {
26 | return new Response("Invalid source", { status: 400 });
27 | }
28 |
29 | if (cache[source]) {
30 | return new Response(cache[source], {
31 | headers: {
32 | "Content-Type": "application/javascript",
33 | "Cache-Control": sha
34 | ? "public, max-age=31536000, immutable"
35 | : "no-cache",
36 | },
37 | });
38 | }
39 |
40 | if (
41 | !allowedDirsToBundle.some(
42 | (dir) => !path.relative(dir, source).startsWith(".")
43 | )
44 | ) {
45 | return new Response("Source not allowed", { status: 400 });
46 | }
47 |
48 | let build = await esbuild.build({
49 | entryPoints: { source },
50 | write: false,
51 | bundle: true,
52 | minify: true,
53 | format: "esm",
54 | platform: "browser",
55 | plugins: [
56 | {
57 | name: "esm.sh",
58 | setup(build) {
59 | build.onResolve({ filter: /.*/ }, (args) => {
60 | if (
61 | args.path.startsWith(".") ||
62 | args.path.startsWith("~") ||
63 | path.isAbsolute(args.path)
64 | ) {
65 | return undefined;
66 | }
67 |
68 | let splitPackage = args.path.split("/");
69 | let packageName = splitPackage[0];
70 | let packageRest = splitPackage.slice(1).join("/");
71 | if (packageName.startsWith("@")) {
72 | packageName = splitPackage.slice(0, 2).join("/");
73 | packageRest = splitPackage.slice(2).join("/");
74 | }
75 | let packageVersion =
76 | dependencies[packageName as keyof typeof dependencies];
77 | packageVersion = packageVersion.replace(/^[\^~]/, "");
78 | packageRest = packageRest ? `/${packageRest}` : "";
79 |
80 | let postfix =
81 | packageName !== "preact"
82 | ? `?alias=react:preact/compat&deps=preact@${preactVersion}`
83 | : "";
84 |
85 | return {
86 | path: `https://esm.sh/${packageName}@${packageVersion}${packageRest}${postfix}`,
87 | external: true,
88 | };
89 | });
90 | },
91 | },
92 | {
93 | name: "on-demand-alias",
94 | setup(build) {
95 | build.onResolve({ filter: /.*/ }, (args) => {
96 | if (args.path === source) {
97 | return undefined;
98 | }
99 |
100 | if (
101 | !args.path.startsWith(".") &&
102 | !args.path.startsWith("~") &&
103 | path.isAbsolute(args.path)
104 | ) {
105 | return {
106 | errors: [
107 | {
108 | text: `Cannot resolve "${args.path}"`,
109 | detail: `You can only import relative paths or bare modules defined in your package.json.`,
110 | },
111 | ],
112 | };
113 | }
114 |
115 | let resolveDir = args.resolveDir;
116 | let resolvePath = args.path;
117 | if (resolvePath.startsWith("~")) {
118 | resolvePath = resolvePath.slice(2);
119 | resolveDir = path.resolve(process.cwd(), "app");
120 | }
121 |
122 | let resolvedPath = path.resolve(resolveDir, resolvePath);
123 |
124 | let relativePath = path.relative(process.cwd(), resolvedPath);
125 |
126 | return {
127 | path: `/_script?source=${relativePath}${
128 | sha ? `&sha=${sha}` : ""
129 | }`,
130 | external: true,
131 | };
132 | });
133 | },
134 | },
135 | ],
136 | });
137 |
138 | let builtFile = build.outputFiles.find((file) => file.path === "");
139 |
140 | if (!builtFile) {
141 | if (build.errors.length) {
142 | console.error(
143 | await esbuild.formatMessages(build.errors, { kind: "error" })
144 | );
145 | }
146 | return new Response("No output file", { status: 500 });
147 | }
148 |
149 | cache[source] = builtFile.contents;
150 |
151 | return new Response(builtFile.contents, {
152 | headers: {
153 | "Content-Type": "text/javascript",
154 | "Cache-Control": sha ? "public, max-age=31536000, immutable" : "no-cache",
155 | },
156 | });
157 | }
158 |
--------------------------------------------------------------------------------