diff --git a/package.json b/package.json index 4c3cbc649e..d77b3c75e1 100644 --- a/package.json +++ b/package.json @@ -178,6 +178,7 @@ "@scalar/api-reference": "^1.25.109", "@types/archiver": "^6.0.3", "@types/aws-lambda": "^8.10.147", + "@types/bun": "^1.2.2", "@types/estree": "^1.0.6", "@types/etag": "^1.8.3", "@types/fs-extra": "^11.0.4", diff --git a/src/presets/_types.gen.ts b/src/presets/_types.gen.ts index d0909dcd8e..43401a2bf5 100644 --- a/src/presets/_types.gen.ts +++ b/src/presets/_types.gen.ts @@ -20,6 +20,6 @@ export interface PresetOptions { export const presetsWithConfig = ["awsAmplify","awsLambda","azure","cloudflare","firebase","netlify","vercel"] as const; -export type PresetName = "alwaysdata" | "aws-amplify" | "aws-lambda" | "azure" | "azure-functions" | "azure-swa" | "base-worker" | "bun" | "cleavr" | "cli" | "cloudflare" | "cloudflare-durable" | "cloudflare-module" | "cloudflare-module-legacy" | "cloudflare-pages" | "cloudflare-pages-static" | "cloudflare-worker" | "deno" | "deno-deploy" | "deno-server" | "deno-server-legacy" | "digital-ocean" | "edgio" | "firebase" | "firebase-app-hosting" | "flight-control" | "genezio" | "github-pages" | "gitlab-pages" | "heroku" | "iis" | "iis-handler" | "iis-node" | "koyeb" | "layer0" | "netlify" | "netlify-builder" | "netlify-edge" | "netlify-legacy" | "netlify-static" | "nitro-dev" | "nitro-prerender" | "node" | "node-cluster" | "node-listener" | "node-server" | "platform-sh" | "render-com" | "service-worker" | "static" | "stormkit" | "vercel" | "vercel-edge" | "vercel-static" | "winterjs" | "zeabur" | "zeabur-static" | "zerops" | "zerops-static"; +export type PresetName = "alwaysdata" | "aws-amplify" | "aws-lambda" | "azure" | "azure-functions" | "azure-swa" | "base-worker" | "bun" | "bun-cluster" | "cleavr" | "cli" | "cloudflare" | "cloudflare-durable" | "cloudflare-module" | "cloudflare-module-legacy" | "cloudflare-pages" | "cloudflare-pages-static" | "cloudflare-worker" | "deno" | "deno-deploy" | "deno-server" | "deno-server-legacy" | "digital-ocean" | "edgio" | "firebase" | "firebase-app-hosting" | "flight-control" | "genezio" | "github-pages" | "gitlab-pages" | "heroku" | "iis" | "iis-handler" | "iis-node" | "koyeb" | "layer0" | "netlify" | "netlify-builder" | "netlify-edge" | "netlify-legacy" | "netlify-static" | "nitro-dev" | "nitro-prerender" | "node" | "node-cluster" | "node-listener" | "node-server" | "platform-sh" | "render-com" | "service-worker" | "static" | "stormkit" | "vercel" | "vercel-edge" | "vercel-static" | "winterjs" | "zeabur" | "zeabur-static" | "zerops" | "zerops-static"; -export type PresetNameInput = "alwaysdata" | "aws-amplify" | "awsAmplify" | "aws_amplify" | "aws-lambda" | "awsLambda" | "aws_lambda" | "azure" | "azure-functions" | "azureFunctions" | "azure_functions" | "azure-swa" | "azureSwa" | "azure_swa" | "base-worker" | "baseWorker" | "base_worker" | "bun" | "cleavr" | "cli" | "cloudflare" | "cloudflare-durable" | "cloudflareDurable" | "cloudflare_durable" | "cloudflare-module" | "cloudflareModule" | "cloudflare_module" | "cloudflare-module-legacy" | "cloudflareModuleLegacy" | "cloudflare_module_legacy" | "cloudflare-pages" | "cloudflarePages" | "cloudflare_pages" | "cloudflare-pages-static" | "cloudflarePagesStatic" | "cloudflare_pages_static" | "cloudflare-worker" | "cloudflareWorker" | "cloudflare_worker" | "deno" | "deno-deploy" | "denoDeploy" | "deno_deploy" | "deno-server" | "denoServer" | "deno_server" | "deno-server-legacy" | "denoServerLegacy" | "deno_server_legacy" | "digital-ocean" | "digitalOcean" | "digital_ocean" | "edgio" | "firebase" | "firebase-app-hosting" | "firebaseAppHosting" | "firebase_app_hosting" | "flight-control" | "flightControl" | "flight_control" | "genezio" | "github-pages" | "githubPages" | "github_pages" | "gitlab-pages" | "gitlabPages" | "gitlab_pages" | "heroku" | "iis" | "iis-handler" | "iisHandler" | "iis_handler" | "iis-node" | "iisNode" | "iis_node" | "koyeb" | "layer0" | "netlify" | "netlify-builder" | "netlifyBuilder" | "netlify_builder" | "netlify-edge" | "netlifyEdge" | "netlify_edge" | "netlify-legacy" | "netlifyLegacy" | "netlify_legacy" | "netlify-static" | "netlifyStatic" | "netlify_static" | "nitro-dev" | "nitroDev" | "nitro_dev" | "nitro-prerender" | "nitroPrerender" | "nitro_prerender" | "node" | "node-cluster" | "nodeCluster" | "node_cluster" | "node-listener" | "nodeListener" | "node_listener" | "node-server" | "nodeServer" | "node_server" | "platform-sh" | "platformSh" | "platform_sh" | "render-com" | "renderCom" | "render_com" | "service-worker" | "serviceWorker" | "service_worker" | "static" | "stormkit" | "vercel" | "vercel-edge" | "vercelEdge" | "vercel_edge" | "vercel-static" | "vercelStatic" | "vercel_static" | "winterjs" | "zeabur" | "zeabur-static" | "zeaburStatic" | "zeabur_static" | "zerops" | "zerops-static" | "zeropsStatic" | "zerops_static" | (string & {}); +export type PresetNameInput = "alwaysdata" | "aws-amplify" | "awsAmplify" | "aws_amplify" | "aws-lambda" | "awsLambda" | "aws_lambda" | "azure" | "azure-functions" | "azureFunctions" | "azure_functions" | "azure-swa" | "azureSwa" | "azure_swa" | "base-worker" | "baseWorker" | "base_worker" | "bun" | "bun-cluster" | "bunCluster" | "bun_cluster" | "cleavr" | "cli" | "cloudflare" | "cloudflare-durable" | "cloudflareDurable" | "cloudflare_durable" | "cloudflare-module" | "cloudflareModule" | "cloudflare_module" | "cloudflare-module-legacy" | "cloudflareModuleLegacy" | "cloudflare_module_legacy" | "cloudflare-pages" | "cloudflarePages" | "cloudflare_pages" | "cloudflare-pages-static" | "cloudflarePagesStatic" | "cloudflare_pages_static" | "cloudflare-worker" | "cloudflareWorker" | "cloudflare_worker" | "deno" | "deno-deploy" | "denoDeploy" | "deno_deploy" | "deno-server" | "denoServer" | "deno_server" | "deno-server-legacy" | "denoServerLegacy" | "deno_server_legacy" | "digital-ocean" | "digitalOcean" | "digital_ocean" | "edgio" | "firebase" | "firebase-app-hosting" | "firebaseAppHosting" | "firebase_app_hosting" | "flight-control" | "flightControl" | "flight_control" | "genezio" | "github-pages" | "githubPages" | "github_pages" | "gitlab-pages" | "gitlabPages" | "gitlab_pages" | "heroku" | "iis" | "iis-handler" | "iisHandler" | "iis_handler" | "iis-node" | "iisNode" | "iis_node" | "koyeb" | "layer0" | "netlify" | "netlify-builder" | "netlifyBuilder" | "netlify_builder" | "netlify-edge" | "netlifyEdge" | "netlify_edge" | "netlify-legacy" | "netlifyLegacy" | "netlify_legacy" | "netlify-static" | "netlifyStatic" | "netlify_static" | "nitro-dev" | "nitroDev" | "nitro_dev" | "nitro-prerender" | "nitroPrerender" | "nitro_prerender" | "node" | "node-cluster" | "nodeCluster" | "node_cluster" | "node-listener" | "nodeListener" | "node_listener" | "node-server" | "nodeServer" | "node_server" | "platform-sh" | "platformSh" | "platform_sh" | "render-com" | "renderCom" | "render_com" | "service-worker" | "serviceWorker" | "service_worker" | "static" | "stormkit" | "vercel" | "vercel-edge" | "vercelEdge" | "vercel_edge" | "vercel-static" | "vercelStatic" | "vercel_static" | "winterjs" | "zeabur" | "zeabur-static" | "zeaburStatic" | "zeabur_static" | "zerops" | "zerops-static" | "zeropsStatic" | "zerops_static" | (string & {}); diff --git a/src/presets/bun/preset.ts b/src/presets/bun/preset.ts index ed6250b3cd..61a720dd33 100644 --- a/src/presets/bun/preset.ts +++ b/src/presets/bun/preset.ts @@ -1,4 +1,8 @@ +import { fileURLToPath, resolvePathSync } from "mlly"; import { defineNitroPreset } from "nitropack/kit"; +import { dirname, join, normalize, parse } from "pathe"; + +const dirName = dirname(fileURLToPath(import.meta.url)) const bun = defineNitroPreset( { @@ -7,7 +11,7 @@ const bun = defineNitroPreset( // https://bun.sh/docs/runtime/modules#resolution exportConditions: ["bun", "worker", "node", "import", "default"], commands: { - preview: "bun run ./server/index.mjs", + preview: "bun --bun ./server/index.mjs", }, }, { @@ -16,4 +20,59 @@ const bun = defineNitroPreset( } ); -export default [bun] as const; +const bunCluster = defineNitroPreset( + { + extends: "node-server", + entry: "./runtime/bun-cluster", + commands: { + preview: "bun --bun ./server/index.mjs", + }, + rollupConfig: { + external: ["bun"], + input: [join(dirName + "/runtime/bun-cluster"), join(dirName + "/runtime/bun-worker")], + }, + hooks: { + "rollup:before"(_nitro, rollupConfig) { + // ensure worker and master code chunk is seperated and isolated into it's own entry-file + // this prevents worker file importing from master file + const manualChunks = rollupConfig.output?.manualChunks; + if (manualChunks && typeof manualChunks === "function") { + const workerFile = resolvePathSync("./runtime/bun-worker", { + url: import.meta.url, + }); + + const masterFile = resolvePathSync("./runtime/bun-cluster", { + url: import.meta.url, + }); + + rollupConfig.output.manualChunks = (id, meta) => { + if (id.includes("bun-worker") && normalize(id) === workerFile) { + return "nitro/bun-worker"; + } + + if (id.includes("bun-cluster") && normalize(id) === masterFile) { + return "nitro/bun-cluster"; + } + + return manualChunks(id, meta) + }; + } + + // unique name for master to find worker file. + rollupConfig.output.entryFileNames = (chunkInfo) => { + if (chunkInfo.name === 'bun-worker') { + return 'bun-worker.mjs'; + } + return "index.mjs" + } + }, + }, + exportConditions: ["bun", "worker", "node", "import", "default"], + }, + { + name: "bun-cluster" as const, + url: import.meta.url, + } +); + +export default [bun, bunCluster] as const; diff --git a/src/presets/bun/runtime/bun-cluster.ts b/src/presets/bun/runtime/bun-cluster.ts new file mode 100644 index 0000000000..a4219c45d6 --- /dev/null +++ b/src/presets/bun/runtime/bun-cluster.ts @@ -0,0 +1,81 @@ +import { spawn, type Subprocess } from 'bun'; +import { fileURLToPath } from 'mlly'; +import { + getGracefulShutdownConfig, + trapUnhandledNodeErrors, +} from 'nitropack/runtime/internal'; +import { join, dirname } from 'pathe'; + +const numberOfWorkers = + Number.parseInt(process.env.NITRO_CLUSTER_WORKERS || "") || + navigator.hardwareConcurrency; + +const workers: Subprocess[] = Array.from({ length: numberOfWorkers }) +const dirName = dirname(fileURLToPath(import.meta.url)) +const workerFile = join(dirName, "./bun-worker") + +for (let i = 0; i < numberOfWorkers; i++) { + workers[i] = createWorker(i) +} + +let isShuttingDown = false; +let isTimedOut = false + +const shutdownConfig = getGracefulShutdownConfig(); + +if (!shutdownConfig.disabled) { + for (const signal of shutdownConfig.signals) { + process.once(signal, onShutdown); + } +} + +trapUnhandledNodeErrors(); + +function createWorker(workerIndex: number): Subprocess { + return spawn({ + cmd: ["bun", "--bun", workerFile], + stdout: "inherit", + stderr: "inherit", + stdin: "inherit", + onExit: () => { + if (!isShuttingDown) + workers[workerIndex] = createWorker(workerIndex) + } + }); +} + +async function onShutdown() { + if (isShuttingDown) { + return; + } + isShuttingDown = true; + + killWorkers() + + const workersKilled = Promise.all(workers.map(i => i.exited)) + + await Promise.any([setTimeout(), workersKilled]) + + if (isTimedOut) { + console.warn( + "[nitro] [cluster] Timeout reached for graceful shutdown. Forcing exit." + ); + } + + if (shutdownConfig.forceExit) { + process.exit(0); + } +} + +async function setTimeout() { + isTimedOut = false + await Bun.sleep(shutdownConfig.timeout) + isTimedOut = true +} + +function killWorkers() { + for (const bun of workers) { + bun.kill(); + } +} + diff --git a/src/presets/bun/runtime/bun-worker.ts b/src/presets/bun/runtime/bun-worker.ts new file mode 100644 index 0000000000..a062eaacbb --- /dev/null +++ b/src/presets/bun/runtime/bun-worker.ts @@ -0,0 +1,46 @@ +import "#nitro-internal-pollyfills"; +import { useNitroApp } from "nitropack/runtime"; +import { startScheduleRunner } from "nitropack/runtime/internal"; + +import wsAdapter from "crossws/adapters/bun"; + +const nitroApp = useNitroApp(); + +const ws = import.meta._websocket + ? wsAdapter(nitroApp.h3App.websocket) + : undefined; + +const server = Bun.serve({ + reusePort: true, + port: process.env.NITRO_PORT || process.env.PORT || 3000, + websocket: import.meta._websocket ? ws!.websocket : (undefined as any), + async fetch(req: Request, server: any) { + // https://crossws.unjs.io/adapters/bun + if (import.meta._websocket && req.headers.get("upgrade") === "websocket") { + return ws!.handleUpgrade(req, server); + } + + const url = new URL(req.url); + + let body; + if (req.body) { + body = await req.arrayBuffer(); + } + + return nitroApp.localFetch(url.pathname + url.search, { + host: url.hostname, + protocol: url.protocol, + headers: req.headers, + method: req.method, + redirect: req.redirect, + body, + }); + }, +}); + +console.log(`Listening on http://localhost:${server.port}...`); + +// Scheduled tasks +if (import.meta._tasks) { + startScheduleRunner(); +} diff --git a/src/presets/bun/runtime/bun.ts b/src/presets/bun/runtime/bun.ts index d3f0eb05a9..e419be556f 100644 --- a/src/presets/bun/runtime/bun.ts +++ b/src/presets/bun/runtime/bun.ts @@ -10,7 +10,6 @@ const ws = import.meta._websocket ? wsAdapter(nitroApp.h3App.websocket) : undefined; -// @ts-expect-error const server = Bun.serve({ port: process.env.NITRO_PORT || process.env.PORT || 3000, websocket: import.meta._websocket ? ws!.websocket : (undefined as any),