├── .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 |
--------------------------------------------------------------------------------