diff --git a/README.md b/README.md index e893565..dc0236b 100644 --- a/README.md +++ b/README.md @@ -24,26 +24,26 @@ Add a file at `desktop/index.js` to run the electron app. The `initRemix` functi ```ts // desktop/index.js -const { initRemix } = require("remix-electron") -const { app, BrowserWindow } = require("electron") -const { join } = require("node:path") +const { initRemix } = require("remix-electron"); +const { app, BrowserWindow } = require("electron"); +const { join } = require("node:path"); /** @type {BrowserWindow | undefined} */ -let win +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 }) - await win.loadURL(url) - win.show() + win = new BrowserWindow({ show: false }); + await win.loadURL(url); + win.show(); } catch (error) { - console.error(error) + console.error(error); } -}) +}); ``` Build the app with `npm run build`, then run `npx electron desktop/index.js` to start the app! 🚀 @@ -56,18 +56,18 @@ To circumvent this, create a `electron.server.ts` file, which re-exports from el ```ts // app/electron.server.ts -import electron from "electron" -export default electron +import electron from "electron"; +export default electron; ``` ```ts // app/routes/_index.tsx -import electron from "~/electron.server" +import electron from "~/electron.server"; export function loader() { return { userDataPath: electron.app.getPath("userData"), - } + }; } ``` @@ -82,7 +82,7 @@ function createWindow() { webPreferences: { nodeIntegration: true, }, - }) + }); } ``` @@ -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 @@ -108,17 +110,17 @@ Options: **app/context.ts** ```ts -import type * as remix from "@remix-run/node" +import type * as remix from "@remix-run/node"; // your context type export type LoadContext = { - secret: string -} + secret: string; +}; // a custom data function args type to use for loaders/actions export type DataFunctionArgs = Omit & { - context: LoadContext -} + context: LoadContext; +}; ``` **desktop/main.js** @@ -131,13 +133,13 @@ const url = await initRemix({ getLoadContext: () => ({ secret: "123", }), -}) +}); ``` In a route file: ```ts -import type { DataFunctionArgs, LoadContext } from "~/context" +import type { DataFunctionArgs, LoadContext } from "~/context"; export async function loader({ context }: DataFunctionArgs) { // do something with context diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08b8aed..92ba5fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -141,6 +141,46 @@ importers: specifier: ^5.3.3 version: 5.3.3 + workspaces/test-app-esm: + dependencies: + '@remix-run/node': + specifier: ^2.5.1 + version: 2.5.1(typescript@5.3.3) + '@remix-run/react': + specifier: ^2.5.1 + version: 2.5.1(react-dom@18.2.0)(react@18.2.0)(typescript@5.3.3) + isbot: + specifier: ^4.4.0 + version: 4.4.0 + react: + specifier: ^18.2.0 + version: 18.2.0 + react-dom: + specifier: ^18.2.0 + version: 18.2.0(react@18.2.0) + remix-electron: + specifier: ^2.0.1 + version: link:../remix-electron + devDependencies: + '@remix-run/dev': + specifier: ^2.5.1 + version: 2.5.1(@remix-run/serve@2.5.1)(@types/node@20.11.14)(typescript@5.3.3) + '@types/react': + specifier: ^18.2.48 + version: 18.2.48 + '@types/react-dom': + specifier: ^18.2.18 + version: 18.2.18 + electron: + specifier: ^28.2.1 + version: 28.2.1 + nodemon: + specifier: ^3.0.3 + version: 3.0.3 + typescript: + specifier: ^5.3.3 + version: 5.3.3 + workspaces/tests: devDependencies: '@playwright/test': diff --git a/workspaces/remix-electron/src/index.mts b/workspaces/remix-electron/src/index.mts index 56469f2..82a7a91 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..5d827d9 --- /dev/null +++ b/workspaces/test-app-esm/nodemon.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json.schemastore.org/nodemon.json", + "exec": "electron --trace-warnings ./desktop/index.mjs", + "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..b8440cc --- /dev/null +++ b/workspaces/test-app-esm/package.json @@ -0,0 +1,27 @@ +{ + "name": "remix-electron-test-app", + "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", + "isbot": "^4.4.0", + "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.mjs b/workspaces/test-app-esm/remix.config.mjs new file mode 100644 index 0000000..e86b2af --- /dev/null +++ b/workspaces/test-app-esm/remix.config.mjs @@ -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..1b341b7 --- /dev/null +++ b/workspaces/test-app-esm/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "verbatimModuleSyntax": false, + "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 1b8d18c..cfda970 100644 --- a/workspaces/tests/tests/integration.test.ts +++ b/workspaces/tests/tests/integration.test.ts @@ -1,82 +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 { run } from "./integration.js" export const appFolder = new URL("../../test-app", import.meta.url) -let app!: ElectronApplication -let window!: Page - -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", + ) + }) +}