diff --git a/README.md b/README.md index 6bee8b6..016de89 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Install depencencies: $ npm install next-electron-rsc next electron electron-builder ``` -Add following to your `main.js` in Electron: +Add following to your `main.js` in Electron before you create a window: ```js import { app, protocol } from 'electron'; @@ -28,8 +28,12 @@ const { createInterceptor } = createHandler({ localhostUrl, protocol, }); +``` + +Then add this when `mainWindow` is created: -if (!isDev) createInterceptor(); +```js +if (!isDev) createInterceptor({ session: mainWindow.webContents.session }); ``` Configure your Next.js build in `next.config.js`: diff --git a/demo/src-electron/index.ts b/demo/src-electron/index.ts index c8b089e..a6865c2 100644 --- a/demo/src-electron/index.ts +++ b/demo/src-electron/index.ts @@ -1,14 +1,14 @@ import path from 'path'; -import { app, BrowserWindow, Menu, protocol, shell } from 'electron'; +import { app, BrowserWindow, Menu, protocol, session, shell } from 'electron'; import defaultMenu from 'electron-default-menu'; import { createHandler } from 'next-electron-rsc'; const isDev = process.env.NODE_ENV === 'development'; -const debugServer = !!process.env.DEBUG_SERVER; const appPath = app.getAppPath(); -const localhostUrl = 'http://localhost:666'; // must match Next.js dev server +const localhostUrl = 'http://localhost:3000'; // must match Next.js dev server let mainWindow; +let stopIntercept; process.env['ELECTRON_DISABLE_SECURITY_WARNINGS'] = 'true'; process.env['ELECTRON_ENABLE_LOGGING'] = 'true'; @@ -21,9 +21,22 @@ const openDevTools = () => { mainWindow.webContents.openDevTools(); }; +// Next.js handler + +const standaloneDir = path.join(appPath, '.next', 'standalone', 'demo'); + +const { createInterceptor } = createHandler({ + standaloneDir, + localhostUrl, + protocol, + debug: true, +}); + +// Next.js handler + const createWindow = async () => { mainWindow = new BrowserWindow({ - width: isDev ? 2000 : 1000, + width: 1000, height: 800, webPreferences: { contextIsolation: true, // protect against prototype pollution @@ -33,25 +46,19 @@ const createWindow = async () => { // Next.js handler - const standaloneDir = path.join(appPath, '.next', 'standalone', 'demo'); - - const { createInterceptor } = createHandler({ - standaloneDir, - localhostUrl, - protocol, - debug: true, - }); - - if (!isDev || debugServer) { - if (debugServer) console.log(`[APP] Server Debugging Enabled, ${localhostUrl} will be intercepted`); - createInterceptor(); + if (!isDev) { + console.log(`[APP] Server Debugging Enabled, ${localhostUrl} will be intercepted to ${standaloneDir}`); + stopIntercept = createInterceptor({ session: mainWindow.webContents.session }); } // Next.js handler - mainWindow.once('ready-to-show', () => isDev && openDevTools()); + mainWindow.once('ready-to-show', () => openDevTools()); - mainWindow.on('closed', () => (mainWindow = null)); + mainWindow.on('closed', () => { + mainWindow = null; + stopIntercept?.(); + }); mainWindow.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url).catch((e) => console.error(e)); diff --git a/demo/src/app/client.tsx b/demo/src/app/client.tsx index 587e9c2..4f92d36 100644 --- a/demo/src/app/client.tsx +++ b/demo/src/app/client.tsx @@ -5,6 +5,7 @@ import Image from 'next/image'; export default function Client({ foo }) { const [text, setText] = useState(); + const [cookie, setCookie] = useState(); useEffect(() => { fetch('/test', { method: 'POST', body: 'Hello from frontend!' }) @@ -12,10 +13,18 @@ export default function Client({ foo }) { .then((text) => setText(text)); }, []); + useEffect(() => { + setCookie(document.cookie); + }, []); + return (
Server: {foo}, API: {text} +
+ Cookie: {cookie} +
Next Electron RSC +
Next Electron RSC
); diff --git a/demo/src/app/test/route.ts b/demo/src/app/test/route.ts index c104413..1a8e2d3 100644 --- a/demo/src/app/test/route.ts +++ b/demo/src/app/test/route.ts @@ -1,7 +1,21 @@ -import { NextResponse as Response } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; export const dynamic = 'force-dynamic'; -export async function POST(req: Request) { - return Response.json({ message: 'Hello from Next.js! in response to ' + (await req.text()) }); +export async function POST(req: NextRequest) { + const iteration = parseInt(req.cookies.get('iteration')?.value, 10) || 0; + + const res = NextResponse.json({ message: 'Hello from Next.js! in response to ' + (await req.text()) }); + + res.cookies.set('iteration', (iteration + 1).toString(), { + path: '/', + maxAge: 60 * 60, // 1 hour + }); + + res.cookies.set('date', Date.now().toString(), { + path: '/', + maxAge: 60 * 60, // 1 hour + }); + + return res; } diff --git a/lib/README.md b/lib/README.md index 6bee8b6..016de89 100644 --- a/lib/README.md +++ b/lib/README.md @@ -12,7 +12,7 @@ Install depencencies: $ npm install next-electron-rsc next electron electron-builder ``` -Add following to your `main.js` in Electron: +Add following to your `main.js` in Electron before you create a window: ```js import { app, protocol } from 'electron'; @@ -28,8 +28,12 @@ const { createInterceptor } = createHandler({ localhostUrl, protocol, }); +``` + +Then add this when `mainWindow` is created: -if (!isDev) createInterceptor(); +```js +if (!isDev) createInterceptor({ session: mainWindow.webContents.session }); ``` Configure your Next.js build in `next.config.js`: diff --git a/lib/package.json b/lib/package.json index 7cf7de7..19d5027 100644 --- a/lib/package.json +++ b/lib/package.json @@ -1,6 +1,6 @@ { "name": "next-electron-rsc", - "version": "0.1.5", + "version": "0.2.0", "description": "Next.js + Electron + React Server Components", "main": "build/index.js", "main:src": "build/index.tsx", @@ -15,13 +15,16 @@ }, "license": "MIT", "dependencies": { - "resolve": "^1.22.8" + "cookie": "^1.0.1", + "resolve": "^1.22.8", + "set-cookie-parser": "^2.7.1" }, "devDependencies": { "@types/node": "^22.8.6", "@types/react": "18.3.11", "@types/react-dom": "^18.3.1", "@types/resolve": "^1.20.6", + "@types/set-cookie-parser": "^2", "electron": "^33.0.2", "next": "^15.0.2", "typescript": "^5.6.3" diff --git a/lib/src/index.ts b/lib/src/index.ts index 00eed26..d0e7f1b 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -1,31 +1,64 @@ -import type { Protocol, ProtocolRequest, ProtocolResponse } from 'electron'; +import type { Protocol, Session } from 'electron'; import type { NextConfig } from 'next'; import type NextNodeServer from 'next/dist/server/next-server'; import { IncomingMessage, ServerResponse } from 'node:http'; import { Socket } from 'node:net'; -import http from 'node:http'; -import https from 'node:https'; import resolve from 'resolve'; import { parse } from 'url'; import path from 'path'; -import { PassThrough } from 'node:stream'; - -function createRequest({ socket, origReq }: { socket: Socket; origReq: ProtocolRequest }): IncomingMessage { +import fs from 'fs'; +import { parse as parseCookie, splitCookiesString } from 'set-cookie-parser'; +import { serialize as serializeCookie } from 'cookie'; +import assert = require('node:assert'); + +async function createRequest({ + socket, + origReq, + session, +}: { + socket: Socket; + origReq: Request; + session: Session; +}): Promise { const req = new IncomingMessage(socket); - const url = parse(origReq.url, false); + const url = new URL(origReq.url); // Normal Next.js URL does not contain schema and host/port, otherwise endless loops due to butchering of schema by normalizeRepeatedSlashes in resolve-routes req.url = url.pathname + (url.search || ''); req.method = origReq.method; - req.headers = origReq.headers; - origReq.uploadData?.forEach((item) => { - if (!item.bytes) return; - req.push(item.bytes); + origReq.headers.forEach((value, key) => { + req.headers[key] = value; }); + // @see https://github.com/electron/electron/issues/39525#issue-1852825052 + const cookies = await session.cookies.get({ + url: origReq.url, + // domain: url.hostname, + // path: url.pathname, + // `secure: true` Cookies should not be sent via http + // secure: url.protocol === 'http:' ? false : undefined, + // theoretically not possible to implement sameSite because we don't know the url + // of the website that is requesting the resource + }); + + if (cookies.length) { + const cookiesHeader = []; + + for (const cookie of cookies) { + const { name, value, ...options } = cookie; + cookiesHeader.push(serializeCookie(name, value)); // ...(options as any)? + } + + req.headers.cookie = cookiesHeader.join('; '); + } + + if (origReq.body) { + req.push(Buffer.from(await origReq.arrayBuffer())); + } + req.push(null); req.complete = true; @@ -33,31 +66,56 @@ function createRequest({ socket, origReq }: { socket: Socket; origReq: ProtocolR } class ReadableServerResponse extends ServerResponse { - private passThrough = new PassThrough(); - private promiseResolvers = Promise.withResolvers(); // there is no event for writeHead - - constructor(req: IncomingMessage) { - super(req); - this.write = this.passThrough.write.bind(this.passThrough); - this.end = this.passThrough.end.bind(this.passThrough); - this.passThrough.on('drain', () => this.emit('drain')); + write(chunk: any, ...args): boolean { + this.emit('data', chunk); + return super.write(chunk, ...args); } - writeHead(statusCode: number, ...args: any): this { - super.writeHead(statusCode, ...args); - - this.promiseResolvers.resolve({ - statusCode: this.statusCode, - mimeType: this.getHeader('Content-Type') as any, - headers: this.getHeaders() as any, - data: this.passThrough as any, - }); + end(chunk: any, ...args): this { + this.emit('end', chunk); + return super.end(chunk, ...args); + } - return this; + writeHead(statusCode: number, ...args: any): this { + this.emit('writeHead', statusCode); + return super.writeHead(statusCode, ...args); } - async createProtocolResponse() { - return this.promiseResolvers.promise; + getResponse() { + return new Promise((resolve, reject) => { + const readableStream = new ReadableStream({ + start: (controller) => { + let onData; + + this.on( + 'data', + (onData = (chunk) => { + controller.enqueue(chunk); + }), + ); + + this.once('end', (chunk) => { + controller.enqueue(chunk); + controller.close(); + this.off('data', onData); + }); + }, + pull: (controller) => { + this.emit('drain'); + }, + cancel: () => {}, + }); + + this.once('writeHead', (statusCode) => { + resolve( + new Response(readableStream, { + status: statusCode, + statusText: this.statusMessage, + headers: this.getHeaders() as any, + }), + ); + }); + }); } } @@ -82,6 +140,11 @@ export function createHandler({ protocol: Protocol; debug?: boolean; }) { + assert(standaloneDir, 'standaloneDir is required'); + assert(protocol, 'protocol is required'); + + assert(fs.existsSync(standaloneDir), 'standaloneDir does not exist'); + const next = require(resolve.sync('next', { basedir: standaloneDir })); // @see https://github.com/vercel/next.js/issues/64031#issuecomment-2078708340 @@ -99,66 +162,92 @@ export function createHandler({ let socket; + protocol.registerSchemesAsPrivileged([ + { + scheme: 'http', + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + }, + }, + ]); + //TODO Return function to close socket process.on('SIGTERM', () => socket.end()); process.on('SIGINT', () => socket.end()); - async function handleRequest(origReq: ProtocolRequest): Promise { - try { - if (!socket) throw new Error('Socket is not initialized, check if createInterceptor was called'); + /** + * @param {import('electron').Session} session + * @returns {() => void} + */ + function createInterceptor({ session }: { session: Session }) { + assert(session, 'Session is required'); - await preparePromise; - - const req = createRequest({ socket, origReq }); - const res = new ReadableServerResponse(req); - const url = parse(req.url, true); + socket = new Socket(); - handler(req, res, url); + protocol.handle('http', async (request) => { + try { + if (!request.url.startsWith(localhostUrl)) { + if (debug) console.log('[NEXT] External HTTP not supported', request.url); + throw new Error('External HTTP not supported, use HTTPS'); + } - return await res.createProtocolResponse(); - } catch (e) { - return e; - } - } + if (!socket) throw new Error('Socket is not initialized, check if createInterceptor was called'); - function createInterceptor() { - socket = new Socket(); + await preparePromise; - protocol.interceptStreamProtocol('http', async (request, callback) => { - if (!request.url.startsWith(localhostUrl)) { - const protocol = (request.url.startsWith('https') ? https : http) as any; + const req = await createRequest({ socket, origReq: request, session }); + const res = new ReadableServerResponse(req); + const url = parse(req.url, true); - const req = protocol.request( - { - method: request.method, - headers: request.headers, - url: request.url, - }, - callback, - ); + handler(req, res, url); - request.uploadData?.forEach((item) => { - if (!item.bytes) return; - req.write(item.bytes); - }); + const response = await res.getResponse(); - req.end(); + // @see https://github.com/electron/electron/issues/30717 + // @see https://github.com/electron/electron/issues/39525 + const cookies = parseCookie( + response.headers.getSetCookie().reduce((r, c) => { + // @see https://github.com/nfriedly/set-cookie-parser?tab=readme-ov-file#usage-in-react-native-and-with-some-other-fetch-implementations + return [...r, ...splitCookiesString(c)]; + }, []), + ); - return; - } + for (const cookie of cookies) { + const expires = cookie.expires + ? cookie.expires.getTime() + : cookie.maxAge + ? Date.now() + cookie.maxAge * 1000 + : undefined; + + if (expires < Date.now()) { + await session.cookies.remove(request.url, cookie.name); + continue; + } + + await session.cookies.set({ + name: cookie.name, + value: cookie.value, + path: cookie.path, + domain: cookie.domain, + secure: cookie.secure, + httpOnly: cookie.httpOnly, + url: request.url, + expirationDate: expires, + } as any); + } - try { - const response = await handleRequest(request); - if (debug) console.log('[NEXT] Handler', request.url, response.statusCode, response.mimeType); - callback(response); + if (debug) console.log('[NEXT] Handler', request.url, response.status); + return response; } catch (e) { if (debug) console.log('[NEXT] Error', e); - callback(e); + return new Response(e.message, { status: 500 }); } }); return () => { - protocol.uninterceptProtocol('http'); + protocol.unhandle('http'); socket.end(); }; } diff --git a/yarn.lock b/yarn.lock index dacde77..1759559 100644 --- a/yarn.lock +++ b/yarn.lock @@ -851,6 +851,15 @@ __metadata: languageName: node linkType: hard +"@types/set-cookie-parser@npm:^2": + version: 2.4.10 + resolution: "@types/set-cookie-parser@npm:2.4.10" + dependencies: + "@types/node": "npm:*" + checksum: 10/105cc90c7d7deeb344858f720b58bd137356586545ac00d1a448e050bfcc0f385553ff26bc9c674bd8c2e953a458149eadb1945ee3d1eee81e6c0656236ebc0a + languageName: node + linkType: hard + "@types/verror@npm:^1.10.3": version: 1.10.5 resolution: "@types/verror@npm:1.10.5" @@ -1924,6 +1933,13 @@ __metadata: languageName: node linkType: hard +"cookie@npm:^1.0.1": + version: 1.0.1 + resolution: "cookie@npm:1.0.1" + checksum: 10/4b24d4fad5ba94ab76d74a8fc33ae1dcdb5dc02013e03e9577b26f019d9dfe396ffb9b3711ba1726bcfa1b93c33d117db0f31e187838aed7753dee1abc691688 + languageName: node + linkType: hard + "core-util-is@npm:1.0.2, core-util-is@npm:~1.0.0": version: 1.0.2 resolution: "core-util-is@npm:1.0.2" @@ -4942,9 +4958,12 @@ __metadata: "@types/react": "npm:18.3.11" "@types/react-dom": "npm:^18.3.1" "@types/resolve": "npm:^1.20.6" + "@types/set-cookie-parser": "npm:^2" + cookie: "npm:^1.0.1" electron: "npm:^33.0.2" next: "npm:^15.0.2" resolve: "npm:^1.22.8" + set-cookie-parser: "npm:^2.7.1" typescript: "npm:^5.6.3" peerDependencies: electron: ">=30" @@ -6141,6 +6160,13 @@ __metadata: languageName: node linkType: hard +"set-cookie-parser@npm:^2.7.1": + version: 2.7.1 + resolution: "set-cookie-parser@npm:2.7.1" + checksum: 10/c92b1130032693342bca13ea1b1bc93967ab37deec4387fcd8c2a843c0ef2fd9a9f3df25aea5bb3976cd05a91c2cf4632dd6164d6e1814208fb7d7e14edd42b4 + languageName: node + linkType: hard + "set-function-length@npm:^1.2.1": version: 1.2.2 resolution: "set-function-length@npm:1.2.2"