├── .nvmrc ├── .prettierrc ├── .husky ├── .gitignore └── pre-commit ├── .env.example ├── .eslintrc.json ├── public └── favicon.ico ├── .eslintignore ├── .prettierignore ├── tailwind.config.js ├── app ├── entry.client.tsx ├── components │ ├── buttons.tsx │ ├── forms.tsx │ └── styled.tsx ├── routes │ ├── logout.tsx │ ├── index.tsx │ ├── notes.new.tsx │ ├── notes.tsx │ ├── profile.tsx │ ├── login.tsx │ └── signup.tsx ├── entry.server.tsx ├── context.server.ts ├── session.server.ts ├── utils.server.ts ├── durable-objects │ ├── note.server.ts │ └── user.server.ts └── root.tsx ├── remix.env.d.ts ├── .gitignore ├── remix.config.js ├── tests ├── integration │ ├── auth.spec.ts │ ├── dashboard.spec.ts │ ├── notes.spec.ts │ └── utils.ts ├── index.spec.ts └── setup.ts ├── tsconfig.json ├── playwright.config.ts ├── wrangler.toml ├── LICENSE ├── scripts └── build.mjs ├── worker ├── index.ts └── adapter.ts ├── README.md ├── .github └── workflows │ └── development.yml └── package.json /.nvmrc: -------------------------------------------------------------------------------- 1 | 16 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | SESSION_SECRET="abc123-this-should-be-longer" -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@remix-run", "prettier"] 3 | } 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacob-ebey/remix-cloudflare-stack/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /.cache 2 | /.github 3 | /.husky 4 | /build 5 | /public 6 | /node_modules 7 | /dist 8 | /app/styles/tailwind.css 9 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /.cache 2 | /.github 3 | /.husky 4 | /build 5 | /public 6 | /node_modules 7 | /dist 8 | /app/styles/tailwind.css 9 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ["./app/**/*.tsx", "./app/**/*.ts"], 3 | theme: {}, 4 | plugins: [], 5 | }; 6 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { hydrate } from "react-dom"; 2 | import { RemixBrowser } from "remix"; 3 | 4 | hydrate(, document); 5 | -------------------------------------------------------------------------------- /app/components/buttons.tsx: -------------------------------------------------------------------------------- 1 | import { styledTag } from "./styled"; 2 | 3 | export let DefaultButton = styledTag( 4 | "button", 5 | "inline-block border hover:border-black px-4 py-2" 6 | ); 7 | -------------------------------------------------------------------------------- /remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | declare var process: { 5 | env: { 6 | NODE_ENV: "production" | "development"; 7 | VERSION: string; 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | # Remix 4 | /.cache 5 | /build 6 | /public/build 7 | 8 | # Custom Build 9 | /dist 10 | 11 | # Tailwind 12 | /app/styles/tailwind.css 13 | 14 | # Cypress 15 | /cypress/videos 16 | /cypress/screenshots 17 | 18 | # Miniflare 19 | /.mf 20 | /.env 21 | -------------------------------------------------------------------------------- /remix.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@remix-run/dev/config').AppConfig} 3 | */ 4 | module.exports = { 5 | appDirectory: "app", 6 | assetsBuildDirectory: "public/build", 7 | publicPath: "/build/", 8 | serverModuleFormat: "esm", 9 | serverPlatform: "neutral", 10 | serverBuildDirectory: "build", 11 | devServerBroadcastDelay: 1000, 12 | ignoredRouteFiles: [".*"], 13 | }; 14 | -------------------------------------------------------------------------------- /tests/integration/auth.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "../setup"; 2 | import { signup, login } from "./utils"; 3 | 4 | test("can signup and login", async ({ page, queries }) => { 5 | const { email, password } = await signup(page); 6 | expect(await queries.findByText(email, { exact: false })).toBeTruthy(); 7 | 8 | await page.click("button[data-testid=logout]"); 9 | await page.waitForURL("/"); 10 | 11 | await login(page, { email, password }); 12 | expect(await queries.findByText(email, { exact: false })).toBeTruthy(); 13 | }); 14 | -------------------------------------------------------------------------------- /app/routes/logout.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "remix"; 2 | 3 | import { ActionFunction } from "~/context.server"; 4 | 5 | export let loader = () => redirect("/"); 6 | 7 | export let action: ActionFunction = async ({ 8 | request, 9 | context: { sessionStorage }, 10 | }) => { 11 | let session = await sessionStorage.getSession(request.headers.get("Cookie")); 12 | 13 | return redirect("/", { 14 | headers: { 15 | "Set-Cookie": await sessionStorage.destroySession(session), 16 | }, 17 | }); 18 | }; 19 | 20 | export default () => null; 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["remix.env.d.ts", "app/**/*.ts", "app/**/*.tsx", "worker/**.ts"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 5 | "esModuleInterop": true, 6 | "resolveJsonModule": true, 7 | "jsx": "react-jsx", 8 | "module": "CommonJS", 9 | "moduleResolution": "node", 10 | "target": "ESNext", 11 | "strict": true, 12 | "types": ["@cloudflare/workers-types"], 13 | "paths": { 14 | "~/*": ["./app/*"] 15 | }, 16 | 17 | // Remix takes care of building everything in `remix build`. 18 | "noEmit": true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { PlaywrightTestConfig, devices } from "@playwright/test"; 2 | 3 | const config: PlaywrightTestConfig = { 4 | forbidOnly: !!process.env.CI, 5 | retries: process.env.CI ? 2 : 0, 6 | use: { 7 | trace: "on-first-retry", 8 | }, 9 | projects: [ 10 | { 11 | name: "chromium", 12 | use: { ...devices["Desktop Chrome"] }, 13 | }, 14 | { 15 | name: "firefox", 16 | use: { ...devices["Desktop Firefox"] }, 17 | }, 18 | // { 19 | // name: 'webkit', 20 | // use: { ...devices['Desktop Safari'] }, 21 | // }, 22 | ], 23 | }; 24 | export default config; 25 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOMServer from "react-dom/server"; 2 | import type { EntryContext } from "remix"; 3 | import { RemixServer } from "remix"; 4 | 5 | export default function handleRequest( 6 | request: Request, 7 | responseStatusCode: number, 8 | responseHeaders: Headers, 9 | remixContext: EntryContext 10 | ) { 11 | let markup = ReactDOMServer.renderToString( 12 | 13 | ); 14 | 15 | responseHeaders.set("Content-Type", "text/html"); 16 | 17 | return new Response("" + markup, { 18 | status: responseStatusCode, 19 | headers: responseHeaders, 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /tests/integration/dashboard.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "../setup"; 2 | import { signup } from "./utils"; 3 | 4 | test("can change display name", async ({ page, queries }) => { 5 | const { email } = await signup(page); 6 | expect(await queries.findByText(email, { exact: false })).toBeTruthy(); 7 | await page.goto("/profile"); 8 | 9 | let newDisplayName = "new display name"; 10 | 11 | await page.type("input[name=displayName]", newDisplayName); 12 | await page.click("button[data-testid=updateProfile]"); 13 | await page.waitForLoadState("networkidle"); 14 | 15 | await page.goto("/"); 16 | expect( 17 | await queries.findByText(newDisplayName, { exact: false }) 18 | ).toBeTruthy(); 19 | }); 20 | -------------------------------------------------------------------------------- /app/context.server.ts: -------------------------------------------------------------------------------- 1 | import type { SessionStorage } from "remix"; 2 | import type { DataFunctionArgs } from "@remix-run/server-runtime"; 3 | 4 | export interface CloudflareEnvironment { 5 | SESSION_SECRET: string; 6 | NOTE: DurableObjectNamespace; 7 | USER: DurableObjectNamespace; 8 | } 9 | 10 | export interface Context { 11 | ctx: ExecutionContext; 12 | env: CloudflareEnvironment; 13 | sessionStorage: SessionStorage; 14 | } 15 | 16 | export type ActionFunction = ( 17 | args: Omit & { context: Context } 18 | ) => Response | Promise; 19 | 20 | export type LoaderFunction = ( 21 | args: Omit & { context: Context } 22 | ) => Response | Promise; 23 | -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "remix-cloudflare-do" 2 | type = "javascript" 3 | usage_model = "unbound" 4 | workers_dev = true 5 | # account_id = "Or specified with process.env.CF_ACCOUNT_ID" 6 | # zone_id = "Or specified with process.env.CF_ZONE_ID" 7 | # route = "example.com/*" 8 | 9 | [durable_objects] 10 | bindings = [ 11 | {name = "USER", class_name = "UserDurableObject"}, 12 | {name = "NOTE", class_name = "NoteDurableObject"}, 13 | ] 14 | 15 | [[migrations]] 16 | new_classes = ["NoteDurableObject", "UserDurableObject"] 17 | tag = "v1" 18 | 19 | [site] 20 | bucket = "./public" 21 | entry-point = "." 22 | 23 | [build] 24 | command = "npm run build" 25 | watch_dir = "./build/index.js" 26 | 27 | [build.upload] 28 | dir = "./dist" 29 | format = "modules" 30 | main = "./worker.mjs" 31 | -------------------------------------------------------------------------------- /app/components/forms.tsx: -------------------------------------------------------------------------------- 1 | import cn from "classnames"; 2 | 3 | import { styledTag } from "./styled"; 4 | 5 | export let Checkbox = styledTag< 6 | "input", 7 | { 8 | position?: "left" | "right"; 9 | } 10 | >( 11 | "input", 12 | "inline-block w-5 h-5", 13 | ({ position }) => 14 | cn({ 15 | "mr-2": !position || position === "left", 16 | "ml-2": position === "right", 17 | }), 18 | { 19 | type: "checkbox", 20 | } 21 | ); 22 | 23 | export let CheckboxLabel = styledTag("label", "flex items-center mt-2"); 24 | 25 | export let Label = styledTag("label", "block mt-2"); 26 | 27 | export let Input = styledTag( 28 | "input", 29 | "block border hover:border-black px-4 py-2 mt-2" 30 | ); 31 | 32 | export let InputError = styledTag("span", "mt-2 text-red-500", undefined, { 33 | role: "alert", 34 | }); 35 | 36 | export let Textarea = styledTag( 37 | "textarea", 38 | "block border hover:border-black px-4 py-2 mt-2" 39 | ); 40 | -------------------------------------------------------------------------------- /tests/integration/notes.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "../setup"; 2 | import { signup } from "./utils"; 3 | 4 | test("can interact with notes", async ({ page, queries }) => { 5 | const { email } = await signup(page); 6 | expect(await queries.findByText(email, { exact: false })).toBeTruthy(); 7 | await page.goto("/notes/new"); 8 | 9 | let newNoteTitle = "new note title"; 10 | let newNoteBody = "new note body"; 11 | 12 | await page.type("input[name=title]", newNoteTitle); 13 | await page.type("textarea[name=body]", newNoteBody); 14 | await page.click("button[data-testid=createNote]"); 15 | await page.waitForURL("/notes"); 16 | expect(await queries.findByText(newNoteTitle, { exact: false })).toBeTruthy(); 17 | expect(await queries.findByText(newNoteBody, { exact: false })).toBeTruthy(); 18 | 19 | await page.click("button[name=toDelete]"); 20 | await page.waitForLoadState("networkidle"); 21 | await page.waitForSelector("[data-testid=noNotes]"); 22 | }); 23 | -------------------------------------------------------------------------------- /tests/integration/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Page } from "@playwright/test"; 2 | import { v4 as uuidV4 } from "uuid"; 3 | 4 | interface SignupResult { 5 | email: string; 6 | password: string; 7 | } 8 | 9 | export async function signup(page: Page): Promise { 10 | let email = uuidV4() + "@test.com"; 11 | let password = uuidV4(); 12 | 13 | await page.goto("/signup"); 14 | await page.type("input[name=email]", email); 15 | await page.type("input[name=password]", password); 16 | await page.type("input[name=verifyPassword]", password); 17 | await page.click("button[data-testid=signup]"); 18 | await page.waitForURL("/"); 19 | 20 | return { email, password }; 21 | } 22 | 23 | export async function login(page: Page, { email, password }: SignupResult) { 24 | await page.goto("/login"); 25 | await page.type("input[name=email]", email); 26 | await page.type("input[name=password]", password); 27 | await page.click("button[data-testid=login]"); 28 | await page.waitForURL("/"); 29 | } 30 | -------------------------------------------------------------------------------- /app/session.server.ts: -------------------------------------------------------------------------------- 1 | import type { SessionStorage } from "remix"; 2 | import { redirect } from "remix"; 3 | 4 | const USER_ID_KEY = "userId"; 5 | 6 | export async function setLogin( 7 | request: Request, 8 | sessionStorage: SessionStorage, 9 | userId: string, 10 | rememberMe: boolean 11 | ) { 12 | let session = await sessionStorage.getSession(request.headers.get("Cookie")); 13 | 14 | session.set(USER_ID_KEY, userId); 15 | 16 | return sessionStorage.commitSession(session, { 17 | maxAge: rememberMe ? 60 * 60 * 24 * 7 : undefined, 18 | }); 19 | } 20 | 21 | export async function verifyLogin( 22 | request: Request, 23 | sessionStorage: SessionStorage, 24 | redirects?: { 25 | success?: string; 26 | failure?: string; 27 | } 28 | ) { 29 | let session = await sessionStorage.getSession(request.headers.get("Cookie")); 30 | 31 | let userId = session.get(USER_ID_KEY); 32 | if (!userId && redirects?.failure) { 33 | throw redirect(redirects.failure); 34 | } 35 | if (userId && redirects?.success) { 36 | throw redirect(redirects.success); 37 | } 38 | 39 | return userId; 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Edmund Hung 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/utils.server.ts: -------------------------------------------------------------------------------- 1 | import { ZodError } from "zod"; 2 | import type zod from "zod"; 3 | 4 | interface ParsedErrors> { 5 | errors: Record & "global", string>; 6 | } 7 | 8 | interface ParsedData { 9 | data: Data; 10 | } 11 | 12 | export type ParsedResult< 13 | Schema extends zod.ZodEffects, 14 | Data = zod.infer 15 | > = ParsedErrors | ParsedData; 16 | 17 | export type Errors> = T extends ParsedResult< 18 | infer Schema 19 | > 20 | ? ParsedErrors 21 | : unknown; 22 | 23 | export async function tryParseFormData>( 24 | formData: FormData, 25 | schema: Schema 26 | ): Promise> { 27 | try { 28 | let data = await schema.parseAsync(formData); 29 | return { data }; 30 | } catch (err) { 31 | let errors: any = {}; 32 | if (err instanceof ZodError) { 33 | for (let issue of (err as ZodError).issues) { 34 | errors[issue.path.join(".")] = issue.message; 35 | } 36 | } else { 37 | errors.global = 38 | (err instanceof Error ? err.message : String(err)) || "unknown error"; 39 | } 40 | return { errors }; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /scripts/build.mjs: -------------------------------------------------------------------------------- 1 | import * as esbuild from "esbuild"; 2 | 3 | async function build() { 4 | // eslint-disable-next-line no-undef 5 | const mode = process.env.NODE_ENV?.toLowerCase() ?? "development"; 6 | // eslint-disable-next-line no-undef 7 | const version = process.env.VERSION ?? new Date().toISOString(); 8 | 9 | console.log(`Building Worker in ${mode} mode for version ${version}`); 10 | 11 | const outfile = "./dist/worker.mjs"; 12 | const startTime = Date.now(); 13 | const result = await esbuild.build({ 14 | entryPoints: ["./worker/index.ts"], 15 | bundle: true, 16 | minify: true, 17 | sourcemap: mode !== "production", 18 | format: "esm", 19 | metafile: true, 20 | external: ["__STATIC_CONTENT_MANIFEST"], 21 | define: { 22 | "process.env.NODE_ENV": `"${mode}"`, 23 | "process.env.VERSION": `"${version}"`, 24 | "process.env.REMIX_DEV_SERVER_WS_PORT": `""`, 25 | }, 26 | outfile, 27 | }); 28 | const endTime = Date.now(); 29 | 30 | console.log(`Built in ${endTime - startTime}ms`); 31 | 32 | if (mode === "production") { 33 | console.log(await esbuild.analyzeMetafile(result.metafile)); 34 | } 35 | } 36 | 37 | build().catch((e) => console.error("Unknown error caught during build:", e)); 38 | -------------------------------------------------------------------------------- /app/components/styled.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import cn from "classnames"; 3 | import type { Argument } from "classnames"; 4 | 5 | type ComponentType = 6 | | keyof JSX.IntrinsicElements 7 | | React.JSXElementConstructor; 8 | 9 | type StyledTagProps< 10 | TDefaultTag extends ComponentType, 11 | TTag extends ComponentType = TDefaultTag 12 | > = React.ComponentProps & { 13 | tag?: ComponentType; 14 | }; 15 | 16 | export function styledTag( 17 | defaultTag: TDefaultTag, 18 | baseClassName?: string, 19 | classNames?: ( 20 | props: TExtraProps & StyledTagProps 21 | ) => Argument, 22 | baseProps?: StyledTagProps 23 | ) { 24 | function StyledTag( 25 | props: TExtraProps & StyledTagProps, 26 | ref: any 27 | ) { 28 | const { tag, className, ...rest } = props as any; 29 | const Tag: any = tag || defaultTag; 30 | 31 | return ( 32 | 38 | ); 39 | } 40 | 41 | return React.forwardRef(StyledTag); 42 | } 43 | -------------------------------------------------------------------------------- /tests/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "./setup"; 2 | 3 | test.beforeEach(async ({ page }) => { 4 | await page.goto("/"); 5 | }); 6 | 7 | /** 8 | * You can interact with the page or browser through the page / queries 9 | */ 10 | test("if the page shows the package name", async ({ queries }) => { 11 | const elements = await queries.queryAllByText("remix-worker-template", { 12 | exact: false, 13 | }); 14 | 15 | expect(elements).toBeTruthy(); 16 | }); 17 | 18 | /** 19 | * You can interact with the miniflare instance as well 20 | * Like reading KV values saved with `mf.getKVNamespace('...')` 21 | * Or even interacting with the DO by `mf.getDurableObjectNamespace(...)` 22 | */ 23 | test("if the binding are set properly", async ({ mf }) => { 24 | const bindings = await mf.getBindings(); 25 | 26 | expect(bindings).toEqual({ 27 | SESSION_SECRET: expect.anything(), 28 | NOTE: expect.anything(), 29 | USER: expect.anything(), 30 | __STATIC_CONTENT: expect.anything(), 31 | __STATIC_CONTENT_MANIFEST: expect.anything(), 32 | }); 33 | }); 34 | 35 | /** 36 | * You can also mock the requests sent out from the workers 37 | * @see https://github.com/nodejs/undici/blob/main/docs/api/MockAgent.md 38 | */ 39 | test("if the request is sent", async ({ mockAgent }) => { 40 | const client = mockAgent.get("http://example.com"); 41 | 42 | client 43 | .intercept({ 44 | method: "GET", 45 | path: "/hello-world", 46 | }) 47 | .reply(200, { 48 | foo: "bar", 49 | }); 50 | 51 | // expect() something happens 52 | }); 53 | -------------------------------------------------------------------------------- /worker/index.ts: -------------------------------------------------------------------------------- 1 | import { createCookieSessionStorage } from "remix"; 2 | 3 | import { createFetchHandler, createWorkerAssetHandler } from "./adapter"; 4 | 5 | import type { CloudflareEnvironment, Context } from "../app/context.server"; 6 | 7 | // @ts-ignore 8 | import * as build from "../build/index.js"; 9 | 10 | export { NoteDurableObject } from "../app/durable-objects/note.server"; 11 | export { UserDurableObject } from "../app/durable-objects/user.server"; 12 | 13 | const handleFetch = createFetchHandler({ 14 | /** 15 | * Required: Remix build files 16 | */ 17 | build: build as any, 18 | 19 | /** 20 | * Optional: Context to be available on `loader` or `action`, default to `undefined` if not defined 21 | * @param request Request 22 | * @param env Variables defined for the environment 23 | * @param ctx Exectuion context, i.e. ctx.waitUntil() or ctx.passThroughOnException(); 24 | * @returns Context 25 | */ 26 | getLoadContext(request, env, ctx): Context { 27 | let sessionStorage = createCookieSessionStorage({ 28 | cookie: { 29 | isSigned: true, 30 | httpOnly: true, 31 | name: "__session", 32 | path: "/", 33 | sameSite: "lax", 34 | secrets: [env.SESSION_SECRET], 35 | }, 36 | }); 37 | 38 | return { env, ctx, sessionStorage }; 39 | }, 40 | 41 | /** 42 | * Required: Setup how the assets are served 43 | * 1) Call `createWorkerAssetHandler(build)` when using Worker Site 44 | * 2) Call `createPageAssetHandler()` when using Pages 45 | */ 46 | handleAsset: createWorkerAssetHandler(build as any), 47 | 48 | /** 49 | * Optional: Enable cache for response from the Remix request handler, no cache by default 50 | * Experimental feature - Let me know if you run into problems with cache enabled 51 | */ 52 | enableCache: false, 53 | }); 54 | 55 | const worker: ExportedHandler = { 56 | fetch: handleFetch, 57 | }; 58 | 59 | export default worker; 60 | -------------------------------------------------------------------------------- /app/durable-objects/note.server.ts: -------------------------------------------------------------------------------- 1 | import { json } from "remix"; 2 | import { v4 as uuidV4 } from "uuid"; 3 | import { zfd } from "zod-form-data"; 4 | 5 | import { CloudflareEnvironment } from "../context.server"; 6 | import { tryParseFormData } from "../utils.server"; 7 | import type { ParsedResult } from "../utils.server"; 8 | 9 | export interface Note { 10 | id: string; 11 | title: string; 12 | body: string; 13 | createdAt: number; 14 | } 15 | 16 | let noteSchema = zfd.formData({ 17 | title: zfd.text(), 18 | body: zfd.text(), 19 | }); 20 | 21 | export type NoteResult = ParsedResult; 22 | 23 | export class NoteDurableObject { 24 | private notes?: Note[]; 25 | 26 | constructor( 27 | private state: DurableObjectState, 28 | private env: CloudflareEnvironment 29 | ) {} 30 | 31 | async fetch(request: Request): Promise { 32 | let url = new URL(request.url); 33 | let method = request.method.toLowerCase(); 34 | 35 | if (method === "get" && url.pathname === "/") { 36 | if (!this.notes) { 37 | this.notes = Array.from( 38 | (await this.state.storage.list({ reverse: true })).values() 39 | ); 40 | } 41 | 42 | return json(this.notes.sort((a, b) => b.createdAt - a.createdAt)); 43 | } 44 | if (method === "delete") { 45 | let id = url.pathname.slice(1); 46 | this.notes = this.notes?.filter((n) => n.id !== id); 47 | await this.state.storage.delete(id); 48 | return json({}); 49 | } 50 | if (method === "post" && url.pathname === "/") { 51 | let parsed = await tryParseFormData(await request.formData(), noteSchema); 52 | 53 | if ("errors" in parsed) { 54 | return json(parsed, { status: 400 }); 55 | } 56 | let id = uuidV4(); 57 | let note = { 58 | id, 59 | createdAt: Date.now(), 60 | ...parsed.data, 61 | }; 62 | this.notes?.push(note); 63 | await this.state.storage.put(id, note); 64 | return json({ data: note }); 65 | } 66 | 67 | return json({ errors: { global: "not found" } }, { status: 404 }); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { json, Link, Form, useLoaderData } from "remix"; 2 | 3 | import type { LoaderFunction } from "~/context.server"; 4 | import { verifyLogin } from "~/session.server"; 5 | 6 | import { DefaultButton } from "~/components/buttons"; 7 | 8 | interface LoaderData { 9 | displayName: string | null; 10 | } 11 | 12 | export let loader: LoaderFunction = async ({ 13 | request, 14 | context: { 15 | sessionStorage, 16 | env: { USER }, 17 | }, 18 | }) => { 19 | let userId = await verifyLogin(request, sessionStorage); 20 | 21 | let profile: any; 22 | if (userId) { 23 | let id = USER.idFromName(userId); 24 | let obj = USER.get(id); 25 | let profileResponse = await obj.fetch("/profile"); 26 | profile = await profileResponse.json(); 27 | } 28 | 29 | return json({ 30 | displayName: profile?.displayName, 31 | }); 32 | }; 33 | 34 | export default function Index() { 35 | let { displayName } = useLoaderData(); 36 | 37 | return ( 38 |
39 |

40 | {displayName ? `Welcome ${displayName}!` : "remix-worker-template"} 41 |

42 |

43 | All-in-one remix starter template for Cloudflare Workers. 44 |

45 | 46 |
47 | {displayName ? ( 48 | <> 49 | 50 | Notes 51 | 52 | 53 | 54 | Profile 55 | 56 | 57 |
58 | 59 | Logout 60 | 61 |
62 | 63 | ) : ( 64 | <> 65 | 66 | Login 67 | 68 | 69 | 70 | Signup 71 | 72 | 73 | )} 74 |
75 |
76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /tests/setup.ts: -------------------------------------------------------------------------------- 1 | import { test as base, expect as baseExpect } from "@playwright/test"; 2 | import type { Expect } from "@playwright/test"; 3 | import { 4 | fixtures, 5 | TestingLibraryFixtures, 6 | } from "@playwright-testing-library/test/fixture"; 7 | import { Miniflare } from "miniflare"; 8 | import { MockAgent, setGlobalDispatcher } from "undici"; 9 | 10 | interface TestFixtures extends TestingLibraryFixtures { 11 | mockAgent: MockAgent; 12 | } 13 | 14 | interface WorkerFixtures { 15 | mf: Miniflare; 16 | port: number; 17 | } 18 | 19 | let expect = baseExpect as Expect & jest.Expect; 20 | 21 | export { expect }; 22 | 23 | export const test = base.extend({ 24 | // Setup queries from playwright-testing-library 25 | ...fixtures, 26 | 27 | // Assign a unique "port" for each worker process 28 | port: [ 29 | // eslint-disable-next-line no-empty-pattern 30 | async ({}, use, workerInfo) => { 31 | await use(3001 + workerInfo.workerIndex); 32 | }, 33 | { scope: "worker" }, 34 | ], 35 | 36 | // Ensure visits works with relative path 37 | baseURL: ({ port }, use) => { 38 | use(`http://localhost:${port}`); 39 | }, 40 | 41 | // Setup mock client for requests initiated by the Worker 42 | mockAgent: 43 | // eslint-disable-next-line no-empty-pattern 44 | async ({}, use) => { 45 | const mockAgent = new MockAgent(); 46 | 47 | // Optional: This makes all the request fails if no matching mock is found 48 | // mockAgent.disableNetConnect(); 49 | 50 | setGlobalDispatcher(mockAgent); 51 | 52 | await use(mockAgent); 53 | }, 54 | 55 | // Miniflare instance 56 | mf: [ 57 | async ({ port }, use) => { 58 | const mf = new Miniflare({ 59 | wranglerConfigPath: true, 60 | buildCommand: undefined, 61 | bindings: { 62 | SESSION_SECRET: "secret", 63 | }, 64 | port, 65 | }); 66 | 67 | // Start the server. 68 | let server = await mf.startServer(); 69 | 70 | // Use the server in the tests. 71 | await use(mf); 72 | 73 | // Cleanup. 74 | await new Promise((resolve, reject) => { 75 | server.close((error) => (error ? reject(error) : resolve())); 76 | }); 77 | }, 78 | { scope: "worker", auto: true }, 79 | ], 80 | }); 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # remix-worker-template 2 | 3 | [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/edmundhung/remix-worker-template) 4 | 5 | > The current starter template is based on Remix 1.3.2 6 | 7 | - [Repository](https://github.com/remix-run/remix) 8 | - [Remix Docs](https://remix.run/docs) 9 | 10 | ## Differences with the Official CF Workers template 11 | 12 | While the official template provides the bare minimums for you to kickstart a Remix app running on Cloudflare Workers, this starter template adds a few extra tools that are common for development and let you be productive right away. These tools include: 13 | 14 | - Tailwind 15 | - Playwright 16 | - ESLint 17 | - Prettier 18 | 19 | In addition, it is now setup using a custom adapter based on the official **Cloudflare Pages adapter**. This allows us running a [module worker](https://developers.cloudflare.com/workers/learning/migrating-to-module-workers/) with supports of `Durable Objects`. This is a temporay workaround until an official update is landed on the CF Worker adapter. 20 | 21 | ## Node Version 22 | 23 | Please make sure the node version is **>= 16.7**. If you are using `nvm`, just run: 24 | 25 | ```sh 26 | nvm use 27 | ``` 28 | 29 | This allows [miniflare](https://github.com/cloudflare/miniflare) to serve a development environment as close to the actual worker runtime as possibile. 30 | 31 | ## Development 32 | 33 | To starts your app in development mode, rebuilding assets on file changes, the recommended approach is: 34 | 35 | ```sh 36 | npm run dev 37 | ``` 38 | 39 | This will run your remix app in dev mode using miniflare. 40 | 41 | ## Testing 42 | 43 | Before running the tests, please ensure the worker is built: 44 | 45 | ```sh 46 | npm run build && npm run test 47 | ``` 48 | 49 | ## Deployment 50 | 51 | To deploy your Remix app, simply do it with Wrangler using: 52 | 53 | ```sh 54 | npx wrangler publish 55 | ``` 56 | 57 | ## CI/CD 58 | 59 | The template ships a [development workflow](./.github/workflows/development.yml) which is triggered whenever new changes are pushed. 60 | 61 | To allow GitHub deploying the worker for you, following variables are required: 62 | 63 | - CF_API_TOKEN 64 | - CF_ACCOUNT_ID 65 | 66 | These values could be found / created on your Cloudflare Dashboard. If your project is bootstrapped with the deploy button above, both should be already set in the repository. 67 | 68 | Alternatively, **CF_ACCOUNT_ID** can be set as `account_id` on the [wrangler.toml](./wrangler.toml). 69 | -------------------------------------------------------------------------------- /app/routes/notes.new.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | import { json, Form, useActionData, redirect } from "remix"; 3 | 4 | import type { ActionFunction, LoaderFunction } from "~/context.server"; 5 | 6 | import { verifyLogin } from "~/session.server"; 7 | 8 | import { DefaultButton } from "~/components/buttons"; 9 | import { Input, InputError, Label, Textarea } from "~/components/forms"; 10 | 11 | export let loader: LoaderFunction = async ({ request, context }) => { 12 | await verifyLogin(request, context.sessionStorage, { 13 | failure: "/login", 14 | }); 15 | 16 | return json({}); 17 | }; 18 | 19 | interface ActionData { 20 | errors?: { 21 | title?: string; 22 | body?: string; 23 | }; 24 | } 25 | 26 | export let action: ActionFunction = async ({ 27 | request, 28 | context: { 29 | env: { NOTE }, 30 | sessionStorage, 31 | }, 32 | }) => { 33 | let userId = await verifyLogin(request, sessionStorage, { 34 | failure: "/login", 35 | }); 36 | 37 | let id = NOTE.idFromName(userId); 38 | let obj = NOTE.get(id); 39 | 40 | let createNoteResponse = await obj.fetch("/", request.clone()); 41 | let actionData = await createNoteResponse.json(); 42 | 43 | if (actionData.errors) { 44 | return json(actionData); 45 | } 46 | 47 | return redirect(`/notes`); 48 | }; 49 | 50 | export default function NewNote() { 51 | let { errors } = useActionData() || {}; 52 | let titleInputRef = useRef(null); 53 | let bodyInputRef = useRef(null); 54 | 55 | useEffect(() => { 56 | if (errors?.title) { 57 | titleInputRef.current?.focus(); 58 | } else if (errors?.body) { 59 | bodyInputRef.current?.focus(); 60 | } 61 | }, [errors]); 62 | 63 | return ( 64 |
65 |

New note

66 | 67 |
68 | 78 | 79 |