├── .commitlintrc.json ├── .github └── workflows │ └── CI.yml ├── .gitignore ├── .husky ├── commit-msg ├── pre-commit └── pre-push ├── .releaserc.json ├── LICENSE ├── README.md ├── package.json ├── src ├── __tests__ │ └── index.test.ts └── index.ts ├── tsconfig.json ├── usage ├── app │ └── routes │ │ └── index.tsx ├── build.ts └── server.js ├── vitest.config.ts └── yarn.lock /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@commitlint/config-conventional" 4 | ] 5 | } -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - "*" 9 | jobs: 10 | # Test: 11 | # runs-on: ${{ matrix.os }} 12 | # strategy: 13 | # matrix: 14 | # node-version: 15 | # - 16.x 16 | # os: 17 | # - ubuntu-latest 18 | # steps: 19 | # - uses: actions/checkout@v2 20 | # - name: Use Node.js ${{matrix.node-version}} 21 | # uses: actions/setup-node@v2 22 | # with: 23 | # node-version: ${{ matrix.node-version }} 24 | # - uses: actions/cache@v2 25 | # with: 26 | # path: "**/node_modules" 27 | # key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} 28 | # - name: Install dependencies 29 | # run: yarn install --frozen-lockfile 30 | # - name: Run Tests 🧪 31 | # run: yarn run coverage 32 | # - name: Report coverage (client) 📏 33 | # uses: codecov/codecov-action@v2 34 | # with: 35 | # token: ${{ secrets.CODECOV_TOKEN }} 36 | # working-directory: ./packages/client 37 | # flags: client 38 | 39 | Publish: 40 | runs-on: ubuntu-latest 41 | # needs: 42 | # - Test 43 | if: ${{ github.ref == 'refs/heads/main' }} 44 | steps: 45 | - uses: actions/checkout@v2 46 | - name: Use Node.js 47 | uses: actions/setup-node@v2 48 | with: 49 | node-version: 16.x 50 | registry-url: https://registry.npmjs.org 51 | - uses: actions/cache@v2 52 | with: 53 | path: "node_modules" 54 | key: ${{ runner.os }}-modules-${{ hashFiles('yarn.lock') }} 55 | - name: Install dependencies 56 | run: yarn install --frozen-lockfile 57 | - name: Build 🏗 58 | run: yarn build 59 | - name: Publish 🚀 60 | run: yarn run semantic-release 61 | env: 62 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 63 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules/ 3 | *.log 4 | dist 5 | coverage 6 | build/ -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn commitlint --edit -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run test:run -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branches": ["main", {"name": "beta", "prerelease": true}] 3 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 AijiUejima 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # remix-service-bindings 2 | 3 | This is a plugin for using cloudflare workers [service bindings](https://developers.cloudflare.com/workers/learning/using-services/) in Remix. 4 | 5 | ![](https://user-images.githubusercontent.com/6711766/168193222-d314552a-9e02-419f-85b7-2fdaf2ff3087.png) 6 | 7 | **Script size must be kept under 1 megabyte to deploy to Cloudflare Workers. By splitting services and connecting them with service bindings, they are freed from that limitation.** 8 | 9 | Automatically split scripts during production deployment and deploy to two workers. 10 | 11 | One side receives access at the edge. But it does not have loader and action logic, it just SSRs the React component. 12 | The other holds the loader and action logic on behalf of the edge and is called from the edge by the service binding. 13 | In other words, the bundle size per worker can be reduced because it is automatically divided into two groups: workers with design-related libraries, such as UI libraries, and workers with logic and libraries for processing server-side data. 14 | 15 | This worker isolation process is handled by esbuild plug-ins, so the developer does not need to be aware of any control over it. 16 | 17 | ![](https://user-images.githubusercontent.com/6711766/168193751-8ee86790-6a72-4a95-b0b1-8c89e5e199fe.png) 18 | 19 | ## Install 20 | 21 | You need `wrangler >= 2.0.0`. 22 | 23 | ```bash 24 | npm install -D wrangler remix-service-bindings remix-esbuild-override 25 | ``` 26 | 27 | ## Setup 28 | 29 | ```js 30 | // remix.config.js 31 | const { withEsbuildOverride } = require("remix-esbuild-override"); 32 | const remixServiceBindings = require("remix-service-bindings").default; 33 | 34 | withEsbuildOverride((option, { isServer }) => { 35 | if (isServer) { 36 | option.plugins = [ 37 | /** 38 | * remixServiceBindings 39 | * @param isEdgeSide {boolean} - When this is true, the build is for edge (binder) and when false, the build is for bindee. 40 | * (Deployment (build) must be done in two parts.) 41 | * @param bindingsName {string} - The bind name set in toml. This name will be converted to a bind object. 42 | * @param enabled {boolean} - If this is false, this plugin is disabled. 43 | */ 44 | remixServiceBindings(!process.env.BINDEE, "BINDEE", !!process.env.DEPLOY), 45 | ...option.plugins, 46 | ]; 47 | } 48 | 49 | return option; 50 | }); 51 | 52 | /** 53 | * @type {import('@remix-run/dev').AppConfig} 54 | */ 55 | module.exports = { 56 | serverBuildTarget: "cloudflare-workers", 57 | server: "./server.js", 58 | devServerBroadcastDelay: 1000, 59 | ignoredRouteFiles: ["**/.*"], 60 | // appDirectory: "app", 61 | // assetsBuildDirectory: "public/build", 62 | // serverBuildPath: "build/index.js", 63 | // publicPath: "/build/", 64 | }; 65 | ``` 66 | 67 | server.js 68 | 69 | ```js 70 | import { createEventHandler } from "@remix-run/cloudflare-workers"; 71 | import * as build from "@remix-run/dev/server-build"; 72 | 73 | addEventListener( 74 | "fetch", 75 | createEventHandler({ 76 | build, 77 | mode: process.env.NODE_ENV, 78 | getLoadContext: (event) => { 79 | return { event }; 80 | }, 81 | }) 82 | ); 83 | ``` 84 | 85 | wrangler.edge.toml 86 | 87 | ```toml 88 | # wrangler.toml 89 | name = "your-service-name" 90 | 91 | compatibility_date = "2022-05-11" 92 | 93 | account_id = "" 94 | workers_dev = true 95 | main = "./build/index.js" 96 | 97 | [[unsafe.bindings]] 98 | name = "BINDEE" 99 | type = "service" 100 | service = "your-bindee-service-name" 101 | environment = "production" 102 | 103 | [site] 104 | bucket = "./public" 105 | 106 | [build] 107 | command = "DEPLOY=true npm run build" 108 | ``` 109 | 110 | wrangler.bindee.toml 111 | 112 | ```toml 113 | # wrangler.bindee.toml 114 | name = "your-bindee-service-name" 115 | 116 | compatibility_date = "2022-05-11" 117 | 118 | account_id = "" 119 | workers_dev = true 120 | main = "./build/index.js" 121 | 122 | [site] 123 | bucket = "./public" 124 | 125 | [build] 126 | command = "DEPLOY=true BINDEE=true npm run build" 127 | ``` 128 | 129 | package.json 130 | 131 | ```json 132 | "scripts": { 133 | "deploy:edge": "wrangler publish -c wrangler.edge.toml", 134 | "deploy:bindee": "wrangler publish -c wrangler.bindee.toml", 135 | } 136 | ``` 137 | 138 | ## Deploy 139 | 140 | ```bash 141 | npm run deploy:bindee 142 | rm -rf public/build 143 | npm run deploy:edge 144 | ``` 145 | 146 | ## License 147 | 148 | This project is licensed under the MIT License - see the [LICENSE](https://github.com/aiji42/remix-service-bindings/blob/main/LICENSE) file for details 149 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix-service-bindings", 3 | "version": "0.0.0", 4 | "description": "This is a plugin for using cloudflare workers service bindings in Remix.", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "files": [ 8 | "dist" 9 | ], 10 | "repository": "https://github.com/aiji42/remix-service-bindings.git", 11 | "author": "aiji42 (https://twitter.com/aiji42_dev)", 12 | "license": "MIT", 13 | "scripts": { 14 | "build": "tsc", 15 | "format": "prettier -w src", 16 | "semantic-release": "semantic-release", 17 | "prepare": "husky install", 18 | "test": "vitest", 19 | "test:run": "vitest run", 20 | "test:coverage": "vitest run --coverage", 21 | "build:usage": "node -r esbuild-register usage/build.ts" 22 | }, 23 | "devDependencies": { 24 | "@commitlint/cli": "^16.2.3", 25 | "@commitlint/config-conventional": "^16.2.1", 26 | "@types/node": "^17.0.25", 27 | "c8": "^7.11.2", 28 | "esbuild": "^0.14.39", 29 | "esbuild-register": "^3.3.2", 30 | "husky": "^8.0.1", 31 | "lint-staged": "^12.4.0", 32 | "prettier": "^2.6.2", 33 | "semantic-release": "^19.0.2", 34 | "semantic-release-cli": "^5.4.4", 35 | "typescript": "^4.6.3", 36 | "vitest": "^0.12.5" 37 | }, 38 | "lint-staged": { 39 | "*.{js,ts,md,json}": "prettier --write" 40 | }, 41 | "dependencies": { 42 | "ts-morph": "^14.0.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "vitest"; 2 | 3 | test.skip("FIXME: write test"); 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Project, VariableDeclarationKind } from "ts-morph"; 2 | import path from "path"; 3 | import fs from "fs"; 4 | import type { Loader, Plugin } from "esbuild"; 5 | 6 | const project = new Project({ 7 | tsConfigFilePath: path.join(process.cwd(), "tsconfig.json"), 8 | }); 9 | 10 | const plugin = ( 11 | isEdge: boolean, 12 | bindingName: string, 13 | active = false, 14 | option?: { 15 | appDirectory?: string; 16 | } 17 | ): Plugin => ({ 18 | name: "remix-service-bindings", 19 | setup(build) { 20 | if (!active) return; 21 | const appRoot = (option?.appDirectory || "app").replace(/^\/|\/$/g, ""); 22 | const filter = new RegExp( 23 | `${appRoot}/(routes/.*|root|entry\\.server)\\.[jt]sx?$` 24 | ); 25 | build.onLoad( 26 | { 27 | filter, 28 | }, 29 | async (args) => { 30 | const { ext, name } = path.parse(args.path); 31 | if (isEdge && name.startsWith("__")) return; 32 | 33 | const src = project.createSourceFile( 34 | `tmp${ext}`, 35 | fs.readFileSync(args.path, "utf8"), 36 | { overwrite: true } 37 | ); 38 | 39 | if (isEdge) { 40 | const replaced: string[] = []; 41 | src.getExportedDeclarations().forEach((node, key) => { 42 | if (["loader", "action"].includes(key)) { 43 | node.forEach((n) => "remove" in n && n.remove()); 44 | replaced.push(key); 45 | } 46 | }); 47 | src.getExportDeclarations().forEach((node) => { 48 | node.getNamedExports().forEach((node) => { 49 | if (["action", "loader"].includes(node.getName())) node.remove(); 50 | }); 51 | }); 52 | 53 | if (replaced.length > 0) { 54 | src.addVariableStatement({ 55 | declarationKind: VariableDeclarationKind.Const, 56 | declarations: replaced.map((key) => ({ 57 | name: key, 58 | initializer: `async ({ context }) => await ${bindingName}.fetch(context.event.request.clone())`, 59 | })), 60 | isExported: true, 61 | }); 62 | } 63 | } 64 | 65 | if (!isEdge) { 66 | src.getExportedDeclarations().forEach((node, key) => { 67 | if (["ErrorBoundary", "CatchBoundary", "default"].includes(key)) 68 | node.forEach((n) => "remove" in n && n.remove()); 69 | }); 70 | src.getExportDeclarations().forEach((node) => { 71 | node.getNamedExports().forEach((node) => { 72 | if (["ErrorBoundary", "CatchBoundary"].includes(node.getName())) 73 | node.remove(); 74 | }); 75 | }); 76 | src.removeDefaultExport(); 77 | if (new RegExp(`${appRoot}/root\\.[jt]sx?$`).test(args.path)) { 78 | src.addFunction({ 79 | name: "Root", 80 | parameters: undefined, 81 | statements: "return null", 82 | isDefaultExport: true, 83 | }); 84 | } 85 | } 86 | 87 | return { 88 | contents: src.getFullText(), 89 | loader: ext.replace(/^\./, "") as Loader, 90 | resolveDir: path.dirname(args.path), 91 | }; 92 | } 93 | ); 94 | }, 95 | }); 96 | 97 | export default plugin; 98 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "strict": true, 6 | "outDir": "dist", 7 | "declaration": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "types": ["vitest/globals"], 12 | "removeComments": false 13 | }, 14 | "include": ["./src"], 15 | "exclude": [ 16 | "./src/__tests__" 17 | ] 18 | } -------------------------------------------------------------------------------- /usage/app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | export const loader = async () => { 2 | return { message: `action function of - /` }; 3 | }; 4 | 5 | export const action = async () => { 6 | return { message: `action function of - /` }; 7 | }; 8 | 9 | export default function Index() { 10 | return ( 11 |
12 |

This page is /index

13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /usage/build.ts: -------------------------------------------------------------------------------- 1 | import { BuildOptions, build } from "esbuild"; 2 | import plugin from "../src/index"; 3 | import * as path from "path"; 4 | 5 | const binderBuilderConfig: BuildOptions = { 6 | entryPoints: [path.resolve(__dirname, "server.js")], 7 | outfile: path.resolve(__dirname, "build/binder.js"), 8 | conditions: ["worker"], 9 | platform: "neutral", 10 | format: "esm", 11 | treeShaking: true, 12 | mainFields: ["browser", "module", "main"], 13 | target: "node14", 14 | bundle: true, 15 | logLevel: "silent", 16 | incremental: undefined, 17 | sourcemap: false, 18 | assetNames: "_assets/[name]-[hash]", 19 | publicPath: "/build/", 20 | plugins: [plugin(true, "BINDEE", true)], 21 | }; 22 | 23 | const bindeeBuilderConfig: BuildOptions = { 24 | entryPoints: [path.resolve(__dirname, "server.js")], 25 | outfile: path.resolve(__dirname, "build/bindee.js"), 26 | conditions: ["worker"], 27 | platform: "neutral", 28 | format: "esm", 29 | treeShaking: true, 30 | mainFields: ["browser", "module", "main"], 31 | target: "node14", 32 | bundle: true, 33 | logLevel: "silent", 34 | incremental: undefined, 35 | sourcemap: false, 36 | assetNames: "_assets/[name]-[hash]", 37 | publicPath: "/build/", 38 | plugins: [plugin(false, "BINDEE", true)], 39 | }; 40 | 41 | const main = async () => { 42 | await build(binderBuilderConfig); 43 | await build(bindeeBuilderConfig); 44 | }; 45 | main().catch((e) => { 46 | console.error(e); 47 | process.exit(1); 48 | }); 49 | -------------------------------------------------------------------------------- /usage/server.js: -------------------------------------------------------------------------------- 1 | export * as index from "./app/routes/index"; 2 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | coverage: { 6 | reporter: ["text", "json", "html", "lcov"], 7 | }, 8 | }, 9 | }); 10 | --------------------------------------------------------------------------------