diff --git a/README.md b/README.md index e893565..fd5f919 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ let win app.on("ready", async () => { try { const url = await initRemix({ - serverBuild: join(__dirname, "../build/index.js"), + serverBuild: join(process.cwd(), "build/index.js"), }) win = new BrowserWindow({ show: false }) @@ -94,13 +94,15 @@ Initializes remix-electron. Returns a promise with a url to load in the browser Options: -- `serverBuild`: The path to your server build (e.g. `path.join(__dirname, 'build')`), or the server build itself (e.g. required from `@remix-run/dev/server-build`). Updates on refresh are only supported when passing a path string. +- `serverBuild`: The path to your server build (e.g. `path.join(process.cwd(), 'build')`), or the server build itself (e.g. required from `@remix-run/dev/server-build`). Updates on refresh are only supported when passing a path string. - `mode`: The mode the app is running in. Can be `"development"` or `"production"`. Defaults to `"production"` when packaged, otherwise uses `process.env.NODE_ENV`. -- `publicFolder`: The folder where static assets are served from, including your browser build. Defaults to `"public"`. Non-relative paths are resolved relative to `app.getAppPath()`. +- `publicFolder`: The folder where static assets are served from, including your browser build. Defaults to `"public"`. Non-absolute paths are resolved relative to `process.cwd()`. -- `getLoadContext`: Use this to inject some value into all of your remix loaders, e.g. an API client. The loaders receive it as `context` +- `getLoadContext`: Use this to inject some value into all of your remix loaders, e.g. an API client. The loaders receive it as `context`. + +- `esm`: Set this to `true` to use remix-electron in an ESM application.
Load context TS example diff --git a/knip.json b/knip.json index 192f070..e9c6c89 100644 --- a/knip.json +++ b/knip.json @@ -17,6 +17,9 @@ "workspaces/tests": {}, "workspaces/test-app": { "ignoreDependencies": ["isbot", "nodemon"] + }, + "workspaces/test-app-esm": { + "ignoreDependencies": ["isbot", "nodemon"] } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 404e9ce..e5fdd55 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,6 +147,43 @@ importers: specifier: ^5.3.3 version: 5.5.4 + workspaces/test-app-esm: + dependencies: + '@remix-run/node': + specifier: ^2.5.1 + version: 2.11.2(typescript@5.5.4) + '@remix-run/react': + specifier: ^2.5.1 + version: 2.11.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + remix-electron: + specifier: ^2.0.1 + version: 2.0.2(@remix-run/node@2.11.2(typescript@5.5.4))(electron@28.3.3) + devDependencies: + '@remix-run/dev': + specifier: ^2.5.1 + version: 2.11.2(@remix-run/react@2.11.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4))(@remix-run/serve@2.11.2(typescript@5.5.4))(@types/node@20.11.14)(typescript@5.5.4)(vite@5.4.2(@types/node@20.11.14)) + '@types/react': + specifier: ^18.2.48 + version: 18.3.4 + '@types/react-dom': + specifier: ^18.2.18 + version: 18.3.0 + electron: + specifier: ^28.2.1 + version: 28.3.3 + nodemon: + specifier: ^3.0.3 + version: 3.1.4 + typescript: + specifier: ^5.3.3 + version: 5.5.4 + workspaces/tests: devDependencies: '@playwright/test': @@ -5769,7 +5806,7 @@ snapshots: proc-log: 4.2.0 promise-inflight: 1.0.1 promise-retry: 2.0.1 - semver: 7.6.0 + semver: 7.6.3 which: 4.0.0 transitivePeerDependencies: - bluebird @@ -5803,7 +5840,7 @@ snapshots: json-parse-even-better-errors: 3.0.2 normalize-package-data: 6.0.2 proc-log: 3.0.0 - semver: 7.6.0 + semver: 7.6.3 transitivePeerDependencies: - bluebird @@ -5927,7 +5964,7 @@ snapshots: '@pnpm/npm-package-arg@1.0.0': dependencies: hosted-git-info: 4.1.0 - semver: 7.6.0 + semver: 7.6.3 validate-npm-package-name: 4.0.0 '@pnpm/npm-resolver@18.1.1(@pnpm/logger@5.0.0)': @@ -5951,7 +5988,7 @@ snapshots: path-temp: 2.1.0 ramda: '@pnpm/ramda@0.28.1' rename-overwrite: 5.0.4 - semver: 7.6.0 + semver: 7.6.3 ssri: 10.0.5 version-selector-type: 3.0.0 transitivePeerDependencies: @@ -5961,7 +5998,7 @@ snapshots: '@pnpm/resolve-workspace-range@5.0.1': dependencies: - semver: 7.6.0 + semver: 7.6.3 '@pnpm/resolver-base@11.1.0': dependencies: @@ -6711,7 +6748,7 @@ snapshots: builtins@5.1.0: dependencies: - semver: 7.6.0 + semver: 7.6.3 bundle-name@4.1.0: dependencies: @@ -9157,7 +9194,7 @@ snapshots: normalize-package-data@6.0.2: dependencies: hosted-git-info: 7.0.2 - semver: 7.6.0 + semver: 7.6.3 validate-npm-package-license: 3.0.4 normalize-path@3.0.0: {} @@ -9183,7 +9220,7 @@ snapshots: dependencies: hosted-git-info: 7.0.2 proc-log: 4.2.0 - semver: 7.6.0 + semver: 7.6.3 validate-npm-package-name: 5.0.1 npm-pick-manifest@8.0.2: @@ -9198,7 +9235,7 @@ snapshots: npm-install-checks: 6.3.0 npm-normalize-package-bin: 3.0.1 npm-package-arg: 11.0.3 - semver: 7.6.0 + semver: 7.6.3 npm-run-all@4.1.5: dependencies: @@ -9353,7 +9390,7 @@ snapshots: ky: 1.7.1 registry-auth-token: 5.0.2 registry-url: 6.0.1 - semver: 7.6.2 + semver: 7.6.3 pako@0.2.9: {} @@ -9957,7 +9994,7 @@ snapshots: semver-diff@4.0.0: dependencies: - semver: 7.6.2 + semver: 7.6.3 semver@5.7.2: {} @@ -10555,7 +10592,7 @@ snapshots: is-npm: 6.0.0 latest-version: 9.0.0 pupa: 3.1.0 - semver: 7.6.2 + semver: 7.6.3 semver-diff: 4.0.0 xdg-basedir: 5.1.0 @@ -10608,7 +10645,7 @@ snapshots: version-selector-type@3.0.0: dependencies: - semver: 7.6.0 + semver: 7.6.3 vfile-message@3.1.4: dependencies: diff --git a/workspaces/remix-electron/src/index.mts b/workspaces/remix-electron/src/index.mts index 56469f2..b51e331 100644 --- a/workspaces/remix-electron/src/index.mts +++ b/workspaces/remix-electron/src/index.mts @@ -4,7 +4,7 @@ import * as webFetch from "@remix-run/web-fetch" // if we override everything else, we get errors caused by the mismatch of built-in types and remix types global.File = webFetch.File -import { watch } from "node:fs/promises" +import { constants, access, watch } from "node:fs/promises" import type { AppLoadContext, ServerBuild } from "@remix-run/node" import { broadcastDevReady, createRequestHandler } from "@remix-run/node" import { app, protocol } from "electron" @@ -25,6 +25,7 @@ interface InitRemixOptions { mode?: string publicFolder?: string getLoadContext?: GetLoadContextFunction + esm?: boolean } /** @@ -38,18 +39,29 @@ export async function initRemix({ mode, publicFolder: publicFolderOption = "public", getLoadContext, + esm = typeof require === "undefined", }: InitRemixOptions): Promise { - const appRoot = app.getAppPath() - const publicFolder = asAbsolutePath(publicFolderOption, appRoot) + const publicFolder = asAbsolutePath(publicFolderOption, process.cwd()) + + if ( + !(await access(publicFolder, constants.R_OK).then( + () => true, + () => false, + )) + ) { + throw new Error( + `Public folder ${publicFolder} does not exist. Make sure that the initRemix \`publicFolder\` option is configured correctly.`, + ) + } const buildPath = - typeof serverBuildOption === "string" - ? require.resolve(serverBuildOption) - : undefined + typeof serverBuildOption === "string" ? serverBuildOption : undefined let serverBuild = - typeof serverBuildOption === "string" - ? /** @type {ServerBuild} */ require(serverBuildOption) + typeof buildPath === "string" + ? /** @type {ServerBuild} */ await import( + esm ? `${buildPath}?${Date.now()}` : buildPath + ) : serverBuildOption await app.whenReady() @@ -95,9 +107,13 @@ export async function initRemix({ ) { void (async () => { for await (const _event of watch(buildPath)) { - purgeRequireCache(buildPath) - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - serverBuild = require(buildPath) + if (esm) { + serverBuild = await import(`${buildPath}?${Date.now()}`) + } else { + purgeRequireCache(buildPath) + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + serverBuild = require(buildPath) + } await broadcastDevReady(serverBuild) } })() diff --git a/workspaces/test-app-esm/.gitignore b/workspaces/test-app-esm/.gitignore new file mode 100644 index 0000000..26b49f6 --- /dev/null +++ b/workspaces/test-app-esm/.gitignore @@ -0,0 +1,3 @@ +/build +/public/build +.cache diff --git a/workspaces/test-app-esm/app/electron.server.ts b/workspaces/test-app-esm/app/electron.server.ts new file mode 100644 index 0000000..9370d39 --- /dev/null +++ b/workspaces/test-app-esm/app/electron.server.ts @@ -0,0 +1,2 @@ +import electron from "electron" +export default electron diff --git a/workspaces/test-app-esm/app/root.tsx b/workspaces/test-app-esm/app/root.tsx new file mode 100644 index 0000000..ec18c94 --- /dev/null +++ b/workspaces/test-app-esm/app/root.tsx @@ -0,0 +1,32 @@ +import type { MetaFunction } from "@remix-run/node" +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "@remix-run/react" + +export const meta: MetaFunction = () => { + return [{ title: "New Remix App" }] +} + +export default function App() { + return ( + + + + + + + + + + + + {process.env.NODE_ENV === "development" && } + + + ) +} diff --git a/workspaces/test-app-esm/app/routes/_index.tsx b/workspaces/test-app-esm/app/routes/_index.tsx new file mode 100644 index 0000000..7c7fd67 --- /dev/null +++ b/workspaces/test-app-esm/app/routes/_index.tsx @@ -0,0 +1,27 @@ +import { useLoaderData } from "@remix-run/react" +import { useState } from "react" +import electron from "~/electron.server" + +export function loader() { + return { + userDataPath: electron.app.getPath("userData"), + } +} + +export default function Index() { + const data = useLoaderData() + const [count, setCount] = useState(0) + return ( +
+

Welcome to Remix

+

{data.userDataPath}

+ +
+ ) +} diff --git a/workspaces/test-app-esm/app/routes/multipart-uploads.tsx b/workspaces/test-app-esm/app/routes/multipart-uploads.tsx new file mode 100644 index 0000000..609e0f5 --- /dev/null +++ b/workspaces/test-app-esm/app/routes/multipart-uploads.tsx @@ -0,0 +1,36 @@ +import { + type ActionFunctionArgs, + NodeOnDiskFile, + json, + unstable_createFileUploadHandler, + unstable_parseMultipartFormData, +} from "@remix-run/node" +import { Form, useActionData } from "@remix-run/react" + +export async function action({ request }: ActionFunctionArgs) { + const formData = await unstable_parseMultipartFormData( + request, + unstable_createFileUploadHandler(), + ) + + const file = formData.get("file") + if (!(file instanceof NodeOnDiskFile)) { + throw new Error("No file uploaded") + } + + const text = await file.text() + return json({ text }) +} + +export default function MultipartUploadsTest() { + const data = useActionData() + return ( + <> +
+ + +
+

{data?.text}

+ + ) +} diff --git a/workspaces/test-app-esm/app/routes/referrer-redirect.action.tsx b/workspaces/test-app-esm/app/routes/referrer-redirect.action.tsx new file mode 100644 index 0000000..e8539dc --- /dev/null +++ b/workspaces/test-app-esm/app/routes/referrer-redirect.action.tsx @@ -0,0 +1,13 @@ +import type { ActionFunction } from "@remix-run/node" +import { redirect } from "@remix-run/node" + +export const action: ActionFunction = async ({ request }) => { + const { redirects } = Object.fromEntries(await request.formData()) + const referrer = request.headers.get("referer") + if (!referrer) { + throw new Error("No referrer header") + } + const url = new URL(referrer) + url.searchParams.set("redirects", String(Number(redirects) + 1)) + return redirect(url.toString()) +} diff --git a/workspaces/test-app-esm/app/routes/referrer-redirect.form.tsx b/workspaces/test-app-esm/app/routes/referrer-redirect.form.tsx new file mode 100644 index 0000000..3cf63f8 --- /dev/null +++ b/workspaces/test-app-esm/app/routes/referrer-redirect.form.tsx @@ -0,0 +1,21 @@ +import { useFetcher, useSearchParams } from "@remix-run/react" + +export default function RedirectForm() { + const fetcher = useFetcher() + const [params] = useSearchParams() + const redirects = params.get("redirects") + return ( + <> +

{redirects ?? 0}

+ + + + + ) +} diff --git a/workspaces/test-app-esm/desktop/index.js b/workspaces/test-app-esm/desktop/index.js new file mode 100644 index 0000000..7c5af34 --- /dev/null +++ b/workspaces/test-app-esm/desktop/index.js @@ -0,0 +1,29 @@ +import path from "node:path" +import { BrowserWindow, app } from "electron" +import { initRemix } from "remix-electron" + +/** @param {string} url */ +async function createWindow(url) { + const win = new BrowserWindow() + + // load the devtools first before loading the app URL so we can see initial network requests + // electron needs some page content to show the dev tools, so we'll load a dummy page first + await win.loadURL( + `data:text/html;charset=utf-8,${encodeURI("

Loading...

")}`, + ) + win.webContents.openDevTools() + win.webContents.on("devtools-opened", () => { + // devtools takes a bit to load, so we'll wait a bit before loading the app URL + setTimeout(() => { + win.loadURL(url).catch(console.error) + }, 500) + }) +} + +app.on("ready", async () => { + const url = await initRemix({ + serverBuild: path.join(process.cwd(), "./build/index.js"), + esm: true, + }) + await createWindow(url) +}) diff --git a/workspaces/test-app-esm/nodemon.json b/workspaces/test-app-esm/nodemon.json new file mode 100644 index 0000000..f957385 --- /dev/null +++ b/workspaces/test-app-esm/nodemon.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json.schemastore.org/nodemon.json", + "exec": "electron --trace-warnings .", + "watch": ["desktop"], + "ignore": ["build", "public/build"] +} diff --git a/workspaces/test-app-esm/package.json b/workspaces/test-app-esm/package.json new file mode 100644 index 0000000..6c749cc --- /dev/null +++ b/workspaces/test-app-esm/package.json @@ -0,0 +1,26 @@ +{ + "name": "remix-electron-test-app-esm", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "desktop/index.js", + "scripts": { + "dev": "remix dev --manual --command \"nodemon .\"", + "build": "remix build" + }, + "dependencies": { + "@remix-run/node": "^2.5.1", + "@remix-run/react": "^2.5.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "remix-electron": "^2.0.1" + }, + "devDependencies": { + "@remix-run/dev": "^2.5.1", + "@types/react": "^18.2.48", + "@types/react-dom": "^18.2.18", + "electron": "^28.2.1", + "nodemon": "^3.0.3", + "typescript": "^5.3.3" + } +} diff --git a/workspaces/test-app-esm/public/favicon.ico b/workspaces/test-app-esm/public/favicon.ico new file mode 100644 index 0000000..8830cf6 Binary files /dev/null and b/workspaces/test-app-esm/public/favicon.ico differ diff --git a/workspaces/test-app-esm/public/with spaces.txt b/workspaces/test-app-esm/public/with spaces.txt new file mode 100644 index 0000000..5da65bb --- /dev/null +++ b/workspaces/test-app-esm/public/with spaces.txt @@ -0,0 +1 @@ +This is a file with spaces in the path \ No newline at end of file diff --git a/workspaces/test-app-esm/remix.config.js b/workspaces/test-app-esm/remix.config.js new file mode 100644 index 0000000..e86b2af --- /dev/null +++ b/workspaces/test-app-esm/remix.config.js @@ -0,0 +1,4 @@ +/** @type {import("@remix-run/dev").AppConfig} */ +export default { + serverModuleFormat: "esm", +} diff --git a/workspaces/test-app-esm/remix.env.d.ts b/workspaces/test-app-esm/remix.env.d.ts new file mode 100644 index 0000000..72e2aff --- /dev/null +++ b/workspaces/test-app-esm/remix.env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/workspaces/test-app-esm/tsconfig.json b/workspaces/test-app-esm/tsconfig.json new file mode 100644 index 0000000..acd4c12 --- /dev/null +++ b/workspaces/test-app-esm/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "verbatimModuleSyntax": false, + "module": "ESNext", + "moduleResolution": "Bundler", + "paths": { + "~/*": ["./app/*"] + } + }, + "extends": "../../tsconfig.base.json" +} diff --git a/workspaces/tests/tests/integration-esm.test.ts b/workspaces/tests/tests/integration-esm.test.ts new file mode 100644 index 0000000..31875b5 --- /dev/null +++ b/workspaces/tests/tests/integration-esm.test.ts @@ -0,0 +1,5 @@ +import { run } from "./integration.js" + +export const appFolder = new URL("../../test-app-esm", import.meta.url) + +run("esm", appFolder) diff --git a/workspaces/tests/tests/integration.test.ts b/workspaces/tests/tests/integration.test.ts index 7f59ad8..cfda970 100644 --- a/workspaces/tests/tests/integration.test.ts +++ b/workspaces/tests/tests/integration.test.ts @@ -1,81 +1,5 @@ -import { readFile } from "node:fs/promises" -import { fileURLToPath } from "node:url" -import { - type ElectronApplication, - type Page, - expect, - test, -} from "@playwright/test" -import { execa } from "execa" -import { launchElectron } from "./launchElectron.js" -import { appFolder } from "./paths.js" +import { run } from "./integration.js" -let app!: ElectronApplication -let window!: Page +export const appFolder = new URL("../../test-app", import.meta.url) -test.beforeAll("build", async () => { - await execa("pnpm", ["build"], { - cwd: appFolder, - stderr: "inherit", - }) -}) - -test.beforeEach(async () => { - ;({ app, window } = await launchElectron({ - cwd: fileURLToPath(appFolder), - args: ["."], - })) -}) - -test.afterEach(async () => { - await app.close() -}) - -test("electron apis", async () => { - const userDataPath = await app.evaluate(({ app }) => app.getPath("userData")) - - await expect(window.locator('[data-testid="user-data-path"]')).toHaveText( - userDataPath, - ) -}) - -test("scripts", async () => { - const counter = window.locator("[data-testid='counter']") - await expect(counter).toHaveText("0") - await counter.click({ clickCount: 2 }) - await expect(counter).toHaveText("2") -}) - -test("action referrer redirect", async () => { - await window.goto("http://localhost/referrer-redirect/form") - - const redirectCount = window.locator("[data-testid=redirects]") - await expect(redirectCount).toHaveText("0") - await window.click("text=submit") - await expect(redirectCount).toHaveText("1") -}) - -test.skip("multipart uploads", async () => { - await window.goto("http://localhost/multipart-uploads") - - const assetUrl = new URL( - "./fixtures/asset-files/file-upload.txt", - import.meta.url, - ) - - const assetContent = await readFile(assetUrl, "utf-8") - - await window - .locator("input[type=file]") - .setInputFiles(fileURLToPath(assetUrl)) - await window.locator("button").click() - await expect(window.locator("[data-testid=result]")).toHaveText(assetContent) -}) - -test("can load public assets that contain whitespace in their path", async () => { - await window.goto("http://localhost/with spaces.txt") - - await expect(window.locator("body")).toHaveText( - "This is a file with spaces in the path", - ) -}) +run("cjs", appFolder) diff --git a/workspaces/tests/tests/integration.ts b/workspaces/tests/tests/integration.ts new file mode 100644 index 0000000..b744930 --- /dev/null +++ b/workspaces/tests/tests/integration.ts @@ -0,0 +1,86 @@ +import { readFile } from "node:fs/promises" +import { fileURLToPath } from "node:url" +import { + type ElectronApplication, + type Page, + expect, + test, +} from "@playwright/test" +import { execa } from "execa" +import { launchElectron } from "./launchElectron.js" + +export function run(type: "esm" | "cjs", appFolder: URL) { + let app!: ElectronApplication + let window!: Page + + test.beforeAll(`${type} - build`, async () => { + await execa("pnpm", ["build"], { + cwd: appFolder, + stderr: "inherit", + }) + }) + + test.beforeEach(async () => { + ;({ app, window } = await launchElectron({ + cwd: fileURLToPath(appFolder), + args: ["."], + })) + }) + + test.afterEach(async () => { + await app.close() + }) + + test(`${type} - electron apis`, async () => { + const userDataPath = await app.evaluate(({ app }) => + app.getPath("userData"), + ) + + await expect(window.locator('[data-testid="user-data-path"]')).toHaveText( + userDataPath, + ) + }) + + test(`${type} - scripts`, async () => { + const counter = window.locator("[data-testid='counter']") + await expect(counter).toHaveText("0") + await counter.click({ clickCount: 2 }) + await expect(counter).toHaveText("2") + }) + + test(`${type} - action referrer redirect`, async () => { + await window.goto("http://localhost/referrer-redirect/form") + + const redirectCount = window.locator("[data-testid=redirects]") + await expect(redirectCount).toHaveText("0") + await window.click("text=submit") + await expect(redirectCount).toHaveText("1") + }) + + test.skip(`${type} - multipart uploads`, async () => { + await window.goto("http://localhost/multipart-uploads") + + const assetUrl = new URL( + "./fixtures/asset-files/file-upload.txt", + import.meta.url, + ) + + const assetContent = await readFile(assetUrl, "utf-8") + + await window + .locator("input[type=file]") + .setInputFiles(fileURLToPath(assetUrl)) + await window.locator("button").click() + await expect(window.locator("[data-testid=result]")).toHaveText( + assetContent, + ) + }) + + test(`${type} - can load public assets that contain whitespace in their path`, async () => { + await window.goto("http://localhost/with spaces.txt") + + await expect(window.locator("body")).toHaveText( + "This is a file with spaces in the path", + ) + }) +}