@rsc-kit/core 0.30.15 → 0.30.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,7 +28,9 @@ is underneath:
28
28
  `await rpc('Orders.recent')`, typed from the backend's own signatures. See
29
29
  [Backend-Answered Pages](https://docs.rsc-kit.dev/hosts/backend-answered-pages).
30
30
  - **It compiles to a single binary** with `bun build --compile`, assets and
31
- frozen pages inside it.
31
+ frozen pages inside it. A file in `src/commands` is a release step or a
32
+ worker in the same binary: `./app migrate`
33
+ ([guide](https://docs.rsc-kit.dev/guides/commands)).
32
34
 
33
35
  ## Setup
34
36
 
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The build's half of app commands: which files in `src/commands` are
3
+ * commands, what the generated entry says about them, and the small entries
4
+ * that run one. The runner they call is `commands.ts`.
5
+ */
6
+ export interface CommandFile {
7
+ /** What is typed after the binary: the file's name without its extension. */
8
+ name: string;
9
+ /** Absolute path, imported lazily by the entry. */
10
+ file: string;
11
+ /** A plain string literal exported as `description`, for `--help`. */
12
+ description?: string;
13
+ }
14
+ export interface CommandFiles {
15
+ commands: CommandFile[];
16
+ /** Paths under src/commands that are not commands, and why, for the build to say. */
17
+ ignored: {
18
+ path: string;
19
+ reason: string;
20
+ }[];
21
+ }
22
+ /**
23
+ * The commands under `<sourceDir>/commands`.
24
+ *
25
+ * Flat on purpose: a name is what a person types, and `db/seed.ts` raises
26
+ * whether that is `db:seed`, `db/seed` or `seed`. Declaration files, tests and
27
+ * anything starting with `_` or `.` are not commands - the last is how a
28
+ * helper lives beside the commands that share it.
29
+ */
30
+ export declare function commandFiles(sourceDir: string): CommandFiles;
31
+ /** The module the generated entry reads the table from, resolved when the bundle is built. */
32
+ export declare const COMMANDS_MODULE_ID = "virtual:rsc-kit/commands";
33
+ /**
34
+ * The table: every command as a dynamic import, so each is a chunk of its own
35
+ * and the server never evaluates one. An empty object when there are none -
36
+ * a Worker's build, say, which does not carry them.
37
+ *
38
+ * A module of its own rather than text in the entry because the entry is
39
+ * written while the config is still being read, before anything knows the
40
+ * build's mode or which Nitro preset it is for.
41
+ */
42
+ export declare function commandsModule(commands: readonly CommandFile[]): string;
43
+ /**
44
+ * What the generated server entry adds for an app with a `src/commands`:
45
+ * `runCommand` over the table, with the entry's own `register()` and
46
+ * `shutdown()`. `runner` is the engine's `commands` module, named without an
47
+ * extension like every other engine module the entry imports.
48
+ */
49
+ export declare function commandsEntry(runner: string): string;
50
+ /**
51
+ * The entry a plain build runs a command from, written beside the server:
52
+ *
53
+ * node .output/server/commands.mjs migrate
54
+ * bun .output/server/commands.mjs migrate
55
+ *
56
+ * The server is not started - nothing here imports it - and the flag is set
57
+ * before the entry is imported, since importing it is what would otherwise
58
+ * call register().
59
+ */
60
+ export declare function commandsEntrySource(): string;
@@ -0,0 +1,136 @@
1
+ /**
2
+ * The build's half of app commands: which files in `src/commands` are
3
+ * commands, what the generated entry says about them, and the small entries
4
+ * that run one. The runner they call is `commands.ts`.
5
+ */
6
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ const SOURCE = /\.(?:[cm]?[jt]sx?)$/;
9
+ const NAME = /^[A-Za-z0-9][A-Za-z0-9_.:-]*$/;
10
+ /** Earlier wins when two files share a name: migrate.ts over migrate.js. */
11
+ const EXTENSION_ORDER = ['ts', 'mts', 'cts', 'tsx', 'js', 'mjs', 'cjs', 'jsx'];
12
+ /**
13
+ * `export const description = 'Apply the database migrations'`, read off the
14
+ * source so `--help` can list a command without loading it - a command's
15
+ * imports are the database and the logger, and listing should not open either.
16
+ * A plain string literal only; anything computed is simply not listed.
17
+ */
18
+ function describe(source) {
19
+ const match = /^\s*export\s+const\s+description\s*(?::\s*string\s*)?=\s*(["'`])([^"'`\\\n$]*)\1/m.exec(source);
20
+ return match?.[2]?.trim() || undefined;
21
+ }
22
+ /**
23
+ * The commands under `<sourceDir>/commands`.
24
+ *
25
+ * Flat on purpose: a name is what a person types, and `db/seed.ts` raises
26
+ * whether that is `db:seed`, `db/seed` or `seed`. Declaration files, tests and
27
+ * anything starting with `_` or `.` are not commands - the last is how a
28
+ * helper lives beside the commands that share it.
29
+ */
30
+ export function commandFiles(sourceDir) {
31
+ const dir = join(sourceDir, 'commands');
32
+ const found = { commands: [], ignored: [] };
33
+ if (!existsSync(dir))
34
+ return found;
35
+ const taken = new Map();
36
+ const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
37
+ for (const entry of entries) {
38
+ const path = join(dir, entry.name);
39
+ if (entry.name.startsWith('_') || entry.name.startsWith('.'))
40
+ continue;
41
+ if (entry.isDirectory()) {
42
+ found.ignored.push({ path, reason: 'commands are the files directly in src/commands, not a folder of them' });
43
+ continue;
44
+ }
45
+ if (!SOURCE.test(entry.name) || /\.d\.[cm]?ts$/.test(entry.name) || /\.(?:test|spec)\.[cm]?[jt]sx?$/.test(entry.name))
46
+ continue;
47
+ const extension = entry.name.slice(entry.name.lastIndexOf('.') + 1);
48
+ const name = entry.name.slice(0, -(extension.length + 1));
49
+ if (!NAME.test(name)) {
50
+ found.ignored.push({ path, reason: `"${name}" cannot be typed as a command: letters, digits, "-", "_", "." and ":"` });
51
+ continue;
52
+ }
53
+ const existing = taken.get(name);
54
+ if (existing) {
55
+ const keep = EXTENSION_ORDER.indexOf(existing.file.split('.').pop() ?? '') <= EXTENSION_ORDER.indexOf(extension);
56
+ found.ignored.push({
57
+ path: keep ? path : existing.file,
58
+ reason: `two files are called "${name}"; ${keep ? existing.file : path} is the command`,
59
+ });
60
+ if (keep)
61
+ continue;
62
+ found.commands.splice(found.commands.indexOf(existing), 1);
63
+ }
64
+ const command = { name, file: path, description: describe(readFileSync(path, 'utf-8')) };
65
+ taken.set(name, command);
66
+ found.commands.push(command);
67
+ }
68
+ return found;
69
+ }
70
+ /** The module the generated entry reads the table from, resolved when the bundle is built. */
71
+ export const COMMANDS_MODULE_ID = 'virtual:rsc-kit/commands';
72
+ /**
73
+ * The table: every command as a dynamic import, so each is a chunk of its own
74
+ * and the server never evaluates one. An empty object when there are none -
75
+ * a Worker's build, say, which does not carry them.
76
+ *
77
+ * A module of its own rather than text in the entry because the entry is
78
+ * written while the config is still being read, before anything knows the
79
+ * build's mode or which Nitro preset it is for.
80
+ */
81
+ export function commandsModule(commands) {
82
+ const rows = commands.map(({ name, file, description }) => ` ${JSON.stringify(name)}: { load: () => import(${JSON.stringify(file)})${description ? `, description: ${JSON.stringify(description)}` : ''} },`);
83
+ return `export const commands = {\n${rows.join('\n')}\n}\n`;
84
+ }
85
+ /**
86
+ * What the generated server entry adds for an app with a `src/commands`:
87
+ * `runCommand` over the table, with the entry's own `register()` and
88
+ * `shutdown()`. `runner` is the engine's `commands` module, named without an
89
+ * extension like every other engine module the entry imports.
90
+ */
91
+ export function commandsEntry(runner) {
92
+ return `
93
+ import { commands as __commands } from ${JSON.stringify(COMMANDS_MODULE_ID)}
94
+ import { runCommand as __runCommand } from ${JSON.stringify(runner)}
95
+
96
+ // src/commands, each loaded only when it is named.
97
+ export function runCommand(argv: string[], program?: string) {
98
+ return __runCommand(argv, { commands: __commands, program, ready: instrumented, shutdown: __instrumentation.shutdown })
99
+ }
100
+ `;
101
+ }
102
+ /**
103
+ * The entry a plain build runs a command from, written beside the server:
104
+ *
105
+ * node .output/server/commands.mjs migrate
106
+ * bun .output/server/commands.mjs migrate
107
+ *
108
+ * The server is not started - nothing here imports it - and the flag is set
109
+ * before the entry is imported, since importing it is what would otherwise
110
+ * call register().
111
+ */
112
+ export function commandsEntrySource() {
113
+ return ('// Run one of the app\'s commands without starting the server:\n' +
114
+ '// node .output/server/commands.mjs <command> [args]\n' +
115
+ '// bun .output/server/commands.mjs <command> [args]\n' +
116
+ 'import { relative } from "node:path";\n' +
117
+ 'import { fileURLToPath } from "node:url";\n' +
118
+ '\n' +
119
+ 'globalThis[Symbol.for("rsc-kit.command")] = true;\n' +
120
+ '\n' +
121
+ 'let code = 1;\n' +
122
+ '\n' +
123
+ 'try {\n' +
124
+ ' const { runCommand } = await import("./_ssr/rsc.mjs");\n' +
125
+ ' const runtime = process.versions.bun ? "bun" : "node";\n' +
126
+ '\n' +
127
+ ' code = await runCommand(process.argv.slice(2), runtime + " " + relative(process.cwd(), fileURLToPath(import.meta.url)));\n' +
128
+ '} catch (error) {\n' +
129
+ ' // The app failed to load: instrumentation.ts refusing a bad environment,\n' +
130
+ ' // as it would stop the server.\n' +
131
+ ' console.error("[rsc-kit] the app failed to load:", error);\n' +
132
+ '}\n' +
133
+ '\n' +
134
+ 'process.exit(code);\n');
135
+ }
136
+ //# sourceMappingURL=commandFiles.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"commandFiles.js","sourceRoot":"","sources":["../src/commandFiles.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The build's half of app commands: which files in `src/commands` are\n * commands, what the generated entry says about them, and the small entries\n * that run one. The runner they call is `commands.ts`.\n */\n\nimport { existsSync, readdirSync, readFileSync } from 'node:fs'\nimport { join } from 'node:path'\n\nexport interface CommandFile {\n /** What is typed after the binary: the file's name without its extension. */\n name: string\n /** Absolute path, imported lazily by the entry. */\n file: string\n /** A plain string literal exported as `description`, for `--help`. */\n description?: string\n}\n\nexport interface CommandFiles {\n commands: CommandFile[]\n /** Paths under src/commands that are not commands, and why, for the build to say. */\n ignored: { path: string; reason: string }[]\n}\n\nconst SOURCE = /\\.(?:[cm]?[jt]sx?)$/\nconst NAME = /^[A-Za-z0-9][A-Za-z0-9_.:-]*$/\n\n/** Earlier wins when two files share a name: migrate.ts over migrate.js. */\nconst EXTENSION_ORDER = ['ts', 'mts', 'cts', 'tsx', 'js', 'mjs', 'cjs', 'jsx']\n\n/**\n * `export const description = 'Apply the database migrations'`, read off the\n * source so `--help` can list a command without loading it - a command's\n * imports are the database and the logger, and listing should not open either.\n * A plain string literal only; anything computed is simply not listed.\n */\nfunction describe(source: string): string | undefined {\n const match = /^\\s*export\\s+const\\s+description\\s*(?::\\s*string\\s*)?=\\s*([\"'`])([^\"'`\\\\\\n$]*)\\1/m.exec(source)\n\n return match?.[2]?.trim() || undefined\n}\n\n/**\n * The commands under `<sourceDir>/commands`.\n *\n * Flat on purpose: a name is what a person types, and `db/seed.ts` raises\n * whether that is `db:seed`, `db/seed` or `seed`. Declaration files, tests and\n * anything starting with `_` or `.` are not commands - the last is how a\n * helper lives beside the commands that share it.\n */\nexport function commandFiles(sourceDir: string): CommandFiles {\n const dir = join(sourceDir, 'commands')\n const found: CommandFiles = { commands: [], ignored: [] }\n\n if (!existsSync(dir)) return found\n\n const taken = new Map<string, CommandFile>()\n const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))\n\n for (const entry of entries) {\n const path = join(dir, entry.name)\n\n if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue\n\n if (entry.isDirectory()) {\n found.ignored.push({ path, reason: 'commands are the files directly in src/commands, not a folder of them' })\n\n continue\n }\n\n if (!SOURCE.test(entry.name) || /\\.d\\.[cm]?ts$/.test(entry.name) || /\\.(?:test|spec)\\.[cm]?[jt]sx?$/.test(entry.name)) continue\n\n const extension = entry.name.slice(entry.name.lastIndexOf('.') + 1)\n const name = entry.name.slice(0, -(extension.length + 1))\n\n if (!NAME.test(name)) {\n found.ignored.push({ path, reason: `\"${name}\" cannot be typed as a command: letters, digits, \"-\", \"_\", \".\" and \":\"` })\n\n continue\n }\n\n const existing = taken.get(name)\n\n if (existing) {\n const keep = EXTENSION_ORDER.indexOf(existing.file.split('.').pop() ?? '') <= EXTENSION_ORDER.indexOf(extension)\n\n found.ignored.push({\n path: keep ? path : existing.file,\n reason: `two files are called \"${name}\"; ${keep ? existing.file : path} is the command`,\n })\n\n if (keep) continue\n\n found.commands.splice(found.commands.indexOf(existing), 1)\n }\n\n const command: CommandFile = { name, file: path, description: describe(readFileSync(path, 'utf-8')) }\n\n taken.set(name, command)\n found.commands.push(command)\n }\n\n return found\n}\n\n/** The module the generated entry reads the table from, resolved when the bundle is built. */\nexport const COMMANDS_MODULE_ID = 'virtual:rsc-kit/commands'\n\n/**\n * The table: every command as a dynamic import, so each is a chunk of its own\n * and the server never evaluates one. An empty object when there are none -\n * a Worker's build, say, which does not carry them.\n *\n * A module of its own rather than text in the entry because the entry is\n * written while the config is still being read, before anything knows the\n * build's mode or which Nitro preset it is for.\n */\nexport function commandsModule(commands: readonly CommandFile[]): string {\n const rows = commands.map(\n ({ name, file, description }) =>\n ` ${JSON.stringify(name)}: { load: () => import(${JSON.stringify(file)})${description ? `, description: ${JSON.stringify(description)}` : ''} },`,\n )\n\n return `export const commands = {\\n${rows.join('\\n')}\\n}\\n`\n}\n\n/**\n * What the generated server entry adds for an app with a `src/commands`:\n * `runCommand` over the table, with the entry's own `register()` and\n * `shutdown()`. `runner` is the engine's `commands` module, named without an\n * extension like every other engine module the entry imports.\n */\nexport function commandsEntry(runner: string): string {\n return `\nimport { commands as __commands } from ${JSON.stringify(COMMANDS_MODULE_ID)}\nimport { runCommand as __runCommand } from ${JSON.stringify(runner)}\n\n// src/commands, each loaded only when it is named.\nexport function runCommand(argv: string[], program?: string) {\n return __runCommand(argv, { commands: __commands, program, ready: instrumented, shutdown: __instrumentation.shutdown })\n}\n`\n}\n\n/**\n * The entry a plain build runs a command from, written beside the server:\n *\n * node .output/server/commands.mjs migrate\n * bun .output/server/commands.mjs migrate\n *\n * The server is not started - nothing here imports it - and the flag is set\n * before the entry is imported, since importing it is what would otherwise\n * call register().\n */\nexport function commandsEntrySource(): string {\n return (\n '// Run one of the app\\'s commands without starting the server:\\n' +\n '// node .output/server/commands.mjs <command> [args]\\n' +\n '// bun .output/server/commands.mjs <command> [args]\\n' +\n 'import { relative } from \"node:path\";\\n' +\n 'import { fileURLToPath } from \"node:url\";\\n' +\n '\\n' +\n 'globalThis[Symbol.for(\"rsc-kit.command\")] = true;\\n' +\n '\\n' +\n 'let code = 1;\\n' +\n '\\n' +\n 'try {\\n' +\n ' const { runCommand } = await import(\"./_ssr/rsc.mjs\");\\n' +\n ' const runtime = process.versions.bun ? \"bun\" : \"node\";\\n' +\n '\\n' +\n ' code = await runCommand(process.argv.slice(2), runtime + \" \" + relative(process.cwd(), fileURLToPath(import.meta.url)));\\n' +\n '} catch (error) {\\n' +\n ' // The app failed to load: instrumentation.ts refusing a bad environment,\\n' +\n ' // as it would stop the server.\\n' +\n ' console.error(\"[rsc-kit] the app failed to load:\", error);\\n' +\n '}\\n' +\n '\\n' +\n 'process.exit(code);\\n'\n )\n}\n"]}
@@ -0,0 +1,74 @@
1
+ /**
2
+ * App commands: the files in `src/commands`, run as `./app <name>`.
3
+ *
4
+ * A compiled app is one binary that starts a server, and a release step or a
5
+ * queue worker is the same code started differently - `./app migrate`,
6
+ * `./app work`. The same entry also runs from a plain build, as
7
+ * `node .output/server/commands.mjs <name>`, so nothing about a command
8
+ * depends on Bun, on a platform, or on the binary.
9
+ *
10
+ * Each command is a file with a default export, `(args, { signal }) => number
11
+ * | void`. It shares the build's code and dependencies, so the database
12
+ * package and the logger the pages use are the ones it uses, in the same
13
+ * bundle. It is loaded only when it is named: the server never evaluates one.
14
+ *
15
+ * `register()` from instrumentation.ts is skipped unless the command says
16
+ * `export const register = true`. Importing instrumentation.ts is not
17
+ * skipped - a bad environment fails there, at import, for a command as for a
18
+ * server - but a command that only needs the schema should not pay for, or
19
+ * fail on, a connection it never uses.
20
+ *
21
+ * Nothing here imports `node:` anything: the runner is bundled into the
22
+ * server's entry for every host, and a host without those modules must still
23
+ * load it. `process` is reached lazily, inside the functions that need it.
24
+ */
25
+ /**
26
+ * Set on `globalThis` before the server's entry is imported, by whatever runs
27
+ * a command. The entry would otherwise call register() the moment it loads, as
28
+ * a server does at startup, which a command has not asked for.
29
+ */
30
+ export declare const COMMAND_MODE: unique symbol;
31
+ /** What a command receives beside its arguments. */
32
+ export interface CommandContext {
33
+ /** Aborted by SIGTERM or SIGINT: a long-running command stops when it is. */
34
+ signal: AbortSignal;
35
+ }
36
+ /** What the build knows of one command file: how to load it, and a line to list it by. */
37
+ export interface CommandEntry {
38
+ load: () => Promise<{
39
+ default?: unknown;
40
+ register?: unknown;
41
+ }>;
42
+ description?: string;
43
+ }
44
+ export interface RunCommandOptions {
45
+ commands: Record<string, CommandEntry>;
46
+ /** instrumentation.ts's register(), once. Awaited only for a command that exports `register = true`. */
47
+ ready: () => Promise<unknown>;
48
+ /** instrumentation.ts's shutdown(), called after a command that was given `register()`. */
49
+ shutdown?: () => unknown;
50
+ /** How the usage line names this program: `./app`, `node .output/server/commands.mjs`. */
51
+ program?: string;
52
+ out?: (text: string) => void;
53
+ err?: (text: string) => void;
54
+ /** SIGTERM and SIGINT. The process, unless a test brings its own. */
55
+ signals?: {
56
+ on: (signal: 'SIGTERM' | 'SIGINT', listener: () => void) => unknown;
57
+ off: (signal: 'SIGTERM' | 'SIGINT', listener: () => void) => unknown;
58
+ };
59
+ /** Milliseconds a command gets to stop once told to, and shutdown() after it. RSC_SHUTDOWN_TIMEOUT seconds (10) by default. */
60
+ limit?: number;
61
+ }
62
+ /** Whether this process was started to run a command rather than to serve. */
63
+ export declare function inCommandMode(): boolean;
64
+ /** The list `--help` prints, and what an unknown name is answered with. */
65
+ export declare function commandHelp(commands: Record<string, CommandEntry>, program?: string): string;
66
+ /**
67
+ * Run the command `argv` names and say how it ended.
68
+ *
69
+ * Resolves to the exit code and leaves exiting to the caller, so a test sees
70
+ * the code and an entry decides when the process stops. 0 is success and 1 is
71
+ * a command that threw or would not stop; 2 is a name that is not a command,
72
+ * as it is for a tool that was misused.
73
+ */
74
+ export declare function runCommand(argv: readonly string[], options: RunCommandOptions): Promise<number>;
@@ -0,0 +1,172 @@
1
+ /**
2
+ * App commands: the files in `src/commands`, run as `./app <name>`.
3
+ *
4
+ * A compiled app is one binary that starts a server, and a release step or a
5
+ * queue worker is the same code started differently - `./app migrate`,
6
+ * `./app work`. The same entry also runs from a plain build, as
7
+ * `node .output/server/commands.mjs <name>`, so nothing about a command
8
+ * depends on Bun, on a platform, or on the binary.
9
+ *
10
+ * Each command is a file with a default export, `(args, { signal }) => number
11
+ * | void`. It shares the build's code and dependencies, so the database
12
+ * package and the logger the pages use are the ones it uses, in the same
13
+ * bundle. It is loaded only when it is named: the server never evaluates one.
14
+ *
15
+ * `register()` from instrumentation.ts is skipped unless the command says
16
+ * `export const register = true`. Importing instrumentation.ts is not
17
+ * skipped - a bad environment fails there, at import, for a command as for a
18
+ * server - but a command that only needs the schema should not pay for, or
19
+ * fail on, a connection it never uses.
20
+ *
21
+ * Nothing here imports `node:` anything: the runner is bundled into the
22
+ * server's entry for every host, and a host without those modules must still
23
+ * load it. `process` is reached lazily, inside the functions that need it.
24
+ */
25
+ /**
26
+ * Set on `globalThis` before the server's entry is imported, by whatever runs
27
+ * a command. The entry would otherwise call register() the moment it loads, as
28
+ * a server does at startup, which a command has not asked for.
29
+ */
30
+ export const COMMAND_MODE = Symbol.for('rsc-kit.command');
31
+ /** Whether this process was started to run a command rather than to serve. */
32
+ export function inCommandMode() {
33
+ return Boolean(globalThis[COMMAND_MODE]);
34
+ }
35
+ /** The list `--help` prints, and what an unknown name is answered with. */
36
+ export function commandHelp(commands, program = 'app') {
37
+ const names = Object.keys(commands).sort();
38
+ const width = Math.max(0, ...names.map((name) => name.length));
39
+ const rows = names.map((name) => {
40
+ const description = commands[name]?.description;
41
+ return description ? ` ${name.padEnd(width)} ${description}` : ` ${name}`;
42
+ });
43
+ return (`Usage: ${program} <command> [args]\n\n` +
44
+ (rows.length ? `Commands:\n${rows.join('\n')}\n` : 'There are no commands: add a file to src/commands.\n'));
45
+ }
46
+ /**
47
+ * An exit code from what a command returned. Nothing is success; a number is
48
+ * the code. Anything else is the command's mistake, and said so, because the
49
+ * alternative is a process that exits 0 after returning a string.
50
+ */
51
+ function exitCodeOf(result, name) {
52
+ if (result === undefined)
53
+ return 0;
54
+ if (typeof result === 'number' && Number.isInteger(result) && result >= 0 && result <= 255)
55
+ return result;
56
+ throw new TypeError(`the command "${name}" returned ${JSON.stringify(result)}. A command returns an exit code from 0 to 255, or nothing.`);
57
+ }
58
+ function unref(timer) {
59
+ const handle = timer;
60
+ if (handle && typeof handle.unref === 'function')
61
+ handle.unref();
62
+ }
63
+ /** The seconds a command and its shutdown() get after a signal, in milliseconds. */
64
+ function defaultLimit() {
65
+ const seconds = Number(typeof process !== 'undefined' ? process.env.RSC_SHUTDOWN_TIMEOUT : undefined);
66
+ return (Number.isFinite(seconds) && seconds > 0 ? seconds : 10) * 1000;
67
+ }
68
+ /**
69
+ * Run the command `argv` names and say how it ended.
70
+ *
71
+ * Resolves to the exit code and leaves exiting to the caller, so a test sees
72
+ * the code and an entry decides when the process stops. 0 is success and 1 is
73
+ * a command that threw or would not stop; 2 is a name that is not a command,
74
+ * as it is for a tool that was misused.
75
+ */
76
+ export async function runCommand(argv, options) {
77
+ const out = options.out ?? ((text) => void process.stdout.write(text));
78
+ const err = options.err ?? ((text) => void process.stderr.write(text));
79
+ const [name, ...args] = argv;
80
+ if (name === '--help' || name === '-h') {
81
+ out(commandHelp(options.commands, options.program));
82
+ return 0;
83
+ }
84
+ if (name === undefined) {
85
+ err(commandHelp(options.commands, options.program));
86
+ return 2;
87
+ }
88
+ const command = Object.hasOwn(options.commands, name) ? options.commands[name] : undefined;
89
+ if (!command) {
90
+ err(`Not a command: ${name}\n\n${commandHelp(options.commands, options.program)}`);
91
+ return 2;
92
+ }
93
+ const limit = options.limit ?? defaultLimit();
94
+ const signals = options.signals ?? (typeof process !== 'undefined' ? process : undefined);
95
+ const controller = new AbortController();
96
+ let registered = false;
97
+ let stop;
98
+ let stopTimer;
99
+ let signalled = 0;
100
+ // The first signal asks the command to stop and gives it `limit` to do so,
101
+ // as the server gives in-flight requests theirs. The second is a person who
102
+ // meant it, and nothing waits.
103
+ const onSignal = () => {
104
+ signalled += 1;
105
+ if (signalled === 1) {
106
+ controller.abort();
107
+ stopTimer = setTimeout(() => {
108
+ err(`[rsc-kit] the command "${name}" did not stop within ${limit / 1000} seconds.\n`);
109
+ stop?.(1);
110
+ }, limit);
111
+ unref(stopTimer);
112
+ return;
113
+ }
114
+ stop?.(1);
115
+ };
116
+ const stopped = new Promise((resolve) => {
117
+ stop = resolve;
118
+ });
119
+ signals?.on('SIGTERM', onSignal);
120
+ signals?.on('SIGINT', onSignal);
121
+ // A command that is only waiting - for its signal, for a message that has
122
+ // not come - holds nothing open, and Node answers an event loop with nothing
123
+ // in it by exiting 13 with a warning about an unsettled await, where Bun
124
+ // simply waits. A command that is running is running until it returns.
125
+ const alive = setInterval(() => { }, 2 ** 30);
126
+ const ran = (async () => {
127
+ try {
128
+ const module = await command.load();
129
+ if (typeof module.default !== 'function') {
130
+ throw new TypeError(`src/commands/${name} has no default export. Export a function: export default async (args) => {}`);
131
+ }
132
+ // Opted in, and only then: a migration that needs the schema should not
133
+ // fail because the queue it never touches is not up.
134
+ if (module.register === true) {
135
+ await options.ready();
136
+ registered = true;
137
+ }
138
+ return exitCodeOf(await module.default(args, { signal: controller.signal }), name);
139
+ }
140
+ catch (error) {
141
+ err(`[rsc-kit] the command "${name}" failed: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`);
142
+ return 1;
143
+ }
144
+ })();
145
+ let code;
146
+ try {
147
+ code = await Promise.race([ran, stopped]);
148
+ }
149
+ finally {
150
+ clearInterval(alive);
151
+ clearTimeout(stopTimer);
152
+ signals?.off('SIGTERM', onSignal);
153
+ signals?.off('SIGINT', onSignal);
154
+ }
155
+ // What register() opened is closed here, bounded like the server's: a
156
+ // close that hangs must not hold a release step open.
157
+ if (registered && options.shutdown) {
158
+ let timer;
159
+ await Promise.race([
160
+ Promise.resolve()
161
+ .then(() => options.shutdown?.())
162
+ .catch((error) => err(`[rsc-kit] instrumentation.ts shutdown() failed: ${error instanceof Error ? error.message : String(error)}\n`)),
163
+ new Promise((resolve) => {
164
+ timer = setTimeout(resolve, limit);
165
+ unref(timer);
166
+ }),
167
+ ]);
168
+ clearTimeout(timer);
169
+ }
170
+ return code;
171
+ }
172
+ //# sourceMappingURL=commands.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"commands.js","sourceRoot":"","sources":["../src/commands.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * App commands: the files in `src/commands`, run as `./app <name>`.\n *\n * A compiled app is one binary that starts a server, and a release step or a\n * queue worker is the same code started differently - `./app migrate`,\n * `./app work`. The same entry also runs from a plain build, as\n * `node .output/server/commands.mjs <name>`, so nothing about a command\n * depends on Bun, on a platform, or on the binary.\n *\n * Each command is a file with a default export, `(args, { signal }) => number\n * | void`. It shares the build's code and dependencies, so the database\n * package and the logger the pages use are the ones it uses, in the same\n * bundle. It is loaded only when it is named: the server never evaluates one.\n *\n * `register()` from instrumentation.ts is skipped unless the command says\n * `export const register = true`. Importing instrumentation.ts is not\n * skipped - a bad environment fails there, at import, for a command as for a\n * server - but a command that only needs the schema should not pay for, or\n * fail on, a connection it never uses.\n *\n * Nothing here imports `node:` anything: the runner is bundled into the\n * server's entry for every host, and a host without those modules must still\n * load it. `process` is reached lazily, inside the functions that need it.\n */\n\n/**\n * Set on `globalThis` before the server's entry is imported, by whatever runs\n * a command. The entry would otherwise call register() the moment it loads, as\n * a server does at startup, which a command has not asked for.\n */\nexport const COMMAND_MODE = Symbol.for('rsc-kit.command')\n\n/** What a command receives beside its arguments. */\nexport interface CommandContext {\n /** Aborted by SIGTERM or SIGINT: a long-running command stops when it is. */\n signal: AbortSignal\n}\n\n/** What the build knows of one command file: how to load it, and a line to list it by. */\nexport interface CommandEntry {\n load: () => Promise<{ default?: unknown; register?: unknown }>\n description?: string\n}\n\nexport interface RunCommandOptions {\n commands: Record<string, CommandEntry>\n /** instrumentation.ts's register(), once. Awaited only for a command that exports `register = true`. */\n ready: () => Promise<unknown>\n /** instrumentation.ts's shutdown(), called after a command that was given `register()`. */\n shutdown?: () => unknown\n /** How the usage line names this program: `./app`, `node .output/server/commands.mjs`. */\n program?: string\n out?: (text: string) => void\n err?: (text: string) => void\n /** SIGTERM and SIGINT. The process, unless a test brings its own. */\n signals?: {\n on: (signal: 'SIGTERM' | 'SIGINT', listener: () => void) => unknown\n off: (signal: 'SIGTERM' | 'SIGINT', listener: () => void) => unknown\n }\n /** Milliseconds a command gets to stop once told to, and shutdown() after it. RSC_SHUTDOWN_TIMEOUT seconds (10) by default. */\n limit?: number\n}\n\n/** Whether this process was started to run a command rather than to serve. */\nexport function inCommandMode(): boolean {\n return Boolean((globalThis as Record<symbol, unknown>)[COMMAND_MODE])\n}\n\n/** The list `--help` prints, and what an unknown name is answered with. */\nexport function commandHelp(commands: Record<string, CommandEntry>, program = 'app'): string {\n const names = Object.keys(commands).sort()\n const width = Math.max(0, ...names.map((name) => name.length))\n const rows = names.map((name) => {\n const description = commands[name]?.description\n\n return description ? ` ${name.padEnd(width)} ${description}` : ` ${name}`\n })\n\n return (\n `Usage: ${program} <command> [args]\\n\\n` +\n (rows.length ? `Commands:\\n${rows.join('\\n')}\\n` : 'There are no commands: add a file to src/commands.\\n')\n )\n}\n\n/**\n * An exit code from what a command returned. Nothing is success; a number is\n * the code. Anything else is the command's mistake, and said so, because the\n * alternative is a process that exits 0 after returning a string.\n */\nfunction exitCodeOf(result: unknown, name: string): number {\n if (result === undefined) return 0\n\n if (typeof result === 'number' && Number.isInteger(result) && result >= 0 && result <= 255) return result\n\n throw new TypeError(\n `the command \"${name}\" returned ${JSON.stringify(result)}. A command returns an exit code from 0 to 255, or nothing.`,\n )\n}\n\nfunction unref(timer: unknown): void {\n const handle = timer as { unref?: () => void } | undefined\n\n if (handle && typeof handle.unref === 'function') handle.unref()\n}\n\n/** The seconds a command and its shutdown() get after a signal, in milliseconds. */\nfunction defaultLimit(): number {\n const seconds = Number(typeof process !== 'undefined' ? process.env.RSC_SHUTDOWN_TIMEOUT : undefined)\n\n return (Number.isFinite(seconds) && seconds > 0 ? seconds : 10) * 1000\n}\n\n/**\n * Run the command `argv` names and say how it ended.\n *\n * Resolves to the exit code and leaves exiting to the caller, so a test sees\n * the code and an entry decides when the process stops. 0 is success and 1 is\n * a command that threw or would not stop; 2 is a name that is not a command,\n * as it is for a tool that was misused.\n */\nexport async function runCommand(argv: readonly string[], options: RunCommandOptions): Promise<number> {\n const out = options.out ?? ((text: string) => void process.stdout.write(text))\n const err = options.err ?? ((text: string) => void process.stderr.write(text))\n const [name, ...args] = argv\n\n if (name === '--help' || name === '-h') {\n out(commandHelp(options.commands, options.program))\n\n return 0\n }\n\n if (name === undefined) {\n err(commandHelp(options.commands, options.program))\n\n return 2\n }\n\n const command = Object.hasOwn(options.commands, name) ? options.commands[name] : undefined\n\n if (!command) {\n err(`Not a command: ${name}\\n\\n${commandHelp(options.commands, options.program)}`)\n\n return 2\n }\n\n const limit = options.limit ?? defaultLimit()\n const signals = options.signals ?? (typeof process !== 'undefined' ? process : undefined)\n const controller = new AbortController()\n\n let registered = false\n let stop: ((code: number) => void) | undefined\n let stopTimer: unknown\n let signalled = 0\n\n // The first signal asks the command to stop and gives it `limit` to do so,\n // as the server gives in-flight requests theirs. The second is a person who\n // meant it, and nothing waits.\n const onSignal = () => {\n signalled += 1\n\n if (signalled === 1) {\n controller.abort()\n\n stopTimer = setTimeout(() => {\n err(`[rsc-kit] the command \"${name}\" did not stop within ${limit / 1000} seconds.\\n`)\n stop?.(1)\n }, limit)\n\n unref(stopTimer)\n\n return\n }\n\n stop?.(1)\n }\n\n const stopped = new Promise<number>((resolve) => {\n stop = resolve\n })\n\n signals?.on('SIGTERM', onSignal)\n signals?.on('SIGINT', onSignal)\n\n // A command that is only waiting - for its signal, for a message that has\n // not come - holds nothing open, and Node answers an event loop with nothing\n // in it by exiting 13 with a warning about an unsettled await, where Bun\n // simply waits. A command that is running is running until it returns.\n const alive = setInterval(() => {}, 2 ** 30)\n\n const ran = (async (): Promise<number> => {\n try {\n const module = await command.load()\n\n if (typeof module.default !== 'function') {\n throw new TypeError(`src/commands/${name} has no default export. Export a function: export default async (args) => {}`)\n }\n\n // Opted in, and only then: a migration that needs the schema should not\n // fail because the queue it never touches is not up.\n if (module.register === true) {\n await options.ready()\n registered = true\n }\n\n return exitCodeOf(await (module.default as (...a: unknown[]) => unknown)(args, { signal: controller.signal }), name)\n } catch (error) {\n err(`[rsc-kit] the command \"${name}\" failed: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}\\n`)\n\n return 1\n }\n })()\n\n let code: number\n\n try {\n code = await Promise.race([ran, stopped])\n } finally {\n clearInterval(alive)\n clearTimeout(stopTimer as Parameters<typeof clearTimeout>[0])\n signals?.off('SIGTERM', onSignal)\n signals?.off('SIGINT', onSignal)\n }\n\n // What register() opened is closed here, bounded like the server's: a\n // close that hangs must not hold a release step open.\n if (registered && options.shutdown) {\n let timer: unknown\n\n await Promise.race([\n Promise.resolve()\n .then(() => options.shutdown?.())\n .catch((error) => err(`[rsc-kit] instrumentation.ts shutdown() failed: ${error instanceof Error ? error.message : String(error)}\\n`)),\n new Promise((resolve) => {\n timer = setTimeout(resolve, limit)\n unref(timer)\n }),\n ])\n\n clearTimeout(timer as Parameters<typeof clearTimeout>[0])\n }\n\n return code\n}\n"]}
package/dist/files.d.ts CHANGED
@@ -67,7 +67,9 @@ export declare function assetsReader(assets: AssetsBinding, prefix: string): (na
67
67
  * whose other Nitro app compiled with it asked why this one could not.
68
68
  * `bun build --compile --bytecode .output/server/compile.mjs` works now.
69
69
  */
70
- export declare function compileEntrySource(dirName: string): string;
70
+ export declare function compileEntrySource(dirName: string, options?: {
71
+ commands?: boolean;
72
+ }): string;
71
73
  /** The name the build writes the inline module under, beside the stored pages' directory. */
72
74
  export declare function inlineModuleName(dirName: string): string;
73
75
  /**
package/dist/files.js CHANGED
@@ -264,7 +264,9 @@ function safeUrl(relative, base) {
264
264
  * whose other Nitro app compiled with it asked why this one could not.
265
265
  * `bun build --compile --bytecode .output/server/compile.mjs` works now.
266
266
  */
267
- export function compileEntrySource(dirName) {
267
+ export function compileEntrySource(dirName, options = {}) {
268
+ if (options.commands)
269
+ return compileEntryWithCommands(dirName);
268
270
  return ('// The entry to compile into one binary:\n' +
269
271
  '// bun build --compile .output/server/compile.mjs --outfile dist/app\n' +
270
272
  '//\n' +
@@ -277,6 +279,50 @@ export function compileEntrySource(dirName) {
277
279
  `import "./${inlineModuleName(dirName)}";\n` +
278
280
  'import "./index.mjs";\n');
279
281
  }
282
+ /**
283
+ * The same entry for an app that has `src/commands`: the binary is the server
284
+ * with no arguments, and a command with one.
285
+ *
286
+ * The server's import is dynamic here, and only here. A static import starts
287
+ * the server as it evaluates, before any line of this file could look at
288
+ * `argv`, and a release step run as `./app migrate` would bind the port the
289
+ * running version holds. It is not awaited - a top-level await is what stopped
290
+ * `--bytecode` from applying to this entry - and a literal specifier is one
291
+ * Bun embeds, as it does the static one.
292
+ *
293
+ * Any argument is a command, and an unknown one is the runner's to refuse
294
+ * (exit 2) rather than a server that ignores it and starts.
295
+ */
296
+ function compileEntryWithCommands(dirName) {
297
+ return ('// The entry to compile into one binary:\n' +
298
+ '// bun build --compile .output/server/compile.mjs --outfile dist/app\n' +
299
+ '//\n' +
300
+ '// ./app starts the server. ./app <command> runs src/commands/<command>.\n' +
301
+ '//\n' +
302
+ '// A binary carries what is imported by name. The server reads its frozen pages\n' +
303
+ '// through a computed import, which a compile cannot see - imported here, they\n' +
304
+ '// travel inside the binary and are handed to the server before it starts.\n' +
305
+ '// The server itself is imported only when no command was named: a static\n' +
306
+ '// import would start it, and bind its port, before argv could be read. Not\n' +
307
+ '// awaited, since a top-level await is what stopped --bytecode from applying.\n' +
308
+ 'import { basename } from "node:path";\n' +
309
+ `import "./${inlineModuleName(dirName)}";\n` +
310
+ '\n' +
311
+ 'const failed = (what) => (error) => {\n' +
312
+ ' console.error(what, error);\n' +
313
+ ' process.exit(1);\n' +
314
+ '};\n' +
315
+ '\n' +
316
+ 'if (process.argv.length <= 2) {\n' +
317
+ ' import("./index.mjs").catch(failed("[rsc-kit] the server failed to start:"));\n' +
318
+ '} else {\n' +
319
+ ' globalThis[Symbol.for("rsc-kit.command")] = true;\n' +
320
+ '\n' +
321
+ ' import("./_ssr/rsc.mjs")\n' +
322
+ ' .then(({ runCommand }) => runCommand(process.argv.slice(2), "./" + basename(process.execPath)))\n' +
323
+ ' .then((code) => process.exit(code), failed("[rsc-kit] the app failed to load:"));\n' +
324
+ '}\n');
325
+ }
280
326
  /** The name the build writes the inline module under, beside the stored pages' directory. */
281
327
  export function inlineModuleName(dirName) {
282
328
  return `${dirName}-inline.mjs`;