From 5477a81185ccc3e4286d48dea0d6af70027888dc Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 30 Nov 2023 17:45:04 +0100 Subject: [PATCH] feat: experimental nitro tasks (#1929) --- src/cli/commands/tasks/index.ts | 12 ++++ src/cli/commands/tasks/list.ts | 26 +++++++++ src/cli/commands/tasks/run.ts | 46 +++++++++++++++ src/cli/index.ts | 1 + src/dev/server.ts | 6 +- src/imports.ts | 2 + src/nitro.ts | 54 +++++++++++++++++- src/options.ts | 1 + src/runtime/entries/nitro-dev.ts | 36 +++++++++++- src/runtime/index.ts | 1 + src/runtime/task.ts | 62 ++++++++++++++++++++ src/runtime/virtual/tasks.ts | 6 ++ src/scan.ts | 11 ++++ src/task.ts | 78 ++++++++++++++++++++++++++ src/types/nitro.ts | 3 +- test/fixture/nitro.config.ts | 4 ++ test/fixture/routes/tasks/[...name].ts | 10 ++++ test/fixture/tasks/db/migrate.ts | 7 +++ 18 files changed, 359 insertions(+), 7 deletions(-) create mode 100644 src/cli/commands/tasks/index.ts create mode 100644 src/cli/commands/tasks/list.ts create mode 100644 src/cli/commands/tasks/run.ts create mode 100644 src/runtime/task.ts create mode 100644 src/runtime/virtual/tasks.ts create mode 100644 src/task.ts create mode 100644 test/fixture/routes/tasks/[...name].ts create mode 100644 test/fixture/tasks/db/migrate.ts diff --git a/src/cli/commands/tasks/index.ts b/src/cli/commands/tasks/index.ts new file mode 100644 index 0000000000..1eded9c6b6 --- /dev/null +++ b/src/cli/commands/tasks/index.ts @@ -0,0 +1,12 @@ +import { defineCommand } from "citty"; + +export default defineCommand({ + meta: { + name: "tasks", + description: "Operate in nitro tasks (experimental)", + }, + subCommands: { + list: () => import("./list").then((r) => r.default), + run: () => import("./run").then((r) => r.default), + }, +}); diff --git a/src/cli/commands/tasks/list.ts b/src/cli/commands/tasks/list.ts new file mode 100644 index 0000000000..aea14134b6 --- /dev/null +++ b/src/cli/commands/tasks/list.ts @@ -0,0 +1,26 @@ +import { defineCommand } from "citty"; +import { resolve } from "pathe"; +import { consola } from "consola"; +import { listNitroTasks } from "../../../task"; + +export default defineCommand({ + meta: { + name: "run", + description: "List available tasks (experimental)", + }, + args: { + dir: { + type: "string", + description: "project root directory", + }, + }, + async run({ args }) { + const cwd = resolve((args.dir || args.cwd || ".") as string); + const tasks = await listNitroTasks({ cwd, buildDir: ".nitro" }); + for (const [name, task] of Object.entries(tasks)) { + consola.log( + ` - \`${name}\`${task.description ? ` - ${task.description}` : ""}` + ); + } + }, +}); diff --git a/src/cli/commands/tasks/run.ts b/src/cli/commands/tasks/run.ts new file mode 100644 index 0000000000..d5e0e49e3b --- /dev/null +++ b/src/cli/commands/tasks/run.ts @@ -0,0 +1,46 @@ +import { defineCommand } from "citty"; +import { resolve } from "pathe"; +import destr from "destr"; +import { consola } from "consola"; +import { runNitroTask } from "../../../task"; + +export default defineCommand({ + meta: { + name: "run", + description: + "Run a runtime task in the currently running dev server (experimental)", + }, + args: { + name: { + type: "positional", + description: "task name", + required: true, + }, + dir: { + type: "string", + description: "project root directory", + }, + payload: { + type: "string", + description: "payload json to pass to the task", + }, + }, + async run({ args }) { + const cwd = resolve((args.dir || args.cwd || ".") as string); + consola.info(`Running task \`${args.name}\`...`); + try { + const { result } = await runNitroTask( + args.name, + destr(args.payload || "{}"), + { + cwd, + buildDir: ".nitro", + } + ); + consola.success("Result:", result); + } catch (err) { + consola.error(`Failed to run task \`${args.name}\`: ${err.message}`); + process.exit(1); // eslint-disable-line unicorn/no-process-exit + } + }, +}); diff --git a/src/cli/index.ts b/src/cli/index.ts index bc54fdf59d..308be13c10 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -12,6 +12,7 @@ const main = defineCommand({ dev: () => import("./commands/dev").then((r) => r.default), build: () => import("./commands/build").then((r) => r.default), prepare: () => import("./commands/prepare").then((r) => r.default), + tasks: () => import("./commands/tasks").then((r) => r.default), }, }); diff --git a/src/dev/server.ts b/src/dev/server.ts index 24846efeca..0c1323789d 100644 --- a/src/dev/server.ts +++ b/src/dev/server.ts @@ -25,7 +25,7 @@ import defaultErrorHandler from "./error"; export interface NitroWorker { worker: Worker; - address: { host: string; port: number } | { socketPath: string }; + address: { host: string; port: number; socketPath?: string }; } export interface NitroDevServer { @@ -97,7 +97,7 @@ async function killWorker(worker: NitroWorker, nitro: Nitro) { await worker.worker.terminate(); worker.worker = null; } - if ("socketPath" in worker.address && existsSync(worker.address.socketPath)) { + if (worker.address.socketPath && existsSync(worker.address.socketPath)) { await fsp.rm(worker.address.socketPath).catch(() => {}); } } @@ -210,7 +210,7 @@ export function createDevServer(nitro: Nitro): NitroDevServer { if (!address) { return; } - if ("socketPath" in address) { + if (address.socketPath) { try { accessSync(address.socketPath); } catch (err) { diff --git a/src/imports.ts b/src/imports.ts index 02c9f11298..dd6e3248cd 100644 --- a/src/imports.ts +++ b/src/imports.ts @@ -17,6 +17,8 @@ export const nitroImports: Preset[] = [ "getRouteRules", "useAppConfig", "useEvent", + "defineNitroTask", + "runNitroTask", "defineNitroErrorHandler", ], }, diff --git a/src/nitro.ts b/src/nitro.ts index 60638d6445..df3d852c9a 100644 --- a/src/nitro.ts +++ b/src/nitro.ts @@ -1,5 +1,5 @@ import { existsSync } from "node:fs"; -import { resolve } from "pathe"; +import { resolve, normalize } from "pathe"; import { createHooks, createDebugger } from "hookable"; import { createUnimport } from "unimport"; import { defu } from "defu"; @@ -11,7 +11,7 @@ import { normalizeRouteRules, normalizeRuntimeConfig, } from "./options"; -import { scanModules, scanPlugins } from "./scan"; +import { scanModules, scanPlugins, scanTasks } from "./scan"; import { createStorage } from "./storage"; import { resolveNitroModule } from "./module"; @@ -107,6 +107,56 @@ export async function createNitro( } } + // Tasks + const scannedTasks = await scanTasks(nitro); + for (const scannedTask of scannedTasks) { + if (scannedTask.name in nitro.options.tasks) { + if (!nitro.options.tasks[scannedTask.name].handler) { + nitro.options.tasks[scannedTask.name].handler = scannedTask.handler; + } + } else { + nitro.options.tasks[scannedTask.name] = { + handler: scannedTask.handler, + description: "", + }; + } + } + const taskNames = Object.keys(nitro.options.tasks).sort(); + if (taskNames.length > 0) { + consola.warn( + `Nitro tasks are experimental and API may change in the future releases!` + ); + consola.log( + `Available Tasks:\n\n${taskNames + .map( + (t) => + ` - \`${t}\`${ + nitro.options.tasks[t].description + ? ` - ${nitro.options.tasks[t].description}` + : "" + }` + ) + .join("\n")}` + ); + } + nitro.options.virtual["#internal/nitro/virtual/tasks"] = () => ` +export const tasks = { + ${Object.entries(nitro.options.tasks) + .map( + ([name, task]) => + `"${name}": { + description: ${JSON.stringify(task.description)}, + get: ${ + task.handler + ? `() => import("${normalize(task.handler)}")` + : "undefined" + }, + }` + ) + .join(",\n")} +}; + `; + // Auto imports if (nitro.options.imports) { nitro.unimport = createUnimport(nitro.options.imports); diff --git a/src/options.ts b/src/options.ts index 0c63f93b25..8791e86fe0 100644 --- a/src/options.ts +++ b/src/options.ts @@ -54,6 +54,7 @@ const NitroDefaults: NitroConfig = { publicAssets: [], serverAssets: [], plugins: [], + tasks: {}, imports: { exclude: [], dirs: [], diff --git a/src/runtime/entries/nitro-dev.ts b/src/runtime/entries/nitro-dev.ts index fc79b54fd7..6aee45ac5f 100644 --- a/src/runtime/entries/nitro-dev.ts +++ b/src/runtime/entries/nitro-dev.ts @@ -5,9 +5,17 @@ import { join } from "node:path"; import { mkdirSync } from "node:fs"; import { threadId, parentPort } from "node:worker_threads"; import { isWindows, provider } from "std-env"; -import { toNodeListener } from "h3"; +import { + defineEventHandler, + getQuery, + getRouterParam, + toNodeListener, + readBody, +} from "h3"; import { nitroApp } from "../app"; import { trapUnhandledNodeErrors } from "../utils"; +import { runNitroTask } from "../task"; +import { tasks } from "#internal/nitro/virtual/tasks"; const server = new Server(toNodeListener(nitroApp.h3App)); @@ -41,6 +49,32 @@ const listener = server.listen(listenAddress, () => { }); }); +// Register tasks handlers +nitroApp.router.get( + "/_nitro/tasks", + defineEventHandler((event) => { + return { + tasks: Object.fromEntries( + Object.entries(tasks).map(([name, task]) => [ + name, + { description: task.description }, + ]) + ), + }; + }) +); +nitroApp.router.use( + "/_nitro/tasks/:name", + defineEventHandler(async (event) => { + const name = getRouterParam(event, "name"); + const payload = { + ...getQuery(event), + ...(await readBody(event).catch(() => ({}))), + }; + return await runNitroTask(name, payload); + }) +); + // Trap unhandled errors trapUnhandledNodeErrors(); diff --git a/src/runtime/index.ts b/src/runtime/index.ts index 490d4aa519..062edd9744 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -2,6 +2,7 @@ export { useRuntimeConfig, useAppConfig } from "./config"; export * from "./cache"; export { useNitroApp } from "./app"; export * from "./plugin"; +export * from "./task"; export * from "./renderer"; export { getRouteRules } from "./route-rules"; export { useStorage } from "./storage"; diff --git a/src/runtime/task.ts b/src/runtime/task.ts new file mode 100644 index 0000000000..642b6a5c4a --- /dev/null +++ b/src/runtime/task.ts @@ -0,0 +1,62 @@ +import { createError } from "h3"; +import { useNitroApp, type NitroApp } from "./app"; +import { tasks } from "#internal/nitro/virtual/tasks"; + +/** @experimental */ +export interface NitroTaskContext {} + +/** @experimental */ +export interface NitroTaskPayload { + [key: string]: unknown; +} + +/** @experimental */ +export interface NitroTaskMeta { + name?: string; + description?: string; +} + +/** @experimental */ +export interface NitroTask extends NitroTaskMeta { + run( + payload: NitroTaskPayload, + context: NitroTaskContext + ): { result: RT | Promise }; +} + +/** @experimental */ +export function defineNitroTask( + def: NitroTask +): NitroTask { + if (typeof def.run !== "function") { + def.run = () => { + throw new TypeError("Nitro task must implement a `run` method!"); + }; + } + return def; +} + +/** @experimental */ +export async function runNitroTask( + name: string, + payload: NitroTaskPayload = {} +): Promise<{ result: RT }> { + if (!(name in tasks)) { + throw createError({ + message: `Nitro task \`${name}\` is not available!`, + statusCode: 404, + }); + } + if (!tasks[name].get) { + throw createError({ + message: `Nitro task \`${name}\` is not implemented!`, + statusCode: 501, + }); + } + const context: NitroTaskContext = {}; + const handler = await tasks[name].get().then((mod) => mod.default); + const { result } = handler.run(payload, context); + return { + result: result as RT, + }; +} diff --git a/src/runtime/virtual/tasks.ts b/src/runtime/virtual/tasks.ts new file mode 100644 index 0000000000..884d590590 --- /dev/null +++ b/src/runtime/virtual/tasks.ts @@ -0,0 +1,6 @@ +import type { NitroTask } from "../task"; + +export const tasks: Record< + string, + { get: () => Promise<{ default: NitroTask }>; description?: string } +> = {}; diff --git a/src/scan.ts b/src/scan.ts index b570efefae..eadde33105 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -79,6 +79,17 @@ export async function scanPlugins(nitro: Nitro) { return files.map((f) => f.fullPath); } +export async function scanTasks(nitro: Nitro) { + const files = await scanFiles(nitro, "tasks"); + return files.map((f) => { + const name = f.path + .replace(/\/index$/, "") + .replace(/\.[A-Za-z]+$/, "") + .replace(/\//g, ":"); + return { name, handler: f.fullPath }; + }); +} + export async function scanModules(nitro: Nitro) { const files = await scanFiles(nitro, "modules"); return files.map((f) => f.fullPath); diff --git a/src/task.ts b/src/task.ts new file mode 100644 index 0000000000..40d2762e59 --- /dev/null +++ b/src/task.ts @@ -0,0 +1,78 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { resolve } from "pathe"; +import { ofetch } from "ofetch"; +import type { NitroTaskPayload } from "./runtime"; +import { NitroBuildInfo } from "nitropack"; + +interface TaskRunnerOptions { + cwd?: string; + buildDir?: string; +} + +export async function runNitroTask( + name: string, + payload?: NitroTaskPayload, + opts?: TaskRunnerOptions +): Promise<{ result: unknown }> { + const ctx = await getTasksContext(opts); + const result = await ctx.devFetch("/_nitro/tasks/" + name); + return result; +} + +export async function listNitroTasks(opts?: TaskRunnerOptions) { + const ctx = await getTasksContext(opts); + const res = (await ctx.devFetch("/_nitro/tasks")) as { + tasks: Record; + }; + return res.tasks; +} + +const devHint = `(is dev server running?)`; + +/** @experimental */ +async function getTasksContext(opts: TaskRunnerOptions) { + const cwd = resolve(process.cwd(), opts.cwd); + const outDir = resolve(cwd, opts.buildDir || ".nitro"); + + const buildInfoPath = resolve(outDir, "nitro.json"); + if (!existsSync(buildInfoPath)) { + throw new Error(`Missing info file: \`${buildInfoPath}\` ${devHint}`); + } + + const buildInfo = JSON.parse( + await readFile(buildInfoPath, "utf8") + ) as NitroBuildInfo; + + if (!buildInfo.dev?.pid || !buildInfo.dev?.workerAddress) { + throw new Error( + `Missing dev server info in: \`${buildInfoPath}\` ${devHint}` + ); + } + + if (!pidIsRunning(buildInfo.dev.pid)) { + throw new Error(`Dev server is not running (pid: ${buildInfo.dev.pid})`); + } + + const devFetch = ofetch.create({ + baseURL: `http://${buildInfo.dev.workerAddress.host || "localhost"}:${ + buildInfo.dev.workerAddress.port || "3000" + }`, + // @ts-expect-error + socketPath: buildInfo.dev.workerAddress.socketPath, + }); + + return { + buildInfo, + devFetch, + }; +} + +function pidIsRunning(pid) { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} diff --git a/src/types/nitro.ts b/src/types/nitro.ts index 41f51f4e7d..3e800d0cda 100644 --- a/src/types/nitro.ts +++ b/src/types/nitro.ts @@ -218,7 +218,7 @@ export interface NitroBuildInfo { }; dev?: { pid: number; - workerAddress: { host: string; port: number } | { socketPath: string }; + workerAddress: { host: string; port: number; socketPath?: string }; }; } @@ -296,6 +296,7 @@ export interface NitroOptions extends PresetOptions { imports: UnimportPluginOptions | false; modules?: NitroModuleInput[]; plugins: string[]; + tasks: { [name: string]: { handler: string; description: string } }; virtual: Record string | Promise)>; compressPublicAssets: boolean | CompressOptions; ignore: string[]; diff --git a/test/fixture/nitro.config.ts b/test/fixture/nitro.config.ts index 7a1b1fd47d..8b36f62114 100644 --- a/test/fixture/nitro.config.ts +++ b/test/fixture/nitro.config.ts @@ -48,6 +48,10 @@ export default defineNitroConfig({ maxAge: 3600, }, ], + tasks: { + "db:migrate": { description: "Migrate database" }, + "db:seed": { description: "Seed database" }, + }, routeRules: { "/api/param/prerender4": { prerender: true }, "/api/param/prerender2": { prerender: false }, diff --git a/test/fixture/routes/tasks/[...name].ts b/test/fixture/routes/tasks/[...name].ts new file mode 100644 index 0000000000..597f9000a4 --- /dev/null +++ b/test/fixture/routes/tasks/[...name].ts @@ -0,0 +1,10 @@ +export default eventHandler(async (event) => { + const name = getRouterParam(event, "name"); + const payload = { ...getQuery(event) }; + const { result } = await runNitroTask(name, payload); + return { + name, + payload, + result, + }; +}); diff --git a/test/fixture/tasks/db/migrate.ts b/test/fixture/tasks/db/migrate.ts new file mode 100644 index 0000000000..efb6d3d139 --- /dev/null +++ b/test/fixture/tasks/db/migrate.ts @@ -0,0 +1,7 @@ +export default defineNitroTask({ + description: "Run database migrations", + run(payload, context) { + console.log("Running DB migration task...", { payload }); + return { result: "Success" }; + }, +});