| author | |
| committer | |
| log | 3fd50643311d0b0e702628f906f7cb6bbe84e1ba |
| tree | 260280c58644e3c01ed0623c0cefa531bd60e6bd |
| parent | 04df98db4c90790fb5171426894baa3bca57a935 |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 143 insertions(+), 2 deletions(-)
apps/server/src/bin.ts+8-1| ... | ... | @@ -12,6 +12,7 @@ import { connectCommand } from "./cli/connect.ts"; |
| 12 | 12 | import { pairCommand } from "./cli/pair.ts"; |
| 13 | 13 | import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; |
| 14 | 14 | import { sharedServerCommandFlags } from "./cli/config.ts"; |
| 15 | import { isEntrypoint } from "./entrypoint.ts"; | |
| 15 | 16 | import { projectCommand } from "./cli/project.ts"; |
| 16 | 17 | import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; |
| 17 | 18 | import { serviceCommand } from "./cli/service.ts"; |
| ... | ... | @@ -63,7 +64,13 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => |
| 63 | 64 | |
| 64 | 65 | export const cli = makeCli(); |
| 65 | 66 | |
| 66 | if (import.meta.main) { | |
| 67 | if ( | |
| 68 | isEntrypoint({ | |
| 69 | moduleUrl: import.meta.url, | |
| 70 | entryPath: process.argv[1], | |
| 71 | runtimeMain: import.meta.main, | |
| 72 | }) | |
| 73 | ) { | |
| 67 | 74 | Command.run(cli, { version: packageJson.version }).pipe( |
| 68 | 75 | Effect.scoped, |
| 69 | 76 | Effect.provide(CliRuntimeLayer), |
apps/server/src/entrypoint.test.ts created+89| ... | ... | @@ -0,0 +1,89 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off - entrypoint detection is a Node filesystem boundary. | |
| 2 | import * as NodeFS from "node:fs"; | |
| 3 | import * as NodeOS from "node:os"; | |
| 4 | import * as NodePath from "node:path"; | |
| 5 | import * as NodeURL from "node:url"; | |
| 6 | ||
| 7 | import { describe, expect, it } from "vite-plus/test"; | |
| 8 | ||
| 9 | import { isEntrypoint } from "./entrypoint.ts"; | |
| 10 | ||
| 11 | const makeTempDir = () => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-entrypoint-test-")); | |
| 12 | ||
| 13 | describe("isEntrypoint", () => { | |
| 14 | it("uses the runtime answer when Node provides one", () => { | |
| 15 | // Node 22.18+ and 24.2+ populate `import.meta.main`; nothing else is consulted. | |
| 16 | expect( | |
| 17 | isEntrypoint({ | |
| 18 | moduleUrl: "file:///somewhere/bin.mjs", | |
| 19 | entryPath: "/elsewhere/other.mjs", | |
| 20 | runtimeMain: true, | |
| 21 | }), | |
| 22 | ).toBe(true); | |
| 23 | expect( | |
| 24 | isEntrypoint({ | |
| 25 | moduleUrl: "file:///somewhere/bin.mjs", | |
| 26 | entryPath: "/somewhere/bin.mjs", | |
| 27 | runtimeMain: false, | |
| 28 | }), | |
| 29 | ).toBe(false); | |
| 30 | }); | |
| 31 | ||
| 32 | it("matches the entrypoint path when the runtime has no import.meta.main", () => { | |
| 33 | // Node 22.16, 22.17 and 23.11 are inside `engines.node` but leave it undefined. | |
| 34 | const dir = makeTempDir(); | |
| 35 | const entry = NodePath.join(dir, "bin.mjs"); | |
| 36 | NodeFS.writeFileSync(entry, ""); | |
| 37 | ||
| 38 | expect( | |
| 39 | isEntrypoint({ | |
| 40 | moduleUrl: NodeURL.pathToFileURL(entry).href, | |
| 41 | entryPath: entry, | |
| 42 | runtimeMain: undefined, | |
| 43 | }), | |
| 44 | ).toBe(true); | |
| 45 | }); | |
| 46 | ||
| 47 | it("matches through a symlinked entrypoint, as npm and npx install it", () => { | |
| 48 | const dir = makeTempDir(); | |
| 49 | const real = NodePath.join(dir, "bin.mjs"); | |
| 50 | const link = NodePath.join(dir, "t3"); | |
| 51 | NodeFS.writeFileSync(real, ""); | |
| 52 | NodeFS.symlinkSync(real, link); | |
| 53 | ||
| 54 | expect( | |
| 55 | isEntrypoint({ | |
| 56 | moduleUrl: NodeURL.pathToFileURL(real).href, | |
| 57 | entryPath: link, | |
| 58 | runtimeMain: undefined, | |
| 59 | }), | |
| 60 | ).toBe(true); | |
| 61 | }); | |
| 62 | ||
| 63 | it("stays false for an imported module that is not the entrypoint", () => { | |
| 64 | // This is what keeps `bin.test.ts` from launching the CLI on import. | |
| 65 | const dir = makeTempDir(); | |
| 66 | const entry = NodePath.join(dir, "bin.mjs"); | |
| 67 | const imported = NodePath.join(dir, "cli.mjs"); | |
| 68 | NodeFS.writeFileSync(entry, ""); | |
| 69 | NodeFS.writeFileSync(imported, ""); | |
| 70 | ||
| 71 | expect( | |
| 72 | isEntrypoint({ | |
| 73 | moduleUrl: NodeURL.pathToFileURL(imported).href, | |
| 74 | entryPath: entry, | |
| 75 | runtimeMain: undefined, | |
| 76 | }), | |
| 77 | ).toBe(false); | |
| 78 | }); | |
| 79 | ||
| 80 | it("stays false when there is no entrypoint argument", () => { | |
| 81 | expect( | |
| 82 | isEntrypoint({ | |
| 83 | moduleUrl: "file:///somewhere/bin.mjs", | |
| 84 | entryPath: undefined, | |
| 85 | runtimeMain: undefined, | |
| 86 | }), | |
| 87 | ).toBe(false); | |
| 88 | }); | |
| 89 | }); |
apps/server/src/entrypoint.ts created+38| ... | ... | @@ -0,0 +1,38 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | |
| 2 | // Entrypoint detection runs before any Effect runtime is built, so it stays on | |
| 3 | // Node built-ins. | |
| 4 | import * as NodeFS from "node:fs"; | |
| 5 | import * as NodeURL from "node:url"; | |
| 6 | ||
| 7 | /** | |
| 8 | * Whether the module identified by `moduleUrl` is the process entrypoint. | |
| 9 | * | |
| 10 | * `import.meta.main` answers this directly, but it only exists on Node 22.18+ | |
| 11 | * and 24.2+. This package's `engines.node` range also accepts 22.16, 22.17 and | |
| 12 | * 23.11, where it is `undefined`: an `if (import.meta.main)` guard never runs, | |
| 13 | * so the process loads every module and exits 0 without output. Fall back to | |
| 14 | * comparing the entrypoint path on those versions. | |
| 15 | */ | |
| 16 | export const isEntrypoint = (input: { | |
| 17 | readonly moduleUrl: string; | |
| 18 | readonly entryPath: string | undefined; | |
| 19 | readonly runtimeMain: boolean | undefined; | |
| 20 | }): boolean => { | |
| 21 | if (input.runtimeMain !== undefined) { | |
| 22 | return input.runtimeMain; | |
| 23 | } | |
| 24 | if (input.entryPath === undefined || input.entryPath === "") { | |
| 25 | return false; | |
| 26 | } | |
| 27 | if (input.moduleUrl === NodeURL.pathToFileURL(input.entryPath).href) { | |
| 28 | return true; | |
| 29 | } | |
| 30 | // npm and npx install the CLI as a symlink. Without `--preserve-symlinks` the | |
| 31 | // module URL is the resolved real path while `process.argv[1]` keeps the link | |
| 32 | // path, so the comparison above misses. | |
| 33 | try { | |
| 34 | return input.moduleUrl === NodeURL.pathToFileURL(NodeFS.realpathSync(input.entryPath)).href; | |
| 35 | } catch { | |
| 36 | return false; | |
| 37 | } | |
| 38 | }; |
apps/server/src/serviceLauncher.ts+8-1| ... | ... | @@ -27,6 +27,7 @@ import { |
| 27 | 27 | SERVICE_STATE_FILE, |
| 28 | 28 | SERVICE_STOP_MARKER_FILE, |
| 29 | 29 | } from "./cloud/serviceProtocol.ts"; |
| 30 | import { isEntrypoint } from "./entrypoint.ts"; | |
| 30 | 31 | |
| 31 | 32 | const HANDOFF_DELAY_MS = 2_000; |
| 32 | 33 | const PREPARED_TIMEOUT_MS = 120_000; |
| ... | ... | @@ -611,7 +612,13 @@ async function main(): Promise<void> { |
| 611 | 612 | await new Launcher(baseDir, state).run(); |
| 612 | 613 | } |
| 613 | 614 | |
| 614 | if (import.meta.main) { | |
| 615 | if ( | |
| 616 | isEntrypoint({ | |
| 617 | moduleUrl: import.meta.url, | |
| 618 | entryPath: process.argv[1], | |
| 619 | runtimeMain: import.meta.main, | |
| 620 | }) | |
| 621 | ) { | |
| 615 | 622 | main().catch((cause: unknown) => { |
| 616 | 623 | const error = cause instanceof Error ? cause : new Error(String(cause)); |
| 617 | 624 | process.stderr.write(`[service-launcher] ${error.message}\n`); |