├── .changeset ├── README.md └── config.json ├── .eslintrc.cjs ├── .github ├── dependabot.yml └── workflows │ ├── cache.yml │ ├── release.yml │ ├── test-pr.yml │ ├── test-push.yml │ └── test.yml ├── .gitignore ├── .nvmrc ├── README.md ├── example ├── .eslintrc.cjs ├── .gitignore ├── README.md ├── app │ ├── root.tsx │ └── routes │ │ └── _index.tsx ├── package.json ├── public │ └── favicon.ico ├── remix.config.js ├── remix.env.d.ts ├── server.js └── tsconfig.json ├── package.json ├── packages └── remix-raw-http │ ├── CHANGELOG.md │ ├── __tests__ │ └── index.test.ts │ ├── package.json │ ├── src │ ├── index.ts │ └── server.ts │ ├── tsconfig.json │ ├── tsup.config.ts │ └── vitest.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── prettier.config.cjs └── scripts ├── publish.js ├── remove-prerelease-changelogs.js └── tsconfig.json /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | const { globSync } = require("glob"); 2 | 3 | let packages = globSync("packages/*/", {}); 4 | 5 | // get files in packages 6 | const noExtraneousOverrides = packages.map((entry) => { 7 | return { 8 | files: `${entry}**/*`, 9 | rules: { 10 | "import/no-extraneous-dependencies": [ 11 | "error", 12 | { 13 | packageDir: [__dirname, entry], 14 | }, 15 | ], 16 | }, 17 | }; 18 | }); 19 | 20 | const vitestFiles = [ 21 | "packages/**/__tests__/**/*", 22 | "packages/**/*.{spec,test}.*", 23 | ]; 24 | 25 | /** @type {import('eslint').Linter.Config} */ 26 | module.exports = { 27 | extends: [ 28 | "@remix-run/eslint-config", 29 | "@remix-run/eslint-config/node", 30 | "@remix-run/eslint-config/internal", 31 | ], 32 | overrides: [ 33 | ...noExtraneousOverrides, 34 | { 35 | extends: ["@remix-run/eslint-config/jest-testing-library"], 36 | files: vitestFiles, 37 | rules: { 38 | "testing-library/no-await-sync-events": "off", 39 | "jest-dom/prefer-in-document": "off", 40 | }, 41 | // we're using vitest which has a very similar API to jest 42 | // (so the linting plugins work nicely), but it means we have to explicitly 43 | // set the jest version. 44 | settings: { 45 | jest: { 46 | version: 28, 47 | }, 48 | }, 49 | }, 50 | ], 51 | 52 | // Report unused `eslint-disable` comments. 53 | reportUnusedDisableDirectives: true, 54 | // Tell ESLint not to ignore dot-files, which are ignored by default. 55 | ignorePatterns: ["!.*.js", "!.*.mjs", "!.*.cjs"], 56 | }; 57 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: daily 7 | 8 | - package-ecosystem: npm 9 | directory: / 10 | schedule: 11 | interval: weekly 12 | time: "10:00" 13 | timezone: "America/Detroit" 14 | groups: 15 | remix: 16 | patterns: 17 | - "@remix-run/*" 18 | -------------------------------------------------------------------------------- /.github/workflows/cache.yml: -------------------------------------------------------------------------------- 1 | name: 🧹 cleanup caches by a branch 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - closed 7 | 8 | jobs: 9 | cleanup: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: ⬇️ Checkout repo 13 | uses: actions/checkout@v4 14 | 15 | - name: 🧹 Cleanup 16 | run: | 17 | gh extension install actions/gh-actions-cache 18 | 19 | REPO=${{ github.repository }} 20 | BRANCH="refs/pull/${{ github.event.pull_request.number }}/merge" 21 | 22 | echo "Fetching list of cache key" 23 | cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH | cut -f 1 ) 24 | 25 | ## Setting this to not fail the workflow while deleting cache keys. 26 | set +e 27 | echo "Deleting caches..." 28 | for cacheKey in $cacheKeysForPR 29 | do 30 | gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm 31 | done 32 | echo "Done" 33 | env: 34 | GH_TOKEN: ${{ github.token }} 35 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: 🦋 Changesets Release 2 | on: 3 | push: 4 | branches: 5 | - release 6 | - "release-*" 7 | - "!release-experimental" 8 | - "!release-experimental-*" 9 | - "!release-manual" 10 | - "!release-manual-*" 11 | 12 | permissions: 13 | id-token: write 14 | pull-requests: write 15 | contents: write 16 | 17 | concurrency: 18 | group: ${{ github.workflow }}-${{ github.ref }} 19 | cancel-in-progress: true 20 | 21 | jobs: 22 | release: 23 | name: 🦋 Changesets Release 24 | if: github.repository == 'mcansh/remix-node-http-server' 25 | runs-on: ubuntu-latest 26 | outputs: 27 | published: ${{ steps.changesets.outputs.published }} 28 | steps: 29 | - name: ⬇️ Checkout repo 30 | uses: actions/checkout@v4 31 | 32 | - name: ⎔ Setup node 33 | uses: actions/setup-node@v4 34 | with: 35 | node-version-file: ".nvmrc" 36 | 37 | - name: 🟧 Setup pnpm 38 | uses: pnpm/action-setup@v3 39 | with: 40 | version: 8 41 | run_install: | 42 | - recursive: true 43 | args: [--frozen-lockfile, --strict-peer-dependencies] 44 | 45 | - name: 🔐 Setup npm auth 46 | run: | 47 | echo "registry=https://registry.npmjs.org" >> ~/.npmrc 48 | echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc 49 | 50 | # This action has two responsibilities. The first time the workflow runs 51 | # (initial push to a `release-*` branch) it will create a new branch and 52 | # then open a PR with the related changes for the new version. After the 53 | # PR is merged, the workflow will run again and this action will build + 54 | # publish to npm & github packages. 55 | - name: 🚀 PR / Publish 56 | id: changesets 57 | uses: changesets/action@v1 58 | with: 59 | version: pnpm run changeset:version 60 | commit: "chore: Update version for release" 61 | title: "chore: Update version for release" 62 | publish: pnpm run changeset:release 63 | createGithubReleases: true 64 | env: 65 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 66 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 67 | 68 | comment: 69 | name: 📝 Comment on issues and pull requests 70 | if: github.repository == 'mcansh/remix-node-http-server' && needs.release.outputs.published == 'true' 71 | needs: [release] 72 | runs-on: ubuntu-latest 73 | steps: 74 | - name: ⬇️ Checkout repo 75 | uses: actions/checkout@v4 76 | with: 77 | fetch-depth: 0 78 | 79 | - name: 📝 Comment on issues 80 | uses: remix-run/release-comment-action@v0.4.1 81 | with: 82 | DIRECTORY_TO_CHECK: "./packages" 83 | PACKAGE_NAME: "@mcansh/remix-raw-http" 84 | -------------------------------------------------------------------------------- /.github/workflows/test-pr.yml: -------------------------------------------------------------------------------- 1 | name: 🧪 Test (PR) 2 | 3 | on: 4 | pull_request: 5 | 6 | concurrency: 7 | group: ${{ github.workflow }}-${{ github.ref }} 8 | cancel-in-progress: true 9 | 10 | jobs: 11 | test: 12 | if: github.repository == 'mcansh/remix-node-http-server' 13 | uses: ./.github/workflows/test.yml 14 | with: 15 | os: '["ubuntu-latest", "macos-latest", "windows-latest"]' 16 | node: '["latest"]' 17 | -------------------------------------------------------------------------------- /.github/workflows/test-push.yml: -------------------------------------------------------------------------------- 1 | name: 🧪 Test (Push) 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - ./packages/* 9 | - ./.github/* 10 | - ./package.json 11 | - ./pnpm-lock.yaml 12 | pull_request: 13 | branches: 14 | - changeset-release/* 15 | 16 | concurrency: 17 | group: ${{ github.workflow }}-${{ github.ref }} 18 | cancel-in-progress: true 19 | 20 | jobs: 21 | test: 22 | if: github.repository == 'mcansh/remix-node-http-server' 23 | uses: ./.github/workflows/test.yml 24 | with: 25 | os: '["ubuntu-latest", "macos-latest", "windows-latest"]' 26 | node: '["18", "20", "latest"]' 27 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: 🧪 Test 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | os: 7 | required: true 8 | # this is limited to string | boolean | number (https://github.community/t/can-action-inputs-be-arrays/16457) 9 | # but we want to pass an array (os: "[ubuntu-latest, macos-latest, windows-latest]"), 10 | # so we'll need to manually stringify it for now 11 | type: string 12 | node: 13 | required: true 14 | # this is limited to string | boolean | number (https://github.community/t/can-action-inputs-be-arrays/16457) 15 | # but we want to pass an array (node_version: "[18, 20]"), 16 | # so we'll need to manually stringify it for now 17 | type: string 18 | 19 | jobs: 20 | build: 21 | name: ⚙️ Build 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: ⬇️ Checkout repo 25 | uses: actions/checkout@v4 26 | 27 | - name: ⎔ Setup node 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version-file: ".nvmrc" 31 | 32 | - name: 🟧 Setup pnpm 33 | uses: pnpm/action-setup@v3 34 | with: 35 | version: 8 36 | run_install: | 37 | - recursive: true 38 | args: [--frozen-lockfile, --strict-peer-dependencies] 39 | cwd: ./ 40 | 41 | - name: 🏗 Build 42 | run: npm run build 43 | 44 | test: 45 | name: "${{ matrix.os }} | ${{ matrix.node }}" 46 | strategy: 47 | fail-fast: false 48 | matrix: 49 | os: ${{ fromJSON(inputs.os) }} 50 | node: ${{ fromJSON(inputs.node) }} 51 | runs-on: ${{ matrix.os }} 52 | steps: 53 | - name: ⬇️ Checkout repo 54 | uses: actions/checkout@v4 55 | 56 | - name: ⎔ Setup node 57 | uses: actions/setup-node@v4 58 | with: 59 | node-version: ${{ matrix.node }} 60 | 61 | - name: 🟧 Setup pnpm 62 | uses: pnpm/action-setup@v3 63 | with: 64 | version: 8 65 | run_install: | 66 | - recursive: true 67 | args: [--frozen-lockfile, --strict-peer-dependencies] 68 | cwd: ./ 69 | 70 | - name: 🧪 Run Primary Tests 71 | run: pnpm run test 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .ds_store 4 | .eslintcache 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # remix-node-http-server 2 | 3 | a remix app running on a plain node http server 4 | 5 | ## Installation 6 | 7 | ```sh 8 | npm i @mcansh/remix-raw-http 9 | yarn add @mcansh/remix-raw-http 10 | pnpm i @mcansh/remix-raw-http 11 | ``` 12 | 13 | ## Example usage 14 | 15 | See https://github.com/mcansh/remix-node-http-server/blob/main/example/server/index.js 16 | -------------------------------------------------------------------------------- /example/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.Config} */ 2 | module.exports = { 3 | extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"], 4 | root: true, 5 | }; 6 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | /public/build 6 | .env 7 | -------------------------------------------------------------------------------- /example/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 | -------------------------------------------------------------------------------- /example/app/root.tsx: -------------------------------------------------------------------------------- 1 | import { cssBundleHref } from "@remix-run/css-bundle"; 2 | import type { LinksFunction } from "@remix-run/node"; 3 | import { 4 | Links, 5 | LiveReload, 6 | Meta, 7 | Outlet, 8 | Scripts, 9 | ScrollRestoration, 10 | } from "@remix-run/react"; 11 | 12 | export const links: LinksFunction = () => [ 13 | ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), 14 | ]; 15 | 16 | export default function App() { 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /example/app/routes/_index.tsx: -------------------------------------------------------------------------------- 1 | import type { MetaFunction } from "@remix-run/node"; 2 | 3 | export const meta: MetaFunction = () => { 4 | return [ 5 | { title: "New Remix App" }, 6 | { name: "description", content: "Welcome to Remix!" }, 7 | ]; 8 | }; 9 | 10 | export default function Index() { 11 | return ( 12 |
13 |

Welcome to Remix

14 | 39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "private": true, 4 | "sideEffects": false, 5 | "type": "module", 6 | "scripts": { 7 | "build": "remix build", 8 | "dev": "remix dev --manual -c \"node --watch ./server.js\"", 9 | "start": "node ./server.js", 10 | "typecheck": "tsc" 11 | }, 12 | "dependencies": { 13 | "@fastify/send": "^2.1.0", 14 | "@mcansh/remix-raw-http": "1.0.2", 15 | "@remix-run/css-bundle": "^2.5.0", 16 | "@remix-run/node": "^2.5.0", 17 | "@remix-run/react": "^2.5.0", 18 | "isbot": "^4.3.0", 19 | "react": "^18.2.0", 20 | "react-dom": "^18.2.0", 21 | "source-map-support": "^0.5.21" 22 | }, 23 | "devDependencies": { 24 | "@remix-run/dev": "^2.5.0", 25 | "@remix-run/eslint-config": "^2.5.0", 26 | "@types/react": "^18.2.55", 27 | "@types/react-dom": "^18.2.18", 28 | "chokidar": "^3.5.3", 29 | "eslint": "^8.56.0", 30 | "typescript": "^5.3.3" 31 | }, 32 | "engines": { 33 | "node": ">=18.0.0" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcansh/remix-node-http-server/6fae9f6478c7229c4db28123e5b2212f491193b4/example/public/favicon.ico -------------------------------------------------------------------------------- /example/remix.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@remix-run/dev').AppConfig} */ 2 | export default { 3 | ignoredRouteFiles: ["**/.*"], 4 | // appDirectory: "app", 5 | // assetsBuildDirectory: "public/build", 6 | // publicPath: "/build/", 7 | // serverBuildPath: "build/index.js", 8 | }; 9 | -------------------------------------------------------------------------------- /example/remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /example/server.js: -------------------------------------------------------------------------------- 1 | import http from "node:http"; 2 | import fs from "node:fs"; 3 | import path from "node:path"; 4 | import url from "node:url"; 5 | import { createRequestHandler, getURL } from "@mcansh/remix-raw-http"; 6 | import send from "@fastify/send"; 7 | import { installGlobals, broadcastDevReady } from "@remix-run/node"; 8 | import sourceMapSupport from "source-map-support"; 9 | 10 | sourceMapSupport.install(); 11 | installGlobals(); 12 | 13 | let BUILD_PATH = "./build/index.js"; 14 | let VERSION_PATH = "./build/version.txt"; 15 | 16 | /** @typedef {import('@remix-run/node').ServerBuild} ServerBuild */ 17 | 18 | let initialBuild = await import(BUILD_PATH); 19 | 20 | /** 21 | * @param {import('node:http').IncomingMessage} req 22 | * @returns {import('@fastify/send').SendStream | undefined} 23 | */ 24 | async function getFileStream(req) { 25 | let url = getURL(req); 26 | let filePath = path.join(process.cwd(), "public", url.pathname); 27 | 28 | let stat = fs.statSync(filePath); 29 | if (!fs.existsSync(filePath) || !stat.isFile()) { 30 | return undefined; 31 | } 32 | 33 | let isBuildAsset = req.url.startsWith("/build"); 34 | return send(req, filePath, { 35 | immutable: process.env.NODE_ENV === "production" && isBuildAsset, 36 | maxAge: process.env.NODE_ENV === "production" && isBuildAsset ? "1y" : 0, 37 | }); 38 | } 39 | 40 | /** @type {import('node:http').Server} */ 41 | let server = http.createServer(async (req, res) => { 42 | try { 43 | let fileStream = await getFileStream(req); 44 | if (fileStream) return fileStream.pipe(res); 45 | 46 | if (process.env.NODE_ENV === "development") { 47 | let handler = await createDevRequestHandler(initialBuild); 48 | return handler(req, res); 49 | } 50 | 51 | let handler = createRequestHandler({ 52 | build: initialBuild, 53 | mode: initialBuild.mode, 54 | }); 55 | return handler(req, res); 56 | } catch (error) { 57 | console.error(error); 58 | } 59 | }); 60 | 61 | let port = Number(process.env.PORT) || 3000; 62 | 63 | server.listen(port, async () => { 64 | console.log(`✅ app ready: http://localhost:${port}`); 65 | if (process.env.NODE_ENV === "development") { 66 | await broadcastDevReady(initialBuild); 67 | } 68 | }); 69 | 70 | /** 71 | * @param {ServerBuild} initialBuild 72 | * @param {import('@mcansh/remix-raw-http').GetLoadContextFunction} [getLoadContext] 73 | * @returns {import('@mcansh/remix-raw-http').RequestHandler} 74 | */ 75 | async function createDevRequestHandler(initialBuild, getLoadContext) { 76 | let build = initialBuild; 77 | 78 | async function handleServerUpdate() { 79 | // 1. re-import the server build 80 | build = await reimportServer(); 81 | // 2. tell Remix that this app server is now up-to-date and ready 82 | await broadcastDevReady(build); 83 | } 84 | 85 | let chokidar = await import("chokidar"); 86 | chokidar 87 | .watch(VERSION_PATH, { ignoreInitial: true }) 88 | .on("add", handleServerUpdate) 89 | .on("change", handleServerUpdate); 90 | 91 | return async (...args) => { 92 | let handler = createRequestHandler({ 93 | build, 94 | getLoadContext, 95 | mode: "development", 96 | }); 97 | 98 | return handler(...args); 99 | }; 100 | } 101 | 102 | /** @returns {Promise} */ 103 | async function reimportServer() { 104 | let stat = fs.statSync(BUILD_PATH); 105 | 106 | // convert build path to URL for Windows compatibility with dynamic `import` 107 | let BUILD_URL = url.pathToFileURL(BUILD_PATH).href; 108 | 109 | // use a timestamp query parameter to bust the import cache 110 | return import(BUILD_URL + "?t=" + stat.mtimeMs); 111 | } 112 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 5 | "isolatedModules": true, 6 | "esModuleInterop": true, 7 | "jsx": "react-jsx", 8 | "moduleResolution": "Bundler", 9 | "resolveJsonModule": true, 10 | "target": "ES2022", 11 | "strict": true, 12 | "allowJs": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "baseUrl": ".", 15 | "paths": { 16 | "~/*": ["./app/*"] 17 | }, 18 | 19 | // Remix takes care of building everything in `remix build`. 20 | "noEmit": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "repository": "mcansh/remix-node-http-server", 4 | "license": "MIT", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "pnpm run --recursive --parallel dev", 8 | "build": "pnpm run --recursive build", 9 | "test": "pnpm --filter ./packages/* test --if-present", 10 | "lint": "eslint --cache --ignore-path .gitignore --fix .", 11 | "format": "prettier --write --ignore-path .gitignore .", 12 | "changeset": "changeset", 13 | "changeset:version": "changeset version && node ./scripts/remove-prerelease-changelogs.js && pnpm install --lockfile-only", 14 | "changeset:release": "pnpm run build && changeset publish" 15 | }, 16 | "dependencies": { 17 | "@changesets/cli": "^2.27.1", 18 | "@manypkg/get-packages": "^2.2.0", 19 | "@remix-run/eslint-config": "^2.5.0", 20 | "@types/node": "^20.11.0", 21 | "eslint": "^8.56.0", 22 | "eslint-plugin-prefer-let": "^3.0.1", 23 | "glob": "^10.3.10", 24 | "prettier": "^3.1.1", 25 | "publint": "^0.2.7", 26 | "semver": "^7.5.4", 27 | "tsup": "^8.0.1", 28 | "typescript": "^5.3.3", 29 | "vite": "^5.0.11", 30 | "vitest": "^1.1.3" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/remix-raw-http/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @mcansh/remix-raw-http 2 | 3 | ## 1.0.2 4 | 5 | ### Patch Changes 6 | 7 | - 4e139d9: bump dependencies to latest versions 8 | 9 | ## 1.0.1 10 | 11 | ### Patch Changes 12 | 13 | - 65c7323: v1.0.0 was taken 14 | 15 | ## 1.0.0 16 | 17 | ### Major Changes 18 | 19 | - 8260a9c: feat: Remix v2 20 | 21 | ## 0.2.1 22 | 23 | ### Patch Changes 24 | 25 | - d9cd050: support defer, fix request creation when double slashes exist 26 | 27 | ## 0.2.0 28 | 29 | ### Minor Changes 30 | 31 | - 51dec5a: concat chunks for nested routes 32 | -------------------------------------------------------------------------------- /packages/remix-raw-http/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from "node:http"; 2 | import { Readable } from "node:stream"; 3 | import { 4 | createReadableStreamFromReadable, 5 | createRequestHandler as createRemixRequestHandler, 6 | } from "@remix-run/node"; 7 | import "@remix-run/node/install"; 8 | import { createRequest, createResponse } from "node-mocks-http"; 9 | import supertest from "supertest"; 10 | import type { MockedFunction } from "vitest"; 11 | import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; 12 | 13 | import { 14 | createRemixHeaders, 15 | createRemixRequest, 16 | createRequestHandler, 17 | } from "../src/server"; 18 | 19 | vi.mock("@remix-run/node", async () => { 20 | let original = 21 | await vi.importActual("@remix-run/node"); 22 | return { 23 | ...original, 24 | createRequestHandler: vi.fn(), 25 | }; 26 | }); 27 | let mockedCreateRequestHandler = createRemixRequestHandler as MockedFunction< 28 | typeof createRemixRequestHandler 29 | >; 30 | 31 | function createApp() { 32 | let app = createServer((...args) => { 33 | // We don't have a real app to test, but it doesn't matter. We 34 | // won't ever call through to the real createRequestHandler 35 | // @ts-expect-error 36 | let handler = createRequestHandler({ build: undefined }); 37 | return handler(...args); 38 | }); 39 | 40 | return app; 41 | } 42 | 43 | describe("createRequestHandler", () => { 44 | describe("basic requests", () => { 45 | afterEach(() => { 46 | mockedCreateRequestHandler.mockReset(); 47 | }); 48 | 49 | afterAll(() => { 50 | vi.restoreAllMocks(); 51 | }); 52 | 53 | it("handles requests", async () => { 54 | mockedCreateRequestHandler.mockImplementation(() => async (req) => { 55 | return new Response(`URL: ${new URL(req.url).pathname}`); 56 | }); 57 | 58 | let req = supertest(createApp()); 59 | let res = await req.get("/foo/bar"); 60 | 61 | expect(res.status).toBe(200); 62 | expect(res.text).toBe("URL: /foo/bar"); 63 | }); 64 | 65 | it("handles root // URLs", async () => { 66 | mockedCreateRequestHandler.mockImplementation(() => async (req) => { 67 | return new Response("URL: " + new URL(req.url).pathname); 68 | }); 69 | 70 | let req = supertest(createApp()); 71 | let res = await req.get("//"); 72 | 73 | expect(res.statusCode).toBe(200); 74 | expect(res.text).toBe("URL: //"); 75 | }); 76 | 77 | it("handles nested // URLs", async () => { 78 | mockedCreateRequestHandler.mockImplementation(() => async (req) => { 79 | return new Response("URL: " + new URL(req.url).pathname); 80 | }); 81 | 82 | let req = supertest(createApp()); 83 | let res = await req.get("//foo//bar"); 84 | 85 | expect(res.status).toBe(200); 86 | expect(res.text).toBe("URL: //foo//bar"); 87 | }); 88 | 89 | it("handles null body", async () => { 90 | mockedCreateRequestHandler.mockImplementation(() => async () => { 91 | return new Response(null, { status: 200 }); 92 | }); 93 | 94 | let req = supertest(createApp()); 95 | let res = await req.get("/"); 96 | 97 | expect(res.status).toBe(200); 98 | }); 99 | 100 | // https://github.com/node-fetch/node-fetch/blob/4ae35388b078bddda238277142bf091898ce6fda/test/response.js#L142-L148 101 | it("handles body as stream", async () => { 102 | mockedCreateRequestHandler.mockImplementation(() => async () => { 103 | let readable = Readable.from("hello world"); 104 | let stream = createReadableStreamFromReadable(readable); 105 | return new Response(stream, { status: 200 }); 106 | }); 107 | 108 | let req = supertest(createApp()); 109 | let res = await req.get("/"); 110 | 111 | expect(res.statusCode).toBe(200); 112 | expect(res.text).toBe("hello world"); 113 | }); 114 | 115 | it("handles status codes", async () => { 116 | mockedCreateRequestHandler.mockImplementation(() => async () => { 117 | return new Response(null, { status: 204 }); 118 | }); 119 | 120 | let req = supertest(createApp()); 121 | let res = await req.get("/"); 122 | 123 | expect(res.status).toBe(204); 124 | }); 125 | 126 | it("sets headers", async () => { 127 | mockedCreateRequestHandler.mockImplementation(() => async () => { 128 | let headers = new Headers({ "X-Time-Of-Year": "most wonderful" }); 129 | headers.append( 130 | "Set-Cookie", 131 | "first=one; Expires=0; Path=/; HttpOnly; Secure; SameSite=Lax", 132 | ); 133 | headers.append( 134 | "Set-Cookie", 135 | "second=two; MaxAge=1209600; Path=/; HttpOnly; Secure; SameSite=Lax", 136 | ); 137 | headers.append( 138 | "Set-Cookie", 139 | "third=three; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Path=/; HttpOnly; Secure; SameSite=Lax", 140 | ); 141 | return new Response(null, { headers }); 142 | }); 143 | 144 | let req = supertest(createApp()); 145 | let res = await req.get("/"); 146 | 147 | expect(res.headers["x-time-of-year"]).toBe("most wonderful"); 148 | expect(res.headers["set-cookie"]).toEqual([ 149 | "first=one; Expires=0; Path=/; HttpOnly; Secure; SameSite=Lax", 150 | "second=two; MaxAge=1209600; Path=/; HttpOnly; Secure; SameSite=Lax", 151 | "third=three; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Path=/; HttpOnly; Secure; SameSite=Lax", 152 | ]); 153 | }); 154 | }); 155 | }); 156 | 157 | describe("createRemixHeaders", () => { 158 | describe("creates fetch headers from express headers", () => { 159 | it("handles empty headers", () => { 160 | let headers = createRemixHeaders({}); 161 | expect(Array.from(headers.keys())).toHaveLength(0); 162 | }); 163 | 164 | it("handles simple headers", () => { 165 | let headers = createRemixHeaders({ "x-foo": "bar" }); 166 | expect(headers.get("x-foo")).toBe("bar"); 167 | }); 168 | 169 | it("handles multiple headers", () => { 170 | let headers = createRemixHeaders({ "x-foo": "bar", "x-bar": "baz" }); 171 | expect(headers.get("x-foo")).toBe("bar"); 172 | }); 173 | 174 | it("handles headers with multiple values", () => { 175 | let headers = createRemixHeaders({ "x-foo": "bar, baz" }); 176 | expect(headers.get("x-foo")).toBe("bar, baz"); 177 | }); 178 | 179 | it("handles headers with multiple values and multiple headers", () => { 180 | let headers = createRemixHeaders({ "x-foo": "bar, baz", "x-bar": "baz" }); 181 | expect(headers.get("x-foo")).toBe("bar, baz"); 182 | expect(headers.get("x-bar")).toBe("baz"); 183 | }); 184 | 185 | it("handles multiple set-cookie headers", () => { 186 | let headers = createRemixHeaders({ 187 | "set-cookie": [ 188 | "__session=some_value; Path=/; Secure; HttpOnly; MaxAge=7200; SameSite=Lax", 189 | "__other=some_other_value; Path=/; Secure; HttpOnly; MaxAge=3600; SameSite=Lax", 190 | ], 191 | }); 192 | 193 | expect(headers.get("set-cookie")).toBe( 194 | "__session=some_value; Path=/; Secure; HttpOnly; MaxAge=7200; SameSite=Lax, __other=some_other_value; Path=/; Secure; HttpOnly; MaxAge=3600; SameSite=Lax", 195 | ); 196 | }); 197 | }); 198 | }); 199 | 200 | describe("createRemixRequest", () => { 201 | it("creates a request with the correct headers", async () => { 202 | let req = createRequest({ 203 | url: "/foo/bar", 204 | method: "GET", 205 | protocol: "http", 206 | hostname: "localhost", 207 | headers: { 208 | "Cache-Control": "max-age=300, s-maxage=3600", 209 | Host: "localhost:3000", 210 | }, 211 | }); 212 | 213 | let res = createResponse(); 214 | 215 | let request = createRemixRequest(req, res); 216 | 217 | expect(request.method).toBe("GET"); 218 | expect(request.headers.get("cache-control")).toBe( 219 | "max-age=300, s-maxage=3600", 220 | ); 221 | expect(request.headers.get("host")).toBe("localhost:3000"); 222 | }); 223 | }); 224 | -------------------------------------------------------------------------------- /packages/remix-raw-http/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mcansh/remix-raw-http", 3 | "version": "1.0.2", 4 | "description": "Node HTTP server request handler for Remix using http.createServer", 5 | "sideEffects": false, 6 | "type": "module", 7 | "main": "./dist/index.cjs", 8 | "types": "./dist/index.d.cts", 9 | "module": "./dist/index.js", 10 | "exports": { 11 | "./package.json": "./package.json", 12 | ".": { 13 | "types": { 14 | "require": "./dist/index.d.cts", 15 | "import": "./dist/index.d.ts", 16 | "default": "./dist/index.d.cts" 17 | }, 18 | "require": "./dist/index.cjs", 19 | "import": "./dist/index.js", 20 | "default": "./dist/index.cjs" 21 | } 22 | }, 23 | "repository": { 24 | "url": "mcansh/remix-node-http-server", 25 | "directory": "packages/remix-raw-http", 26 | "type": "git" 27 | }, 28 | "funding": [ 29 | { 30 | "type": "github", 31 | "url": "https://github.com/sponsors/mcansh" 32 | } 33 | ], 34 | "keywords": [ 35 | "remix", 36 | "remix-run", 37 | "node", 38 | "http", 39 | "server", 40 | "request", 41 | "handler" 42 | ], 43 | "scripts": { 44 | "prepublishOnly": "npm run build", 45 | "build": "tsup && publint", 46 | "test": "vitest" 47 | }, 48 | "author": "Logan McAnsh (https://mcan.sh/)", 49 | "license": "MIT", 50 | "devDependencies": { 51 | "@fastify/send": "^2.1.0", 52 | "@remix-run/node": "^2.5.0", 53 | "@types/supertest": "^6.0.2", 54 | "node-mocks-http": "^1.14.1", 55 | "supertest": "^6.3.3" 56 | }, 57 | "peerDependencies": { 58 | "@remix-run/node": "^2.0.0" 59 | }, 60 | "files": [ 61 | "dist", 62 | "README.md", 63 | "package.json" 64 | ], 65 | "publishConfig": { 66 | "access": "public", 67 | "provenance": true 68 | }, 69 | "engines": { 70 | "node": ">=18.0.0" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /packages/remix-raw-http/src/index.ts: -------------------------------------------------------------------------------- 1 | export type { GetLoadContextFunction, RequestHandler } from "./server"; 2 | export { createRequestHandler, getURL } from "./server"; 3 | -------------------------------------------------------------------------------- /packages/remix-raw-http/src/server.ts: -------------------------------------------------------------------------------- 1 | import type http from "node:http"; 2 | import type { AppLoadContext, ServerBuild } from "@remix-run/node"; 3 | import { createRequestHandler as createRemixRequestHandler } from "@remix-run/node"; 4 | import { 5 | createReadableStreamFromReadable, 6 | writeReadableStreamToWritable, 7 | } from "@remix-run/node"; 8 | 9 | /** 10 | * A function that returns the value to use as `context` in route `loader` and 11 | * `action` functions. 12 | * 13 | * You can think of this as an escape hatch that allows you to pass 14 | * environment/platform-specific values through to your loader/action, such as 15 | * values that are generated by Express middleware like `req.session`. 16 | */ 17 | export interface GetLoadContextFunction { 18 | (req: http.IncomingMessage, res: http.ServerResponse): AppLoadContext; 19 | } 20 | 21 | export type RequestHandler = ReturnType; 22 | 23 | /** 24 | * Returns a request handler for Express that serves the response using Remix. 25 | */ 26 | export function createRequestHandler({ 27 | build, 28 | getLoadContext, 29 | mode = process.env.NODE_ENV, 30 | }: { 31 | build: ServerBuild; 32 | getLoadContext?: GetLoadContextFunction; 33 | mode?: string; 34 | }) { 35 | let handleRequest = createRemixRequestHandler(build, mode); 36 | 37 | return async (req: http.IncomingMessage, res: http.ServerResponse) => { 38 | let request = createRemixRequest(req, res); 39 | let loadContext = getLoadContext?.(req, res); 40 | let response = await handleRequest(request, loadContext); 41 | return sendRemixResponse(res, response); 42 | }; 43 | } 44 | 45 | export function createRemixHeaders( 46 | requestHeaders: http.IncomingHttpHeaders, 47 | ): Headers { 48 | let headers = new Headers(); 49 | 50 | for (let [key, values] of Object.entries(requestHeaders)) { 51 | if (values) { 52 | if (Array.isArray(values)) { 53 | for (let value of values) { 54 | headers.append(key, value); 55 | } 56 | } else { 57 | headers.set(key, values); 58 | } 59 | } 60 | } 61 | 62 | return headers; 63 | } 64 | 65 | export function getURL(req: http.IncomingMessage): URL { 66 | return new URL(`http://${req.headers.host}${req.url}`); 67 | } 68 | 69 | export function createRemixRequest( 70 | req: http.IncomingMessage, 71 | res: http.OutgoingMessage, 72 | ): Request { 73 | let url = getURL(req); 74 | 75 | // Abort action/loaders once we can no longer write a response 76 | let controller = new AbortController(); 77 | res.on("close", () => controller.abort()); 78 | 79 | let init: RequestInit = { 80 | method: req.method, 81 | headers: createRemixHeaders(req.headers), 82 | signal: controller.signal, 83 | }; 84 | 85 | if (req.method !== "GET" && req.method !== "HEAD") { 86 | init.body = createReadableStreamFromReadable(req); 87 | (init as { duplex: "half" }).duplex = "half"; 88 | } 89 | 90 | return new Request(url.href, init); 91 | } 92 | 93 | async function sendRemixResponse( 94 | res: http.ServerResponse, 95 | nodeResponse: Response, 96 | ) { 97 | res.statusCode = nodeResponse.status; 98 | res.statusMessage = nodeResponse.statusText; 99 | 100 | for (let [key, values] of nodeResponse.headers.entries()) { 101 | res.appendHeader(key, values); 102 | } 103 | 104 | if (nodeResponse.body) { 105 | await writeReadableStreamToWritable(nodeResponse.body, res); 106 | } else { 107 | res.end(); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /packages/remix-raw-http/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["./dist", "./__tests__", "./node_modules"], 3 | "include": ["./src"], 4 | "compilerOptions": { 5 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 6 | "target": "ES2022", 7 | "module": "ES2022", 8 | "verbatimModuleSyntax": true, 9 | "noUncheckedIndexedAccess": true, 10 | 11 | "moduleResolution": "Bundler", 12 | "moduleDetection": "force", 13 | "strict": true, 14 | "outDir": "./dist", 15 | "skipLibCheck": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "esModuleInterop": true, 18 | "isolatedModules": true, 19 | "rootDir": ".", 20 | "declaration": true, 21 | "emitDeclarationOnly": true 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/remix-raw-http/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig(() => { 4 | return { 5 | entry: ["src/index.ts"], 6 | sourcemap: true, 7 | tsconfig: "./tsconfig.json", 8 | dts: true, 9 | format: ["cjs", "esm"], 10 | clean: true, 11 | cjsInterop: true, 12 | splitting: true, 13 | platform: "node", 14 | skipNodeModulesBundle: true, 15 | treeshake: true, 16 | }; 17 | }); 18 | -------------------------------------------------------------------------------- /packages/remix-raw-http/vitest.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig } from "vite"; 3 | 4 | export default defineConfig({ 5 | test: {}, 6 | }); 7 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "packages/*" 3 | - "example" 4 | -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('prettier').Config} */ 2 | module.exports = {}; 3 | -------------------------------------------------------------------------------- /scripts/publish.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { execSync } from "node:child_process"; 4 | import semver from "semver"; 5 | import { globSync } from "glob"; 6 | 7 | let packages = globSync("packages/*", { absolute: true }); 8 | 9 | function getTaggedVersion() { 10 | let output = execSync("git tag --list --points-at HEAD").toString().trim(); 11 | return output.replace(/^v/g, ""); 12 | } 13 | 14 | /** 15 | * @param {string} dir 16 | * @param {string} tag 17 | */ 18 | function publish(dir, tag) { 19 | execSync(`npm publish --access public --tag ${tag} ${dir}`, { 20 | stdio: "inherit", 21 | }); 22 | } 23 | 24 | async function run() { 25 | // Make sure there's a current tag 26 | let taggedVersion = getTaggedVersion(); 27 | if (taggedVersion === "") { 28 | console.error("Missing release version. Run the version script first."); 29 | process.exit(1); 30 | } 31 | 32 | let prerelease = semver.prerelease(taggedVersion); 33 | let prereleaseTag = prerelease ? String(prerelease[0]) : undefined; 34 | let tag = prereleaseTag 35 | ? prereleaseTag.includes("nightly") 36 | ? "nightly" 37 | : prereleaseTag.includes("experimental") 38 | ? "experimental" 39 | : prereleaseTag 40 | : "latest"; 41 | 42 | for (let name of packages) { 43 | publish(name, tag); 44 | } 45 | } 46 | 47 | run().then( 48 | () => { 49 | process.exit(0); 50 | }, 51 | (error) => { 52 | console.error(error); 53 | process.exit(1); 54 | }, 55 | ); 56 | -------------------------------------------------------------------------------- /scripts/remove-prerelease-changelogs.js: -------------------------------------------------------------------------------- 1 | import * as fs from "node:fs"; 2 | import path from "node:path"; 3 | import * as url from "node:url"; 4 | import { getPackagesSync } from "@manypkg/get-packages"; 5 | 6 | const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); 7 | const rootDir = path.join(__dirname, ".."); 8 | 9 | const DRY_RUN = false; 10 | // pre-release headings look like: "1.15.0-pre.2" 11 | const PRE_RELEASE_HEADING_REGEXP = /^\d+\.\d+\.\d+-pre\.\d+$/i; 12 | // stable headings look like: "1.15.0" 13 | const STABLE_HEADING_REGEXP = /^\d+\.\d+\.\d+$/i; 14 | 15 | main(); 16 | 17 | async function main() { 18 | if (isPrereleaseMode()) { 19 | console.log("🚫 Skipping changelog removal in prerelease mode"); 20 | return; 21 | } 22 | await removePreReleaseChangelogs(); 23 | console.log("✅ Removed pre-release changelogs"); 24 | } 25 | 26 | async function removePreReleaseChangelogs() { 27 | let allPackages = getPackagesSync(rootDir).packages; 28 | 29 | /** @type {Promise[]} */ 30 | let processes = []; 31 | for (let pkg of allPackages) { 32 | let changelogPath = path.join(pkg.dir, "CHANGELOG.md"); 33 | if (!fs.existsSync(changelogPath)) { 34 | continue; 35 | } 36 | let changelogFileContents = fs.readFileSync(changelogPath, "utf-8"); 37 | processes.push( 38 | (async () => { 39 | let preReleaseHeadingIndex = findHeadingLineIndex( 40 | changelogFileContents, 41 | { 42 | level: 2, 43 | startAtIndex: 0, 44 | matcher: PRE_RELEASE_HEADING_REGEXP, 45 | }, 46 | ); 47 | 48 | while (preReleaseHeadingIndex !== -1) { 49 | let nextStableHeadingIndex = findHeadingLineIndex( 50 | changelogFileContents, 51 | { 52 | level: 2, 53 | startAtIndex: preReleaseHeadingIndex + 1, 54 | matcher: STABLE_HEADING_REGEXP, 55 | }, 56 | ); 57 | 58 | // remove all lines between the pre-release heading and the next stable 59 | // heading 60 | changelogFileContents = removeLines(changelogFileContents, { 61 | start: preReleaseHeadingIndex, 62 | end: nextStableHeadingIndex === -1 ? "max" : nextStableHeadingIndex, 63 | }); 64 | 65 | // find the next pre-release heading 66 | preReleaseHeadingIndex = findHeadingLineIndex(changelogFileContents, { 67 | level: 2, 68 | startAtIndex: 0, 69 | matcher: PRE_RELEASE_HEADING_REGEXP, 70 | }); 71 | } 72 | 73 | if (DRY_RUN) { 74 | console.log("FILE CONTENTS:\n\n" + changelogFileContents); 75 | } else { 76 | await fs.promises.writeFile( 77 | changelogPath, 78 | changelogFileContents, 79 | "utf-8", 80 | ); 81 | } 82 | })(), 83 | ); 84 | } 85 | return Promise.all(processes); 86 | } 87 | 88 | function isPrereleaseMode() { 89 | try { 90 | let prereleaseFilePath = path.join(rootDir, ".changeset", "pre.json"); 91 | return fs.existsSync(prereleaseFilePath); 92 | } catch (err) { 93 | return false; 94 | } 95 | } 96 | 97 | /** 98 | * @param {string} markdownContents 99 | * @param {{ level: number; startAtIndex: number; matcher: RegExp }} opts 100 | */ 101 | function findHeadingLineIndex( 102 | markdownContents, 103 | { level, startAtIndex, matcher }, 104 | ) { 105 | let index = markdownContents.split("\n").findIndex((line, i) => { 106 | if (i < startAtIndex || !line.startsWith(`${"#".repeat(level)} `)) 107 | return false; 108 | let headingContents = line.slice(level + 1).trim(); 109 | return matcher.test(headingContents); 110 | }); 111 | return index; 112 | } 113 | 114 | /** 115 | * @param {string} markdownContents 116 | * @param {{ start: number; end: number | 'max' }} param1 117 | */ 118 | function removeLines(markdownContents, { start, end }) { 119 | let lines = markdownContents.split("\n"); 120 | lines.splice(start, end === "max" ? lines.length - start : end - start); 121 | return lines.join("\n"); 122 | } 123 | -------------------------------------------------------------------------------- /scripts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["."], 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "allowSyntheticDefaultImports": true, 8 | "moduleResolution": "nodenext", 9 | "module": "ESNext", 10 | "noEmit": true, 11 | "strict": true, 12 | "target": "ES2018" 13 | } 14 | } 15 | --------------------------------------------------------------------------------