diff --git a/package.json b/package.json index c104917ddb..f7a3300e0b 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "consola": "^3.4.2", "crossws": "^0.4.4", "db0": "^0.3.4", + "env-runner": "^0.1.6", "h3": "^2.0.1-rc.16", "hookable": "^6.0.1", "nf3": "^0.3.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d1fb719aac..9eb47901a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,6 +25,9 @@ importers: dotenv: specifier: '*' version: 17.3.1 + env-runner: + specifier: ^0.1.6 + version: 0.1.6(miniflare@4.20260305.0) h3: specifier: ^2.0.1-rc.16 version: 2.0.1-rc.16(crossws@0.4.4(srvx@0.11.9)) @@ -3875,6 +3878,15 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + env-runner@0.1.6: + resolution: {integrity: sha512-fSb7X1zdda8k6611a6/SdSQpDe7a/bqMz2UWdbHjk9YWzpUR4/fn9YtE/hqgGQ2nhvVN0zUtcL1SRMKwIsDbAA==} + hasBin: true + peerDependencies: + miniflare: ^4.0.0 + peerDependenciesMeta: + miniflare: + optional: true + eol@0.9.1: resolution: {integrity: sha512-Ds/TEoZjwggRoz/Q2O7SE3i4Jm66mqTDfmdHdq/7DKVk3bro9Q8h6WdXKdPqFLMoqxrDK5SVRzHVPOS6uuGtrg==} @@ -9500,7 +9512,7 @@ snapshots: '@fastify/error': 4.2.0 fastq: 1.20.1 - axios-retry@4.5.0(axios@1.13.6(debug@4.4.3)): + axios-retry@4.5.0(axios@1.13.6): dependencies: axios: 1.13.6(debug@4.4.3) is-retry-allowed: 2.2.0 @@ -10114,6 +10126,14 @@ snapshots: entities@7.0.1: {} + env-runner@0.1.6(miniflare@4.20260305.0): + dependencies: + crossws: 0.4.4(srvx@0.11.9) + httpxy: 0.3.1 + srvx: 0.11.9 + optionalDependencies: + miniflare: 4.20260305.0 + eol@0.9.1: {} error-stack-parser-es@1.0.5: {} @@ -13413,7 +13433,7 @@ snapshots: dependencies: '@toon-format/toon': 0.9.0 axios: 1.13.6(debug@4.4.3) - axios-retry: 4.5.0(axios@1.13.6(debug@4.4.3)) + axios-retry: 4.5.0(axios@1.13.6) debug: 4.4.3 eventsource: 4.1.0 git-url-parse: 15.0.0 diff --git a/src/build/rolldown/dev.ts b/src/build/rolldown/dev.ts index f71da918ce..4b41eac4d7 100644 --- a/src/build/rolldown/dev.ts +++ b/src/build/rolldown/dev.ts @@ -1,8 +1,7 @@ import type { Nitro } from "nitro/types"; import type { RolldownWatcher, RolldownOptions } from "rolldown"; import { watch as chokidarWatch } from "chokidar"; -import { watch } from "node:fs"; -import { join } from "pathe"; +import { basename, join } from "pathe"; import { debounce } from "perfect-debounce"; import { scanHandlers } from "../../scan.ts"; import { writeTypes } from "../types.ts"; @@ -41,8 +40,12 @@ export async function watchDev(nitro: Nitro, config: RolldownOptions) { } }); - const rootDirWatcher = watch(nitro.options.rootDir, { persistent: false }, (_event, filename) => { - if (filename && /^server\.[mc]?[jt]sx?$/.test(filename)) { + const serverEntryRe = /^server\.[mc]?[jt]sx?$/; + const rootDirWatcher = chokidarWatch(nitro.options.rootDir, { + ignoreInitial: true, + depth: 0, + }).on("all", (event, path) => { + if (watchReloadEvents.has(event) && serverEntryRe.test(basename(path))) { reload(); } }); @@ -55,6 +58,10 @@ export async function watchDev(nitro: Nitro, config: RolldownOptions) { nitro.hooks.hook("rollup:reload", () => reload()); + nitro.logger.info( + `Starting dev watcher (builder: \`rolldown\`, preset: \`${nitro.options.preset}\`, compatibility date: \`${formatCompatibilityDate(nitro.options.compatibilityDate)}\`)` + ); + await load(); function startWatcher(nitro: Nitro, config: RolldownOptions) { @@ -68,9 +75,6 @@ export async function watchDev(nitro: Nitro, config: RolldownOptions) { switch (event.code) { case "START": { start = Date.now(); - nitro.logger.info( - `Starting dev watcher (builder: \`rolldown\`, preset: \`${nitro.options.preset}\`, compatibility date: \`${formatCompatibilityDate(nitro.options.compatibilityDate)}\`)` - ); nitro.hooks.callHook("dev:start"); break; } diff --git a/src/build/rollup/dev.ts b/src/build/rollup/dev.ts index 6653a57b03..20f1fb5eb3 100644 --- a/src/build/rollup/dev.ts +++ b/src/build/rollup/dev.ts @@ -1,9 +1,8 @@ import type { Nitro, RollupConfig } from "nitro/types"; import type { RollupWatcher } from "rollup"; import { watch as chokidarWatch } from "chokidar"; -import { watch } from "node:fs"; import { defu } from "defu"; -import { join } from "pathe"; +import { basename, join } from "pathe"; import { debounce } from "perfect-debounce"; import { scanHandlers } from "../../scan.ts"; import { formatRollupError } from "./error.ts"; @@ -43,8 +42,12 @@ export async function watchDev(nitro: Nitro, rollupConfig: RollupConfig) { } }); - const rootDirWatcher = watch(nitro.options.rootDir, { persistent: false }, (_event, filename) => { - if (filename && /^server\.[mc]?[jt]sx?$/.test(filename)) { + const serverEntryRe = /^server\.[mc]?[jt]sx?$/; + const rootDirWatcher = chokidarWatch(nitro.options.rootDir, { + ignoreInitial: true, + depth: 0, + }).on("all", (event, path) => { + if (watchReloadEvents.has(event) && serverEntryRe.test(basename(path))) { reload(); } }); @@ -57,6 +60,10 @@ export async function watchDev(nitro: Nitro, rollupConfig: RollupConfig) { nitro.hooks.hook("rollup:reload", () => reload()); + nitro.logger.info( + `Starting dev watcher (builder: \`rollup\`, preset: \`${nitro.options.preset}\`, compatibility date: \`${formatCompatibilityDate(nitro.options.compatibilityDate)}\`)` + ); + await load(); function startRollupWatcher(nitro: Nitro, rollupConfig: RollupConfig) { @@ -75,9 +82,6 @@ export async function watchDev(nitro: Nitro, rollupConfig: RollupConfig) { switch (event.code) { case "START": { start = Date.now(); - nitro.logger.info( - `Starting dev watcher (builder: \`rollup\`, preset: \`${nitro.options.preset}\`, compatibility date: \`${formatCompatibilityDate(nitro.options.compatibilityDate)}\`)` - ); nitro.hooks.callHook("dev:start"); break; } diff --git a/src/build/vite/dev.ts b/src/build/vite/dev.ts index 04c758569f..a83f179822 100644 --- a/src/build/vite/dev.ts +++ b/src/build/vite/dev.ts @@ -1,9 +1,12 @@ import type { NitroPluginContext } from "./types.ts"; -import type { DevEnvironmentContext, HotChannel, ResolvedConfig, ViteDevServer } from "vite"; +import type { DevEnvironmentContext, ResolvedConfig, ViteDevServer } from "vite"; +import type { FetchFunctionOptions, FetchResult } from "vite/module-runner"; +import type { RunnerRPCHooks } from "env-runner"; import { IncomingMessage, ServerResponse } from "node:http"; import { NodeRequest, sendNodeResponse } from "srvx/node"; import { DevEnvironment } from "vite"; +import { createViteHotChannel } from "env-runner/vite"; import { watch as chokidarWatch } from "chokidar"; import { watch as fsWatch } from "node:fs"; import { join } from "pathe"; @@ -17,13 +20,7 @@ import { getEnvRunner } from "./env.ts"; export type FetchHandler = (req: Request) => Promise; -export interface TransportHooks { - sendMessage: (data: any) => void; - onMessage: (listener: (value: any) => void) => void; - offMessage: (listener: (value: any) => void) => void; -} - -export interface DevServer extends TransportHooks { +export interface DevServer extends RunnerRPCHooks { fetch: FetchHandler; init?: () => void | Promise; } @@ -34,31 +31,58 @@ export function createFetchableDevEnvironment( name: string, config: ResolvedConfig, devServer: DevServer, - entry: string + entry: string, + opts?: { preventExternalize?: boolean } ): FetchableDevEnvironment { - const transport = createTransport(name, devServer); + const transport = createViteHotChannel(devServer, name); const context: DevEnvironmentContext = { hot: true, transport }; - return new FetchableDevEnvironment(name, config, context, devServer, entry); + return new FetchableDevEnvironment(name, config, context, devServer, entry, opts); } export class FetchableDevEnvironment extends DevEnvironment { devServer: DevServer; + #entry: string; + #preventExternalize: boolean; + constructor( name: string, config: ResolvedConfig, context: DevEnvironmentContext, devServer: DevServer, - entry: string + entry: string, + opts?: { preventExternalize?: boolean } ) { super(name, config, context); this.devServer = devServer; + this.#entry = entry; + this.#preventExternalize = opts?.preventExternalize ?? false; + } - this.devServer.sendMessage({ - type: "custom", - event: "nitro:vite-env", - data: { name, entry }, - }); + override async fetchModule( + id: string, + importer?: string, + options?: FetchFunctionOptions + ): Promise { + // workerd cannot handle CJS/Node modules loaded via import(). + // Bare imports (like "vue") are normally externalized by Vite's fetchModule, + // resolved using mainFields: ["main"] which often picks CJS entries. + // We intercept bare imports, resolve them through the environment's plugin + // pipeline (which respects resolve.conditions and picks ESM), then route + // the resolved path through transformRequest for proper SSR processing. + if ( + this.#preventExternalize && + !id.startsWith("file://") && + importer && + id[0] !== "." && + id[0] !== "/" + ) { + const resolved = await this.pluginContainer.resolveId(id, importer); + if (resolved && !resolved.external) { + return super.fetchModule(resolved.id, importer, options); + } + } + return super.fetchModule(id, importer, options); } async dispatchFetch(request: Request): Promise { @@ -67,37 +91,15 @@ export class FetchableDevEnvironment extends DevEnvironment { override async init(...args: any[]): Promise { await this.devServer.init?.(); - return super.init(...args); + await super.init(...args); + this.devServer.sendMessage({ + type: "custom", + event: "nitro:vite-env", + data: { name: this.name, entry: this.#entry }, + }); } } -function createTransport(name: string, hooks: TransportHooks): HotChannel { - const listeners = new WeakMap(); - return { - send: (data) => hooks.sendMessage({ ...data, viteEnv: name }), - on: (event: string, handler: any) => { - if (event === "connection") return; - const listener = (value: any) => { - if (value?.type === "custom" && value.event === event && value.viteEnv === name) { - handler(value.data, { - send: (payload: any) => hooks.sendMessage({ ...payload, viteEnv: name }), - }); - } - }; - listeners.set(handler, listener); - hooks.onMessage(listener); - }, - off: (event, handler) => { - if (event === "connection") return; - const listener = listeners.get(handler); - if (listener) { - hooks.offMessage(listener); - listeners.delete(handler); - } - }, - }; -} - // ---- Vite Dev Server Integration ---- export async function configureViteDevServer(ctx: NitroPluginContext, server: ViteDevServer) { @@ -117,7 +119,7 @@ export async function configureViteDevServer(ctx: NitroPluginContext, server: Vi // Vite upgrade. TODO: Is there a better way? return; } - getEnvRunner(ctx).upgrade?.(req, socket, head); + getEnvRunner(ctx).upgrade?.({ node: { req, socket, head } }); }); } @@ -160,30 +162,21 @@ export async function configureViteDevServer(ctx: NitroPluginContext, server: Vi rootDirWatcher.close(); }); - // Worker => Host IPC - const hostIPC = { - async transformHTML(html: string) { - return server - .transformIndexHtml("/", html) - .then((r) => - r.replace( - "", - `{{{ globalThis.__nitro_vite_envs__?.["ssr"]?.fetch($REQUEST) || "" }}}` - ) + // Worker => Host RPC + nitroEnv.devServer.onMessage(async (message: any) => { + if (message?.__rpc === "transformHTML") { + try { + const html = (await server.transformIndexHtml("/", message.data)).replace( + "", + `{{{ globalThis.__nitro_vite_envs__?.["ssr"]?.fetch($REQUEST) || "" }}}` ); - }, - }; - nitroEnv.devServer.onMessage(async (payload) => { - if (payload.type === "custom" && payload.event === "nitro:vite-invoke") { - const methodName = payload.data.name as keyof typeof hostIPC; - const res = await hostIPC[methodName](payload.data.data) - .then((data) => ({ data })) - .catch((error) => ({ error })); - nitroEnv.devServer.sendMessage({ - type: "custom", - event: "nitro:vite-invoke-response", - data: { id: payload.data.id, data: res }, - }); + nitroEnv.devServer.sendMessage({ __rpc_id: message.__rpc_id, data: html }); + } catch (error) { + nitroEnv.devServer.sendMessage({ + __rpc_id: message.__rpc_id, + error: error instanceof Error ? error.message : String(error), + }); + } } }); diff --git a/src/build/vite/env.ts b/src/build/vite/env.ts index 174e4b9427..b68c5309a3 100644 --- a/src/build/vite/env.ts +++ b/src/build/vite/env.ts @@ -1,22 +1,16 @@ import type { EnvironmentOptions, RollupCommonJSOptions } from "vite"; import type { NitroPluginContext, ServiceConfig } from "./types.ts"; -import { NodeEnvRunner } from "../../runner/node.ts"; +import type { RunnerName } from "env-runner"; +import { RunnerManager, loadRunner } from "env-runner"; import { join, resolve } from "node:path"; import { runtimeDependencies, runtimeDir } from "nitro/meta"; import { resolveModulePath } from "exsolve"; import { createFetchableDevEnvironment } from "./dev.ts"; import { isAbsolute } from "pathe"; -export function getEnvRunner(ctx: NitroPluginContext) { - return (ctx._envRunner ??= new NodeEnvRunner({ - name: "nitro-vite", - entry: resolve(runtimeDir, "internal/vite/node-runner.mjs"), - data: { server: true }, - })); -} - export function createNitroEnvironment(ctx: NitroPluginContext): EnvironmentOptions { + const isWorkerdRunner = _isWorkerdRunner(ctx); return { consumer: "server", build: { @@ -30,13 +24,19 @@ export function createNitroEnvironment(ctx: NitroPluginContext): EnvironmentOpti }, resolve: { noExternal: ctx.nitro!.options.dev - ? [ - /^nitro$/, // i have absolutely no idea why and how it fixes issues! - new RegExp(`^(${runtimeDependencies.join("|")})$`), // virtual resolutions in vite skip plugin hooks - ...ctx.bundlerConfig!.base.noExternal, - ] + ? isWorkerdRunner + ? true + : [ + /^nitro$/, // i have absolutely no idea why and how it fixes issues! + new RegExp(`^(${runtimeDependencies.join("|")})$`), // virtual resolutions in vite skip plugin hooks + ...ctx.bundlerConfig!.base.noExternal, + ] : true, // production build is standalone - conditions: ctx.nitro!.options.exportConditions, + // workerd cannot handle CJS modules, so we must avoid the "node" export + // condition which often resolves to CJS entries. + conditions: isWorkerdRunner + ? ["workerd", "worker", ...ctx.nitro!.options.exportConditions!.filter((c) => c !== "node")] + : ctx.nitro!.options.exportConditions, externalConditions: ctx.nitro!.options.exportConditions?.filter( (c) => !/browser|wasm|module/.test(c) ), @@ -46,13 +46,15 @@ export function createNitroEnvironment(ctx: NitroPluginContext): EnvironmentOpti "process.env.NODE_ENV": JSON.stringify(ctx.nitro!.options.dev ? "development" : "production"), }, dev: { - createEnvironment: (envName, envConfig) => - createFetchableDevEnvironment( - envName, - envConfig, - getEnvRunner(ctx), - resolve(runtimeDir, "internal/vite/dev-entry.mjs") - ), + createEnvironment: (envName, envConfig) => { + const entry = resolve(runtimeDir, "internal/vite/dev-entry.mjs"); + const env = createFetchableDevEnvironment(envName, envConfig, getEnvRunner(ctx), entry, { + preventExternalize: isWorkerdRunner, + }); + ctx._transformRequest = (id) => env.transformRequest(id); + (ctx._viteEnvs ??= new Map()).set(envName, entry); + return env; + }, }, }; } @@ -62,6 +64,7 @@ export function createServiceEnvironment( name: string, serviceConfig: ServiceConfig ): EnvironmentOptions { + const isWorkerdRunner = _isWorkerdRunner(ctx); return { consumer: "server", build: { @@ -73,19 +76,22 @@ export function createServiceEnvironment( copyPublicDir: false, }, resolve: { - conditions: ctx.nitro!.options.exportConditions, + ...(isWorkerdRunner ? { noExternal: true } : {}), + conditions: isWorkerdRunner + ? ["workerd", "worker", ...ctx.nitro!.options.exportConditions!.filter((c) => c !== "node")] + : ctx.nitro!.options.exportConditions, externalConditions: ctx.nitro!.options.exportConditions?.filter( (c) => !/browser|wasm|module/.test(c) ), }, dev: { - createEnvironment: (envName, envConfig) => - createFetchableDevEnvironment( - envName, - envConfig, - getEnvRunner(ctx), - tryResolve(serviceConfig.entry) - ), + createEnvironment: (envName, envConfig) => { + const entry = tryResolve(serviceConfig.entry); + (ctx._viteEnvs ??= new Map()).set(envName, entry); + return createFetchableDevEnvironment(envName, envConfig, getEnvRunner(ctx), entry, { + preventExternalize: isWorkerdRunner, + }); + }, }, }; } @@ -101,6 +107,90 @@ export function createServiceEnvironments( ); } +export async function initEnvRunner(ctx: NitroPluginContext) { + if (ctx._envRunner) { + return ctx._envRunner; + } + if (!ctx._initPromise) { + ctx._initPromise = (async () => { + const manager = new RunnerManager(); + let _retries = 0; + manager.onClose((_runner, cause) => { + if (_retries++ < 3) { + ctx.nitro!.logger.info("Restarting env runner...", cause ? `Cause: ${cause}` : ""); + _loadRunner(ctx, manager); + } else { + ctx.nitro!.logger.error( + "Env runner failed after 3 retries.", + cause ? `Last cause: ${cause}` : "" + ); + } + }); + manager.onReady(() => { + _retries = 0; + if (ctx._viteEnvs) { + for (const [name, entry] of ctx._viteEnvs) { + manager.sendMessage({ + type: "custom", + event: "nitro:vite-env", + data: { name, entry }, + }); + } + } + }); + await _loadRunner(ctx, manager); + ctx._envRunner = manager; + return manager; + })(); + } + return await ctx._initPromise; +} + +export function getEnvRunner(ctx: NitroPluginContext) { + if (!ctx._envRunner) { + throw new Error("Env runner not initialized. Call initEnvRunner() first."); + } + return ctx._envRunner; +} + +export async function reloadEnvRunner(ctx: NitroPluginContext) { + const manager = ctx._envRunner; + if (!manager) { + return initEnvRunner(ctx); + } + await _loadRunner(ctx, manager); + return manager; +} + +async function _loadRunner(ctx: NitroPluginContext, manager: RunnerManager) { + const runnerName = (ctx.nitro!.options.devServer.runner || + process.env.NITRO_DEV_RUNNER || + "node-worker") as RunnerName; + const entry = resolve(runtimeDir, "internal/vite/dev-worker.mjs"); + let runner; + if (runnerName === "miniflare") { + const { MiniflareEnvRunner } = await import("env-runner/runners/miniflare"); + runner = new MiniflareEnvRunner({ + name: "nitro-vite", + data: { entry }, + }); + } else { + runner = await loadRunner(runnerName, { + name: "nitro-vite", + data: { entry }, + }); + } + await manager.reload(runner); +} + +// workerd-based runners (miniflare) cannot handle CJS externals via import(), +// so all dependencies must be processed through Vite's transform pipeline. +function _isWorkerdRunner(ctx: NitroPluginContext): boolean { + const runnerName = + ctx.nitro!.options.devServer.runner || process.env.NITRO_DEV_RUNNER || "node-worker"; + return runnerName === "miniflare"; +} + function tryResolve(id: string) { if (/^[~#/\0]/.test(id) || isAbsolute(id)) { return id; diff --git a/src/build/vite/plugin.ts b/src/build/vite/plugin.ts index 05a4bf1656..e8de9ecb3c 100644 --- a/src/build/vite/plugin.ts +++ b/src/build/vite/plugin.ts @@ -13,6 +13,7 @@ import { createNitro, prepare } from "../../builder.ts"; import { getBundlerConfig } from "./bundler.ts"; import { buildEnvironments, prodSetup } from "./prod.ts"; import { + initEnvRunner, getEnvRunner, createNitroEnvironment, createServiceEnvironments, @@ -440,7 +441,7 @@ async function setupNitroContext( // Warm up env runner for dev if (ctx.nitro.options.dev) { - getEnvRunner(ctx); + await initEnvRunner(ctx); } // Attach nitro.fetch to env runner diff --git a/src/build/vite/types.ts b/src/build/vite/types.ts index d0b26869d3..7a471f9aff 100644 --- a/src/build/vite/types.ts +++ b/src/build/vite/types.ts @@ -1,5 +1,7 @@ +import type { TransformResult } from "vite"; import type { getBundlerConfig } from "./bundler.ts"; -import type { EnvRunner, Nitro, NitroConfig, NitroModule } from "nitro/types"; +import type { Nitro, NitroConfig, NitroModule } from "nitro/types"; +import type { RunnerManager } from "env-runner"; import type { NitroDevApp } from "../../dev/app.ts"; declare module "vite" { @@ -58,7 +60,10 @@ export interface NitroPluginContext { _isRolldown?: boolean; _initialized?: boolean; - _envRunner?: EnvRunner; + _envRunner?: RunnerManager; + _initPromise?: Promise; + _viteEnvs?: Map; + _transformRequest?: (id: string) => Promise; _publicDistDir?: string; _entryPoints: Record; } diff --git a/src/dev/server.ts b/src/dev/server.ts index 5f9817b28e..a6b5e33d87 100644 --- a/src/dev/server.ts +++ b/src/dev/server.ts @@ -2,9 +2,10 @@ import type { IncomingMessage } from "node:http"; import type { Socket } from "node:net"; import type { FSWatcher } from "chokidar"; import type { ServerOptions, Server } from "srvx"; -import { NodeEnvRunner } from "../runner/node.ts"; -import type { EnvRunnerData } from "../runner/node.ts"; -import type { Nitro, RunnerMessageListener, RunnerRPCHooks, EnvRunner } from "nitro/types"; +import type { EnvRunnerData, RunnerMessageListener, RunnerRPCHooks } from "env-runner"; +import type { RunnerName } from "env-runner"; +import { RunnerManager, loadRunner } from "env-runner"; +import type { Nitro } from "nitro/types"; import { HTTPError } from "h3"; @@ -26,20 +27,30 @@ export class NitroDevServer extends NitroDevApp implements RunnerRPCHooks { #workerData: EnvRunnerData = {}; #listeners: Server[] = []; #watcher?: FSWatcher; - #workers: EnvRunner[] = []; + #manager: RunnerManager; #workerIdCtr: number = 0; #workerError?: unknown; + #workerRetries: number = 0; #building?: boolean = true; // Assume initial build will start soon #buildError?: unknown; - #messageListeners: Set = new Set(); + #reloadPromise?: Promise; constructor(nitro: Nitro) { super(nitro, async (event) => { - const worker = await this.#getWorker(); - if (!worker) { + if (this.#building) { + await this.#waitForBuild(); + } + if (this.#reloadPromise) { + await this.#reloadPromise; + } + if (this.#buildError) { + return this.#generateError(); + } + const response = await this.#manager.fetch(event.req as Request); + if (response.status === 503 && !this.#manager.ready) { return this.#generateError(); } - return worker.fetch(event.req as Request); + return response; }); // Bind all methods to `this` @@ -55,6 +66,28 @@ export class NitroDevServer extends NitroDevApp implements RunnerRPCHooks { this.#entry = resolve(nitro.options.output.dir, nitro.options.output.serverDir, "index.mjs"); + this.#manager = new RunnerManager(); + this.#manager.onReady(async (_runner, addr) => { + this.#workerRetries = 0; + writeDevBuildInfo(this.nitro, addr).catch((error) => { + this.nitro.logger.warn( + `Failed to write dev build info: ${error instanceof Error ? error.message : String(error)}` + ); + }); + }); + this.#manager.onClose((_runner, cause) => { + this.#workerError = cause; + if (this.#workerRetries++ < 3) { + this.nitro.logger.info("Restarting dev worker...", cause ? `Cause: ${cause}` : ""); + this.reload(); + } else { + this.nitro.logger.error( + "Dev worker failed after 3 retries.", + cause ? `Last cause: ${cause}` : "" + ); + } + }); + nitro.hooks.hook("close", () => this.close()); nitro.hooks.hook("dev:start", () => { @@ -77,9 +110,6 @@ export class NitroDevServer extends NitroDevApp implements RunnerRPCHooks { nitro.hooks.hook("dev:error", (cause: unknown) => { this.#buildError = cause; this.#building = false; - for (const worker of this.#workers) { - worker.close(); - } }); const devWatch = nitro.options.devServer.watch; @@ -93,20 +123,13 @@ export class NitroDevServer extends NitroDevApp implements RunnerRPCHooks { // #region Public Methods async upgrade(req: IncomingMessage, socket: Socket, head: any) { - const worker = await this.#getWorker(); - if (!worker) { - throw new HTTPError({ - status: 503, - statusText: "No worker available.", - }); - } - if (!worker.upgrade) { + if (!this.#manager.upgrade) { throw new HTTPError({ status: 501, statusText: "Worker does not support upgrades.", }); } - return worker.upgrade(req, socket, head); + return this.#manager.upgrade({ node: { req, socket, head } }); } listen(opts?: Partial>): Server { @@ -128,9 +151,7 @@ export class NitroDevServer extends NitroDevApp implements RunnerRPCHooks { Promise.all(this.#listeners.map((l) => l.close())).then(() => { this.#listeners = []; }), - Promise.all(this.#workers.map((w) => w.close())).then(() => { - this.#workers = []; - }), + this.#manager.close(), Promise.resolve(this.#watcher?.close()).then(() => { this.#watcher = undefined; }), @@ -143,73 +164,45 @@ export class NitroDevServer extends NitroDevApp implements RunnerRPCHooks { } reload() { - for (const worker of this.#workers) { - worker.close(); - } - const worker = new NodeEnvRunner({ + const nextReload = (this.#reloadPromise ?? Promise.resolve()) + .catch(() => {}) + .then(() => this.#reload()); + this.#reloadPromise = nextReload.finally(() => { + if (this.#reloadPromise === nextReload) { + this.#reloadPromise = undefined; + } + }); + } + + async #reload() { + const runnerName = + this.nitro.options.devServer.runner || process.env.NITRO_DEV_RUNNER || "node-worker"; + const runner = await loadRunner(runnerName as RunnerName, { name: `Nitro_${this.#workerIdCtr++}`, - entry: this.#entry, - data: this.#workerData, - hooks: { - onClose: (worker, cause) => { - this.#workerError = cause; - const index = this.#workers.indexOf(worker); - if (index !== -1) { - this.#workers.splice(index, 1); - } - }, - onReady: async (_worker, addr) => { - writeDevBuildInfo(this.nitro, addr).catch(() => {}); - }, - }, + data: { entry: this.#entry, ...this.#workerData }, }); - if (!worker.closed) { - for (const listener of this.#messageListeners) { - worker.onMessage(listener); - } - this.#workers.unshift(worker); - } + await this.#manager.reload(runner); } sendMessage(message: unknown) { - for (const worker of this.#workers) { - if (!worker.closed) { - worker.sendMessage(message); - } - } + this.#manager.sendMessage(message); } onMessage(listener: RunnerMessageListener) { - this.#messageListeners.add(listener); - for (const worker of this.#workers) { - worker.onMessage(listener); - } + this.#manager.onMessage(listener); } offMessage(listener: RunnerMessageListener) { - this.#messageListeners.delete(listener); - for (const worker of this.#workers) { - worker.offMessage(listener); - } + this.#manager.offMessage(listener); } // #endregion // #region Private Methods - async #getWorker() { - let retry = 0; - const maxRetries = isTest || isCI ? 100 : 10; - while (this.#building || ++retry < maxRetries) { - if ((this.#workers.length === 0 || this.#buildError) && !this.#building) { - return; - } - const activeWorker = this.#workers.find((w) => w.ready); - if (activeWorker) { - return activeWorker; - } - await new Promise((resolve) => setTimeout(resolve, 600)); - } + async #waitForBuild() { + const timeout = isTest || isCI ? 60_000 : 6000; + await this.#manager.waitForReady(timeout); } #generateError() { diff --git a/src/presets/_nitro/runtime/nitro-dev.ts b/src/presets/_nitro/runtime/nitro-dev.ts index fcd222da3f..684b9a6470 100644 --- a/src/presets/_nitro/runtime/nitro-dev.ts +++ b/src/presets/_nitro/runtime/nitro-dev.ts @@ -1,85 +1,36 @@ import "#nitro/virtual/polyfills"; -import { Server } from "node:http"; -import { parentPort, threadId } from "node:worker_threads"; -import wsAdapter from "crossws/adapters/node"; -import { toNodeHandler } from "srvx/node"; -import { getSocketAddress, isSocketSupported } from "get-port-please"; import { useNitroApp, useNitroHooks } from "nitro/app"; import { startScheduleRunner } from "#nitro/runtime/task"; import { trapUnhandledErrors } from "#nitro/runtime/error/hooks"; import { resolveWebsocketHooks } from "#nitro/runtime/app"; -import type { NodeHttp1Handler } from "srvx"; - -// Listen for shutdown signal from runner -parentPort?.on("message", (msg) => { - if (msg && msg.event === "shutdown") { - shutdown(); - } -}); +import type { AppEntry } from "env-runner"; const nitroApp = useNitroApp(); const nitroHooks = useNitroHooks(); trapUnhandledErrors(); -const server = new Server(toNodeHandler(nitroApp.fetch) as NodeHttp1Handler); -let listener: Server | undefined; - -listen() - // eslint-disable-next-line unicorn/prefer-top-level-await - .catch((error) => { - console.error("Dev worker failed to listen:", error); - return shutdown(); - }); - -// https://crossws.unjs.io/adapters/node -if (import.meta._websocket) { - const { handleUpgrade } = wsAdapter({ resolve: resolveWebsocketHooks }); - server.on("upgrade", handleUpgrade); -} - // Scheduled tasks if (import.meta._tasks) { startScheduleRunner({}); } -// --- utils --- - -async function listen() { - const listenAddr = (await isSocketSupported()) - ? getSocketAddress({ - name: `nitro-dev-${threadId}`, - pid: true, - random: true, - }) - : { port: 0, host: "localhost" }; - - return new Promise((resolve, reject) => { - try { - listener = server.listen(listenAddr, () => { - const address = server.address(); - parentPort?.postMessage({ - event: "listen", - address: - typeof address === "string" - ? { socketPath: address } - : { host: "localhost", port: address?.port }, - }); - resolve(); - }); - } catch (error) { - reject(error); - } - }); -} - -async function shutdown() { - server.closeAllConnections?.(); - await Promise.all([ - new Promise((resolve) => listener?.close(resolve)), - nitroHooks.callHook("close"), - ]).catch(console.error); - parentPort?.postMessage({ event: "exit" }); -} +const ws = import.meta._websocket + ? await import("crossws/adapters/node").then((m) => + (m.default || m)({ resolve: resolveWebsocketHooks }) + ) + : undefined; + +export default { + fetch: nitroApp.fetch, + upgrade: ws + ? (context: { node: { req: any; socket: any; head: any } }) => { + ws.handleUpgrade(context.node.req, context.node.socket, context.node.head); + } + : undefined, + ipc: { + onClose: () => nitroHooks.callHook("close"), + }, +} satisfies AppEntry; diff --git a/src/runner/node.ts b/src/runner/node.ts deleted file mode 100644 index 80afd120c2..0000000000 --- a/src/runner/node.ts +++ /dev/null @@ -1,178 +0,0 @@ -import type { IncomingMessage } from "node:http"; -import type { Socket } from "node:net"; -import type { RunnerMessageListener, EnvRunner, WorkerAddress, WorkerHooks } from "nitro/types"; - -import { existsSync } from "node:fs"; -import { rm } from "node:fs/promises"; -import { Worker } from "node:worker_threads"; -import consola from "consola"; -import { proxyFetch, proxyUpgrade } from "httpxy"; -import { isCI, isTest } from "std-env"; - -export interface EnvRunnerData { - name?: string; - [key: string]: unknown; -} - -export class NodeEnvRunner implements EnvRunner { - closed: boolean = false; - - #name: string; - #entry: string; - #data?: EnvRunnerData; - #hooks: Partial; - #worker?: Worker & { _exitCode?: number }; - #address?: WorkerAddress; - #messageListeners: Set<(data: unknown) => void>; - - constructor(opts: { name: string; entry: string; hooks?: WorkerHooks; data?: EnvRunnerData }) { - this.#name = opts.name; - this.#entry = opts.entry; - this.#data = opts.data; - this.#hooks = opts.hooks || {}; - - this.#messageListeners = new Set(); - this.#initWorker(); - } - - get ready() { - return Boolean(!this.closed && this.#address && this.#worker); - } - - // #region Public methods - - async fetch(input: string | URL | Request, init?: RequestInit): Promise { - for (let i = 0; i < 5 && !this.#address; i++) { - await new Promise((r) => setTimeout(r, 100 * Math.pow(2, i))); - } - if (!this.#address) { - return new Response("Node env runner worker is unavailable", { - status: 503, - }); - } - return proxyFetch(this.#address, input, init); - } - - async upgrade(req: IncomingMessage, socket: Socket, head: any) { - if (!this.ready || !this.#address) { - return; - } - await proxyUpgrade(this.#address, req, socket, head); - } - - sendMessage(message: unknown) { - if (!this.#worker) { - throw new Error("Node env worker should be initialized before sending messages."); - } - this.#worker.postMessage(message); - } - - onMessage(listener: RunnerMessageListener) { - this.#messageListeners.add(listener); - } - - offMessage(listener: RunnerMessageListener) { - this.#messageListeners.delete(listener); - } - - async close(cause?: unknown) { - if (this.closed) { - return; - } - this.closed = true; - this.#hooks.onClose?.(this, cause); - this.#hooks = {}; - const onError = (error: unknown) => consola.error(error); - await this.#closeWorker().catch(onError); - await this.#closeSocket().catch(onError); - } - - [Symbol.for("nodejs.util.inspect.custom")]() { - // eslint-disable-next-line unicorn/no-nested-ternary - const status = this.closed ? "closed" : this.ready ? "ready" : "pending"; - return `NodeEnvRunner#${this.#name}(${status})`; - } - - // #endregion - - // #region Private methods - - #initWorker() { - if (!existsSync(this.#entry)) { - this.close(`worker entry not found in "${this.#entry}".`); - return; - } - - const worker = new Worker(this.#entry, { - env: { - ...process.env, - }, - workerData: { - name: this.#name, - ...this.#data, - }, - }) as Worker & { _exitCode?: number }; - - worker.once("exit", (code) => { - worker._exitCode = code; - this.close(`worker exited with code ${code}`); - }); - - worker.once("error", (error) => { - consola.error(`Worker error:`, error); - this.close(error); - }); - - worker.on("message", (message) => { - if (message?.address) { - this.#address = message.address; - this.#hooks.onReady?.(this, this.#address); - } - for (const listener of this.#messageListeners) { - listener(message); - } - }); - - this.#worker = worker; - } - - async #closeSocket() { - const socketPath = this.#address?.socketPath; - if (socketPath && socketPath[0] !== "\0" && !socketPath.startsWith(String.raw`\\.\pipe`)) { - await rm(socketPath).catch(() => {}); - } - this.#address = undefined; - } - - async #closeWorker() { - if (!this.#worker) { - return; - } - this.#worker.postMessage({ event: "shutdown" }); - - if (!this.#worker._exitCode && !isTest && !isCI) { - await new Promise((resolve) => { - const gracefulShutdownTimeoutMs = - Number.parseInt(process.env.NITRO_SHUTDOWN_TIMEOUT || "", 10) || 5000; - const timeout = setTimeout(() => { - consola.warn(`force closing node env runner worker...`); - resolve(); - }, gracefulShutdownTimeoutMs); - - this.#worker?.on("message", (message) => { - if (message.event === "exit") { - clearTimeout(timeout); - resolve(); - } - }); - }); - } - this.#worker.removeAllListeners(); - await this.#worker.terminate().catch((error) => { - consola.error(error); - }); - this.#worker = undefined; - } - - // #endregion -} diff --git a/src/runtime/internal/error/dev.ts b/src/runtime/internal/error/dev.ts index d9dc96ce25..b317f7c2ed 100644 --- a/src/runtime/internal/error/dev.ts +++ b/src/runtime/internal/error/dev.ts @@ -3,9 +3,7 @@ import { getRequestURL } from "h3"; import { readFile } from "node:fs/promises"; import { resolve, dirname } from "node:path"; import consola from "consola"; -import { ErrorParser } from "youch-core"; -import { Youch } from "youch"; -import { SourceMapConsumer } from "source-map"; +import type { ErrorParser } from "youch-core"; import { defineNitroErrorHandler } from "./utils.ts"; import type { InternalHandlerResponse } from "./utils.ts"; import { FastResponse } from "srvx"; @@ -48,6 +46,8 @@ export async function defaultHandler( // Load stack trace with source maps await loadStackTrace(error).catch(consola.error); + const { Youch } = await import("youch"); + // https://github.com/poppinss/youch const youch = new Youch(); @@ -102,6 +102,9 @@ export async function loadStackTrace(error: any): Promise { if (!(error instanceof Error)) { return; } + + const { ErrorParser } = await import("youch-core"); + const parsed = await new ErrorParser().defineSourceLoader(sourceLoader).parse(error); const stack = error.message + "\n" + parsed.frames.map((frame) => fmtFrame(frame)).join("\n"); @@ -124,6 +127,7 @@ async function sourceLoader(frame: StackFrame) { // prettier-ignore const rawSourceMap = await readFile(`${frame.fileName}.map`, "utf8").catch(() => {}); if (rawSourceMap) { + const { SourceMapConsumer } = await import("source-map"); const consumer = await new SourceMapConsumer(rawSourceMap); // prettier-ignore const originalPosition = consumer.originalPositionFor({ line: frame.lineNumber!, column: frame.columnNumber! }); diff --git a/src/runtime/internal/vite/dev-worker.mjs b/src/runtime/internal/vite/dev-worker.mjs new file mode 100644 index 0000000000..0cf82a1736 --- /dev/null +++ b/src/runtime/internal/vite/dev-worker.mjs @@ -0,0 +1,264 @@ +import { ModuleRunner, ESModulesEvaluator } from "vite/module-runner"; +import { createViteTransport } from "env-runner/vite"; + +// Custom evaluator for workerd where `new AsyncFunction()` is disallowed. +// Uses the unsafeEvalBinding exposed by the env-runner miniflare wrapper. +class WorkerdModuleEvaluator { + startOffset = 0; + + async runInlinedModule(context, code) { + const unsafeEval = globalThis.__ENV_RUNNER_UNSAFE_EVAL__; + const keys = Object.keys(context); + const fn = unsafeEval.newAsyncFunction('"use strict";' + code, "runInlinedModule", ...keys); + await fn(...keys.map((k) => context[k])); + Object.seal(context[Object.keys(context)[0]]); + } + + runExternalModule(filepath) { + return import(filepath); + } +} + +// ----- IPC ----- + +let sendMessage; +const messageListeners = new Set(); + +// ----- Environment runners ----- + +const envs = (globalThis.__nitro_vite_envs__ ??= { + nitro: undefined, + ssr: undefined, +}); + +class ViteEnvRunner { + constructor({ name, entry }) { + this.name = name; + this.entryPath = entry; + + this.entry = undefined; + this.entryError = undefined; + + // Create Vite Module Runner + // https://vite.dev/guide/api-environment-runtimes.html#modulerunner + const onMessage = (listener) => messageListeners.add(listener); + const transport = createViteTransport((data) => sendMessage?.(data), onMessage, name); + const evaluator = globalThis.__ENV_RUNNER_UNSAFE_EVAL__ + ? new WorkerdModuleEvaluator() + : new ESModulesEvaluator(); + const debug = + typeof process !== "undefined" && process.env?.NITRO_DEBUG ? console.debug : undefined; + this.runner = new ModuleRunner({ transport }, evaluator, debug); + + this.reload(); + } + + async reload() { + try { + this.entry = await this.runner.import(this.entryPath); + this.entryError = undefined; + } catch (error) { + console.error(error); + this.entryError = error; + } + } + + async fetch(req, init) { + if (this.entryError) { + return renderError(req, this.entryError); + } + for (let i = 0; i < 5 && !(this.entry || this.entryError); i++) { + await new Promise((r) => setTimeout(r, 100 * Math.pow(2, i))); + } + if (this.entryError) { + return renderError(req, this.entryError); + } + if (!this.entry) { + throw httpError(503, `Vite environment "${this.name}" is unavailable`); + } + try { + const entryFetch = this.entry.fetch || this.entry.default?.fetch; + if (!entryFetch) { + throw httpError(500, `No fetch handler exported from ${this.entryPath}`); + } + return await entryFetch(req, init); + } catch (error) { + return renderError(req, error); + } + } +} + +// ----- RPC ----- + +const rpcRequests = new Map(); + +function rpc(name, data, timeout = 3000) { + const id = Math.random().toString(36).slice(2); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + rpcRequests.delete(id); + reject(new Error(`RPC "${name}" timed out`)); + }, timeout); + rpcRequests.set(id, { resolve, reject, timer }); + sendMessage?.({ __rpc: name, __rpc_id: id, data }); + }); +} + +// Trap unhandled errors to avoid worker crash +if (typeof process !== "undefined" && typeof process.on === "function") { + process.on("unhandledRejection", (error) => console.error(error)); + process.on("uncaughtException", (error) => console.error(error)); +} + +// ----- RSC Support ----- + +// define __VITE_ENVIRONMENT_RUNNER_IMPORT__ for RSC support +// https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-rsc/README.md#__vite_environment_runner_import__ + +globalThis.__VITE_ENVIRONMENT_RUNNER_IMPORT__ = async function (environmentName, id) { + const env = envs[environmentName]; + if (!env) { + throw new Error(`Vite environment "${environmentName}" is not registered`); + } + return env.runner.import(id); +}; + +// ----- Reload ----- + +async function reload() { + try { + await Promise.all(Object.values(envs).map((env) => env?.reload())); + } catch (error) { + console.error(error); + } +} + +// eslint-disable-next-line unicorn/prefer-top-level-await +reload(); + +// ----- HTML Transform ----- + +globalThis.__transform_html__ = async function (html) { + html = await rpc("transformHTML", html).catch((error) => { + console.warn("Failed to transform HTML via Vite:", error); + return html; + }); + return html; +}; + +// ----- Exports (env-runner AppEntry) ----- + +export function fetch(req) { + const viteEnv = req?.headers.get("x-vite-env") || "nitro"; + const env = envs[viteEnv]; + if (!env) { + return renderError(req, httpError(500, `Unknown vite environment "${viteEnv}"`)); + } + return env.fetch(req); +} + +export function upgrade(context) { + const handleUpgrade = envs.nitro?.entry?.handleUpgrade; + if (handleUpgrade) { + handleUpgrade(context.node.req, context.node.socket, context.node.head); + } +} + +export const ipc = { + onOpen(ctx) { + sendMessage = ctx.sendMessage; + }, + onMessage(message) { + if (message?.__rpc_id) { + const req = rpcRequests.get(message.__rpc_id); + if (req) { + clearTimeout(req.timer); + rpcRequests.delete(message.__rpc_id); + if (message.error) { + req.reject(typeof message.error === "string" ? new Error(message.error) : message.error); + } else { + req.resolve(message.data); + } + } + return; + } + if (message?.type === "custom") { + if (message.event === "nitro:vite-env") { + const { name, entry } = message.data; + if (!envs[name]) { + envs[name] = new ViteEnvRunner({ name, entry }); + } + return; + } + } + if (message?.type === "full-reload") { + reload(); + return; + } + for (const listener of messageListeners) { + listener(message); + } + }, + onClose() {}, +}; + +// ----- Error handling ----- + +function httpError(status, message) { + const error = new Error(message || `HTTP Error ${status}`); + error.status = status; + error.name = "NitroViteError"; + return error; +} + +async function renderError(req, error) { + if (req.headers.get("accept")?.includes("application/json")) { + return new Response( + JSON.stringify( + { + status: error.status || 500, + name: error.name || "Error", + message: error.message, + stack: (error.stack || "") + .split("\n") + .splice(1) + .map((l) => l.trim()), + }, + null, + 2 + ), + { + status: error.status || 500, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store, max-age=0, must-revalidate", + Pragma: "no-cache", + Expires: "0", + }, + } + ); + } + try { + const { Youch } = await import("youch"); + const youch = new Youch(); + return new Response(await youch.toHTML(error), { + status: error.status || 500, + headers: { + "Content-Type": "text/html", + "Cache-Control": "no-store, max-age=0, must-revalidate", + Pragma: "no-cache", + Expires: "0", + }, + }); + } catch { + return new Response(`
${error.stack || error.message || error}
`, { + status: error.status || 500, + headers: { + "Content-Type": "text/html", + "Cache-Control": "no-store, max-age=0, must-revalidate", + Pragma: "no-cache", + Expires: "0", + }, + }); + } +} diff --git a/src/runtime/internal/vite/node-runner.mjs b/src/runtime/internal/vite/node-runner.mjs deleted file mode 100644 index 3e2e33eafd..0000000000 --- a/src/runtime/internal/vite/node-runner.mjs +++ /dev/null @@ -1,295 +0,0 @@ -import { parentPort, threadId, workerData } from "node:worker_threads"; -import { ModuleRunner, ESModulesEvaluator } from "vite/module-runner"; -import { getSocketAddress, isSocketSupported } from "get-port-please"; - -// ----- Environment runners ----- - -const envs = (globalThis.__nitro_vite_envs__ ??= { - nitro: undefined, - ssr: undefined, -}); - -class EnvRunner { - constructor({ name, entry }) { - this.name = name; - this.entryPath = entry; - - this.entry = undefined; - this.entryError = undefined; - - // Create Vite Module Runner - // https://vite.dev/guide/api-environment-runtimes.html#modulerunner - this.runnerHooks = {}; - this.runner = new ModuleRunner( - { - transport: { - connect({ onMessage, onDisconnection }) { - parentPort.on("message", (payload) => { - if (payload?.type === "custom" && payload.viteEnv === name) { - onMessage(payload); - } - }); - parentPort.on("close", onDisconnection); - }, - send(payload) { - parentPort.postMessage({ ...payload, viteEnv: name }); - }, - }, - }, - new ESModulesEvaluator(), - process.env.NITRO_DEBUG ? console.debug : undefined - ); - - this.reload(); - } - - async reload() { - try { - this.entry = await this.runner.import(this.entryPath); - this.entryError = undefined; - } catch (error) { - console.error(error); - this.entryError = error; - } - } - - async fetch(req, init) { - if (this.entryError) { - return renderError(req, this.entryError); - } - for (let i = 0; i < 5 && !(this.entry || this.entryError); i++) { - await new Promise((r) => setTimeout(r, 100 * Math.pow(2, i))); - } - if (this.entryError) { - return renderError(req, this.entryError); - } - if (!this.entry) { - throw httpError(503, `Vite environment "${this.name}" is unavailable`); - } - try { - const entryFetch = this.entry.fetch || this.entry.default?.fetch; - if (!entryFetch) { - throw httpError(500, `No fetch handler exported from ${this.entryPath}`); - } - return await entryFetch(req, init); - } catch (error) { - return renderError(req, error); - } - } -} - -// ----- RPC listeners ----- - -const viteHostRequests = new Map(); - -async function requestToViteHost( - name, - data, - id = Math.random().toString(16).slice(2), - timeout = 3000 -) { - setTimeout(() => { - if (viteHostRequests.has(id)) { - viteHostRequests.delete(id); - reject(new Error(`Request to vite host timed out (${name}:${id})`)); - } - }, timeout); - let resolve, reject; - const promise = new Promise((_resolve, _reject) => { - resolve = (value) => { - viteHostRequests.delete(id); - return _resolve(value); - }; - reject = (err) => { - viteHostRequests.delete(id); - return _reject(err); - }; - }); - viteHostRequests.set(id, { resolve, reject }); - parentPort.postMessage({ - type: "custom", - event: "nitro:vite-invoke", - data: { name, id, data }, - }); - return promise; -} - -parentPort.on("message", (payload) => { - if (payload?.type !== "custom") { - return; - } - switch (payload.event) { - case "nitro:vite-server-addr": { - viteServerAddr = payload.data; - break; - } - case "nitro:vite-env": { - const { name, entry } = payload.data; - if (envs[name]) { - console.error(`Vite environment "${name}" already registered!`); - } else { - envs[name] = new EnvRunner({ name, entry }); - } - break; - } - case "nitro:vite-invoke-response": { - const { id, data: response } = payload.data; - const req = viteHostRequests.get(id); - if (req) { - if (response.error) { - req.reject(response.error); - } else { - req.resolve(response.data); - } - } - break; - } - } -}); - -// Trap unhandled errors to avoid worker crash -process.on("unhandledRejection", (error) => console.error(error)); -process.on("uncaughtException", (error) => console.error(error)); - -// ----- RSC Support ----- - -// define __VITE_ENVIRONMENT_RUNNER_IMPORT__ for RSC support -// https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-rsc/README.md#__vite_environment_runner_import__ - -globalThis.__VITE_ENVIRONMENT_RUNNER_IMPORT__ = async function (environmentName, id) { - const env = envs[environmentName]; - if (!env) { - throw new Error(`Vite environment "${environmentName}" is not registered`); - } - return env.runner.import(id); -}; - -// ----- Server ----- - -async function reload() { - try { - await Promise.all(Object.values(envs).map((env) => env?.reload())); - } catch (error) { - console.error(error); - } -} - -// eslint-disable-next-line unicorn/prefer-top-level-await -reload(); - -if (workerData.server) { - const { createServer } = await import("node:http"); - const { toNodeHandler } = await import("srvx/node"); - const server = createServer( - toNodeHandler(async (req, init) => { - const viteEnv = init?.viteEnv || req?.headers.get("x-vite-env") || "nitro"; // TODO - const env = envs[viteEnv]; - if (!env) { - return renderError(req, httpError(500, `Unknown vite environment "${viteEnv}"`)); - } - return env.fetch(req, init); - }) - ); - - server.on("upgrade", (req, socket, head) => { - const handleUpgrade = envs["nitro"]?.entry?.handleUpgrade; - handleUpgrade?.(req, socket, head); - }); - - parentPort.on("message", async (message) => { - if (message?.type === "full-reload") { - await reload(); - } else if (message?.event === "shutdown") { - server.close(() => { - parentPort.postMessage({ event: "exit" }); - }); - } - }); - await listen(server); - const address = server.address(); - parentPort?.postMessage({ - event: "listen", - address: - typeof address === "string" - ? { socketPath: address } - : { host: "localhost", port: address?.port }, - }); -} - -// ----- HTML Transform ----- - -globalThis.__transform_html__ = async function (html) { - html = await requestToViteHost("transformHTML", html).catch((error) => { - console.warn("Failed to transform HTML via Vite:", error); - return html; - }); - return html; -}; - -// ----- Error handling ----- - -function httpError(status, message) { - const error = new Error(message || `HTTP Error ${status}`); - error.status = status; - error.name = "NitroViteError"; - return error; -} - -async function renderError(req, error) { - if (req.headers.get("accept")?.includes("application/json")) { - return new Response( - JSON.stringify( - { - status: error.status || 500, - name: error.name || "Error", - message: error.message, - stack: (error.stack || "") - .split("\n") - .splice(1) - .map((l) => l.trim()), - }, - null, - 2 - ), - { - status: error.status || 500, - headers: { - "Content-Type": "application/json", - "Cache-Control": "no-store, max-age=0, must-revalidate", - Pragma: "no-cache", - Expires: "0", - }, - } - ); - } - const { Youch } = await import("youch"); - const youch = new Youch(); - return new Response(await youch.toHTML(error), { - status: error.status || 500, - headers: { - "Content-Type": "text/html", - "Cache-Control": "no-store, max-age=0, must-revalidate", - Pragma: "no-cache", - Expires: "0", - }, - }); -} - -// ----- Server ----- - -async function listen(server) { - const listenAddr = (await isSocketSupported()) - ? getSocketAddress({ - name: `nitro-vite-${threadId}`, - pid: true, - random: true, - }) - : { port: 0, host: "localhost" }; - return new Promise((resolve, reject) => { - try { - server.listen(listenAddr, () => resolve()); - } catch (error) { - reject(error); - } - }); -} diff --git a/src/types/config.ts b/src/types/config.ts index 8e733da763..a6347de124 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -13,6 +13,7 @@ import type { Preset as UnenvPreset } from "unenv"; import type { UnimportPluginOptions } from "unimport/unplugin"; import type { BuiltinDriverName } from "unstorage"; import type { UnwasmPluginOptions } from "unwasm/plugin"; +import type { RunnerName } from "env-runner"; import type { EventHandlerFormat, NitroDevEventHandler, @@ -169,6 +170,7 @@ export interface NitroOptions extends PresetOptions { port?: number; hostname?: string; watch?: string[]; + runner?: RunnerName; }; watchOptions: ChokidarOptions; devProxy: Record; diff --git a/src/types/hooks.ts b/src/types/hooks.ts index 53b5bcd256..8368afcc2e 100644 --- a/src/types/hooks.ts +++ b/src/types/hooks.ts @@ -1,4 +1,4 @@ -import type { EnvRunnerData } from "../runner/node.ts"; +import type { EnvRunnerData } from "env-runner"; import type { NitroConfig } from "./config.ts"; import type { Nitro, NitroTypes } from "./nitro.ts"; import type { PrerenderRoute } from "./prerender.ts"; diff --git a/src/types/runner.ts b/src/types/runner.ts index 6c76c62e64..21adf1b309 100644 --- a/src/types/runner.ts +++ b/src/types/runner.ts @@ -1,32 +1,9 @@ -import type { IncomingMessage } from "node:http"; -import type { Socket } from "node:net"; - -export type FetchHandler = (req: Request) => Promise; - -export type RunnerMessageListener = (data: unknown) => void; - -export type UpgradeHandler = (req: IncomingMessage, socket: Socket, head: any) => void; - -export interface RunnerRPCHooks { - sendMessage: (message: unknown) => void; - onMessage: (listener: RunnerMessageListener) => void; - offMessage: (listener: RunnerMessageListener) => void; -} - -export type WorkerAddress = - | { host?: string; port: number; socketPath?: undefined } - | { host?: undefined; port?: undefined; socketPath: string }; - -export interface WorkerHooks { - onClose?: (worker: EnvRunner, cause?: unknown) => void; - onReady?: (worker: EnvRunner, address?: WorkerAddress) => void; -} - -export interface EnvRunner extends WorkerHooks, RunnerRPCHooks { - readonly ready: boolean; - readonly closed: boolean; - - fetch: FetchHandler; - upgrade?: UpgradeHandler; - close(): Promise; -} +export type { + FetchHandler, + RunnerMessageListener, + UpgradeHandler, + RunnerRPCHooks, + WorkerAddress, + WorkerHooks, + EnvRunner, +} from "env-runner";