24 | >() {
25 | static Live = Layer.effect(this, makeTodoRepo);
26 | }
27 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": [
3 | "remix.env.d.ts",
4 | "**/*.ts",
5 | "**/*.tsx",
6 | "vite.config.js"
7 | ],
8 | "compilerOptions": {
9 | "lib": [
10 | "DOM",
11 | "DOM.Iterable",
12 | "ES2022"
13 | ],
14 | "isolatedModules": true,
15 | "esModuleInterop": true,
16 | "jsx": "react-jsx",
17 | "moduleResolution": "Bundler",
18 | "resolveJsonModule": true,
19 | "target": "ES2022",
20 | "module": "ES2022",
21 | "strict": true,
22 | "allowJs": true,
23 | "forceConsistentCasingInFileNames": true,
24 | "baseUrl": ".",
25 | "paths": {
26 | "~/*": [
27 | "./app/*"
28 | ]
29 | },
30 | "skipLibCheck": true,
31 | // Remix takes care of building everything in `remix build`.
32 | "noEmit": true
33 | }
34 | }
--------------------------------------------------------------------------------
/flake.nix:
--------------------------------------------------------------------------------
1 | {
2 | inputs = {
3 | nixpkgs = {
4 | url = "github:nixos/nixpkgs/nixpkgs-unstable";
5 | };
6 |
7 | flake-utils = {
8 | url = "github:numtide/flake-utils";
9 | };
10 | };
11 |
12 | outputs = {
13 | self,
14 | nixpkgs,
15 | flake-utils,
16 | ...
17 | }:
18 | flake-utils.lib.eachDefaultSystem (system: let
19 | pkgs = nixpkgs.legacyPackages.${system};
20 | corepackEnable = pkgs.runCommand "corepack-enable" {} ''
21 | mkdir -p $out/bin
22 | ${pkgs.nodejs-18_x}/bin/corepack enable --install-directory $out/bin
23 | '';
24 | in {
25 | formatter = pkgs.alejandra;
26 |
27 | devShells = {
28 | default = pkgs.mkShell {
29 | buildInputs = with pkgs; [
30 | nodejs-18_x
31 | corepackEnable
32 | ];
33 | };
34 | };
35 | });
36 | }
37 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Effectful Technologies Inc
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.
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vite-remix",
3 | "private": true,
4 | "sideEffects": false,
5 | "type": "module",
6 | "license": "MIT",
7 | "scripts": {
8 | "build": "vite build && vite build --ssr",
9 | "dev": "NODE_ENV=development node scripts/dev.js",
10 | "start": "remix-serve build/index.js",
11 | "typecheck": "tsc",
12 | "clean": "rm -rf build && rm -rf public/build && rm -rf node_modules/.vite"
13 | },
14 | "dependencies": {
15 | "@effect/schema": "^0.66.10",
16 | "@remix-run/react": "^2.9.1",
17 | "@remix-run/serve": "^2.9.1",
18 | "dotenv": "^16.4.5",
19 | "effect": "^3.0.7",
20 | "isbot": "^5.1.5",
21 | "react": "^18.3.1",
22 | "react-dom": "^18.3.1",
23 | "todomvc-app-css": "^2.4.3",
24 | "todomvc-common": "^1.0.5"
25 | },
26 | "devDependencies": {
27 | "@babel/preset-typescript": "^7.24.1",
28 | "@remix-run/dev": "^2.9.1",
29 | "@remix-run/eslint-config": "^2.9.1",
30 | "@remix-run/node": "^2.9.1",
31 | "@types/react": "^18.3.1",
32 | "@types/react-dom": "^18.3.0",
33 | "babel-plugin-annotate-pure-calls": "^0.4.0",
34 | "eslint": "^9.1.1",
35 | "terser": "^5.30.4",
36 | "typescript": "^5.4.5",
37 | "vite": "^4.5.0",
38 | "vite-plugin-babel": "^1.2.0",
39 | "vite-tsconfig-paths": "^4.3.2"
40 | },
41 | "engines": {
42 | "node": ">=18.0.0"
43 | }
44 | }
--------------------------------------------------------------------------------
/flake.lock:
--------------------------------------------------------------------------------
1 | {
2 | "nodes": {
3 | "flake-utils": {
4 | "inputs": {
5 | "systems": "systems"
6 | },
7 | "locked": {
8 | "lastModified": 1694529238,
9 | "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
10 | "owner": "numtide",
11 | "repo": "flake-utils",
12 | "rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
13 | "type": "github"
14 | },
15 | "original": {
16 | "owner": "numtide",
17 | "repo": "flake-utils",
18 | "type": "github"
19 | }
20 | },
21 | "nixpkgs": {
22 | "locked": {
23 | "lastModified": 1697915759,
24 | "narHash": "sha256-WyMj5jGcecD+KC8gEs+wFth1J1wjisZf8kVZH13f1Zo=",
25 | "owner": "nixos",
26 | "repo": "nixpkgs",
27 | "rev": "51d906d2341c9e866e48c2efcaac0f2d70bfd43e",
28 | "type": "github"
29 | },
30 | "original": {
31 | "owner": "nixos",
32 | "ref": "nixpkgs-unstable",
33 | "repo": "nixpkgs",
34 | "type": "github"
35 | }
36 | },
37 | "root": {
38 | "inputs": {
39 | "flake-utils": "flake-utils",
40 | "nixpkgs": "nixpkgs"
41 | }
42 | },
43 | "systems": {
44 | "locked": {
45 | "lastModified": 1681028828,
46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
47 | "owner": "nix-systems",
48 | "repo": "default",
49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
50 | "type": "github"
51 | },
52 | "original": {
53 | "owner": "nix-systems",
54 | "repo": "default",
55 | "type": "github"
56 | }
57 | }
58 | },
59 | "root": "root",
60 | "version": 7
61 | }
62 |
--------------------------------------------------------------------------------
/app/routes/_index.tsx:
--------------------------------------------------------------------------------
1 | import type { MetaFunction } from "@remix-run/node";
2 | import { useLoaderData } from "@remix-run/react";
3 |
4 | import "todomvc-app-css/index.css";
5 | import "todomvc-common/base.css";
6 |
7 | import { TodoRepo } from "~/services/TodoRepo";
8 | import { loaderFunction } from "~/services/index";
9 | import { Todo } from "../types/Todo";
10 |
11 | export const meta: MetaFunction = () => {
12 | return [
13 | { title: "Remixing Effect" },
14 | {
15 | name: "description",
16 | content: "Integrate Effect & Remix for the greater good!",
17 | },
18 | ];
19 | };
20 |
21 | export const TodoRow = ({ todo }: { todo: Todo.Encoded }) => {
22 | const isCompleted = todo.status === "COMPLETED";
23 | return (
24 |
25 |
26 |
32 |
33 |
34 |
35 |
36 | );
37 | };
38 |
39 | export const AddTodoForm = () => {
40 | return (
41 |
49 | );
50 | };
51 |
52 | export const loader = loaderFunction(() => TodoRepo.getAllTodos);
53 |
54 | export default function Index() {
55 | const todos = useLoaderData();
56 |
57 | return (
58 |
73 | );
74 | }
75 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Using Effect with Remix
2 |
3 | - [Remix Docs](https://remix.run/docs)
4 | - [Effect Docs](https://effect.website/docs/introduction)
5 | - [Vite Docs](https://vitejs.dev/guide/)
6 |
7 | ## Project Goals
8 |
9 | Prototype an integration of Remix + Effect in such a way that the Effect runtime is only ever executed in the backend while keeping the frontend code minimal and fully type safe.
10 |
11 | This project aims to demonstrate integration of Effect in one typical setup where the user is not in control of the program entrypoints that are delegated to frameworks, similar scenarios are for example the usage of Next.js or equivalent frameworks.
12 |
13 | ## Development
14 |
15 | From your terminal:
16 |
17 | ```sh
18 | pnpm run dev
19 | ```
20 |
21 | This starts your app in development mode, rebuilding assets on file changes.
22 |
23 | ## Deployment
24 |
25 | First, build your app for production:
26 |
27 | ```sh
28 | pnpm run build
29 | ```
30 |
31 | Then run the app in production mode:
32 |
33 | ```sh
34 | pnpm run start
35 | ```
36 |
37 | Now you'll need to pick a host to deploy it to.
38 |
39 | ## Telemetry
40 |
41 | If you want to see telemetry data this project is configured to work with [https://www.honeycomb.io/](https://www.honeycomb.io/), create an account if you don't have one (they have a very nice free tier) and write your project name & api key in a file called `.env`, follow the template `.env.template`
42 |
43 | Note: if you want to change the backend you use for tracing, for example using your own [grafana tempo](https://grafana.com/oss/tempo/) you'll need to edit `/services/Tracing.ts` accordingly (see [https://github.com/Effect-TS/opentelemetry](https://github.com/Effect-TS/opentelemetry)).
44 |
45 | ## Project Setup
46 |
47 | This project uses a nightly build of remix in order to use it together with vite. Apart from that it looks like a normal vite project.
48 |
49 | The key configurations for vite are found in the `vite.config.ts` file that looks like:
50 |
51 | ```ts
52 | import { unstable_vitePlugin as remix } from "@remix-run/dev";
53 | import { defineConfig } from "vite";
54 | import babel from "vite-plugin-babel";
55 | import tsconfigPaths from "vite-tsconfig-paths";
56 |
57 | export default defineConfig({
58 | plugins: [
59 | babel({
60 | filter: new RegExp(/\.tsx?$/),
61 | }),
62 | remix(),
63 | tsconfigPaths(),
64 | ],
65 | build: {
66 | outDir: "build",
67 | copyPublicDir: false,
68 | minify: "terser",
69 | },
70 | publicDir: "./public",
71 | });
72 | ```
73 |
74 | Namely here we are setting up Remix together with babel, in babel we use a plugin to annotate pure calls so that we can tree-shake loaders and actions that use higher order functions.
75 |
76 | In short this setup enables us to use and tree-shake the following pattern:
77 |
78 | ```tsx
79 | export const loader = effectLoader(effect);
80 | export const action = effectAction(effect);
81 |
82 | export default function Component() {
83 | // plain component
84 | }
85 | ```
86 |
87 | ## Code Structure
88 |
89 | The project uses 4 main libraries of the effect ecosystem:
90 |
91 | - `effect` to handle all effectful operations
92 | - `@effect/schema` to define data models and handle serialization
93 | - `@effect/opentelemetry` to integrate with a telemetry dashboard
94 | - `@sqlfx/sqlite` to integrate with sqlite
95 |
96 | As of telemetry for simplicity we are using [https://www.honeycomb.io/](https://www.honeycomb.io/) but any open telemetry compatible service will work with minor changes to the code
97 |
98 | The directories are structured in the following way:
99 |
100 | - `/app` contains all the app code
101 | - `/lib` contains integration code with effect (and a temporary hack to load opentelemetry from esm due to improper es modules)
102 | - `/migrations` contains all the database migration code, those are automatically loaded by the sql client
103 | - `/routes` contains the remix routes, loaders and actions
104 | - `/services` contains the business logic encapsulated in effect services
105 | - `/database` contains the sqlite files
106 |
--------------------------------------------------------------------------------
/app/entry.server.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * By default, Remix will handle generating the HTTP Response for you.
3 | * 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` ✨
4 | * For more information, see https://remix.run/file-conventions/entry.server
5 | */
6 | import "dotenv/config";
7 |
8 | import { PassThrough } from "node:stream";
9 |
10 | import type { EntryContext } from "@remix-run/node";
11 | import { createReadableStreamFromReadable } from "@remix-run/node";
12 | import { RemixServer } from "@remix-run/react";
13 | import { isbot } from "isbot";
14 | import { renderToPipeableStream } from "react-dom/server";
15 |
16 | const ABORT_DELAY = 15_000;
17 |
18 | export default async function handleRequest(
19 | request: Request,
20 | responseStatusCode: number,
21 | responseHeaders: Headers,
22 | remixContext: EntryContext
23 | ) {
24 | return isbot(request.headers.get("user-agent"))
25 | ? handleBotRequest(
26 | request,
27 | responseStatusCode,
28 | responseHeaders,
29 | remixContext
30 | )
31 | : handleBrowserRequest(
32 | request,
33 | responseStatusCode,
34 | responseHeaders,
35 | remixContext
36 | );
37 | }
38 |
39 | function handleBotRequest(
40 | request: Request,
41 | responseStatusCode: number,
42 | responseHeaders: Headers,
43 | remixContext: EntryContext
44 | ) {
45 | return new Promise((resolve, reject) => {
46 | let shellRendered = false;
47 | const { pipe, abort } = renderToPipeableStream(
48 | ,
53 | {
54 | onAllReady() {
55 | shellRendered = true;
56 | const body = new PassThrough();
57 | const stream = createReadableStreamFromReadable(body);
58 |
59 | responseHeaders.set("Content-Type", "text/html");
60 |
61 | resolve(
62 | new Response(stream, {
63 | headers: responseHeaders,
64 | status: responseStatusCode,
65 | })
66 | );
67 |
68 | pipe(body);
69 | },
70 | onShellError(error: unknown) {
71 | reject(error);
72 | },
73 | onError(error: unknown) {
74 | responseStatusCode = 500;
75 | // Log streaming rendering errors from inside the shell. Don't log
76 | // errors encountered during initial shell rendering since they'll
77 | // reject and get logged in handleDocumentRequest.
78 | if (shellRendered) {
79 | console.error(error);
80 | }
81 | },
82 | }
83 | );
84 |
85 | setTimeout(abort, ABORT_DELAY);
86 | });
87 | }
88 |
89 | function handleBrowserRequest(
90 | request: Request,
91 | responseStatusCode: number,
92 | responseHeaders: Headers,
93 | remixContext: EntryContext
94 | ) {
95 | return new Promise((resolve, reject) => {
96 | let shellRendered = false;
97 | const { pipe, abort } = renderToPipeableStream(
98 | ,
103 | {
104 | onShellReady() {
105 | shellRendered = true;
106 | const body = new PassThrough();
107 | const stream = createReadableStreamFromReadable(body);
108 |
109 | responseHeaders.set("Content-Type", "text/html");
110 |
111 | resolve(
112 | new Response(stream, {
113 | headers: responseHeaders,
114 | status: responseStatusCode,
115 | })
116 | );
117 |
118 | pipe(body);
119 | },
120 | onShellError(error: unknown) {
121 | reject(error);
122 | },
123 | onError(error: unknown) {
124 | responseStatusCode = 500;
125 | // Log streaming rendering errors from inside the shell. Don't log
126 | // errors encountered during initial shell rendering since they'll
127 | // reject and get logged in handleDocumentRequest.
128 | if (shellRendered) {
129 | console.error(error);
130 | }
131 | },
132 | }
133 | );
134 |
135 | setTimeout(abort, ABORT_DELAY);
136 | });
137 | }
138 |
--------------------------------------------------------------------------------