| author | |
| committer | |
| log | 9375c779707fb95c06670db6da87441720b2d2e2 |
| tree | 3864ba37529e2e094f3118fc041a580c365a88ec |
| parent | 6f00d3881a197dd33c2cb43c6a11a9e759e56089 |
| signature | Signed by PGP key B5690EEEBB952194 |
7 files changed, 227 insertions(+), 9 deletions(-)
apps/server/src/cloud/pinnedRuntime.test.ts+48| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 1 | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 3 | import { assert, it } from "@effect/vitest"; |
| 3 | 4 | import * as Deferred from "effect/Deferred"; |
| ... | ... | @@ -94,6 +95,19 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { |
| 94 | 95 | assert.deepEqual(commands, ["tar"]); |
| 95 | 96 | assert.equal(yield* fs.readFileString(paths.sentinelPath), `${version}\n`); |
| 96 | 97 | assert.isFalse(yield* fs.exists(path.join(paths.versionDir, "t3-runtime-archive"))); |
| 98 | if ((yield* HostProcessPlatform) !== "win32") { | |
| 99 | // The old launcher must still be able to start this archive after the | |
| 100 | // first npm-to-executable update, including from the final directory. | |
| 101 | yield* fs.writeFileString(paths.entryPath, '#!/bin/sh\nprintf "%s\\n" "$@"\n'); | |
| 102 | yield* fs.chmod(paths.entryPath, 0o755); | |
| 103 | const runner = yield* ProcessRunner.make(); | |
| 104 | const legacyStart = yield* runner.run({ | |
| 105 | command: process.execPath, | |
| 106 | args: [path.join(paths.versionDir, "node_modules/t3/dist/bin.mjs"), "serve"], | |
| 107 | }); | |
| 108 | assert.equal(Number(legacyStart.code), 0, legacyStart.stderr); | |
| 109 | assert.equal(legacyStart.stdout.trim(), "serve"); | |
| 110 | } | |
| 97 | 111 | }), |
| 98 | 112 | ); |
| 99 | 113 | |
| ... | ... | @@ -209,6 +223,40 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { |
| 209 | 223 | }), |
| 210 | 224 | ); |
| 211 | 225 | |
| 226 | it.effect("backfills a cached archive without downloading or replacing it", () => | |
| 227 | Effect.gen(function* () { | |
| 228 | const fs = yield* FileSystem.FileSystem; | |
| 229 | const path = yield* Path.Path; | |
| 230 | const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-legacy-cache-" }); | |
| 231 | const cached = pinnedRuntimePaths(path, baseDir, version, "linux"); | |
| 232 | const legacyEntry = path.join(cached.versionDir, "node_modules/t3/dist/bin.mjs"); | |
| 233 | yield* fs.makeDirectory(cached.versionDir, { recursive: true }); | |
| 234 | yield* fs.writeFileString(cached.entryPath, "cached executable\n"); | |
| 235 | yield* fs.writeFileString(cached.sentinelPath, `${version}\n`); | |
| 236 | const requests: string[] = []; | |
| 237 | const commands: string[] = []; | |
| 238 | yield* ensurePinnedRuntimeInstalled({ | |
| 239 | baseDir, | |
| 240 | version, | |
| 241 | fs, | |
| 242 | path, | |
| 243 | platform: "linux", | |
| 244 | arch: "x64", | |
| 245 | httpClient: releaseHttpClient(yield* validChecksums, requests), | |
| 246 | runner: extractingRunner(fs, path, commands), | |
| 247 | validate: () => | |
| 248 | fs.exists(legacyEntry).pipe( | |
| 249 | Effect.flatMap((exists) => (exists ? Effect.void : Effect.die("missing legacy entry"))), | |
| 250 | Effect.orDie, | |
| 251 | ), | |
| 252 | }); | |
| 253 | assert.deepEqual(requests, []); | |
| 254 | assert.deepEqual(commands, []); | |
| 255 | assert.equal(yield* fs.readFileString(cached.entryPath), "cached executable\n"); | |
| 256 | assert.equal(yield* fs.readFileString(cached.sentinelPath), `${version}\n`); | |
| 257 | }), | |
| 258 | ); | |
| 259 | ||
| 212 | 260 | it.effect("preserves a completed runtime when validation fails", () => |
| 213 | 261 | Effect.gen(function* () { |
| 214 | 262 | const fs = yield* FileSystem.FileSystem; |
apps/server/src/cloud/pinnedRuntime.ts+26-1| ... | ... | @@ -8,6 +8,7 @@ import * as Option from "effect/Option"; |
| 8 | 8 | import * as Semaphore from "effect/Semaphore"; |
| 9 | 9 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; |
| 10 | 10 | |
| 11 | import { legacyCliLauncherScript } from "@t3tools/shared/legacyCliLauncher"; | |
| 11 | 12 | import { |
| 12 | 13 | CLI_RELEASE_CHECKSUMS_FILE, |
| 13 | 14 | cliArchiveFileName, |
| ... | ... | @@ -229,6 +230,24 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( |
| 229 | 230 | input: PinnedRuntimeInstallInput, |
| 230 | 231 | ) { |
| 231 | 232 | const { fs } = input; |
| 233 | // Old service launchers still use the npm entry point, including when an | |
| 234 | // archive was cached before this compatibility wrapper existed. | |
| 235 | const ensureLegacyEntry = Effect.fn("cloud.pinned_runtime.ensure_legacy_entry")( | |
| 236 | function* (versionDir: string) { | |
| 237 | const legacyDir = input.path.join(versionDir, "node_modules", "t3", "dist"); | |
| 238 | const entryPath = input.path.join(legacyDir, "bin.mjs"); | |
| 239 | if (yield* fs.exists(entryPath)) return; | |
| 240 | yield* fs.makeDirectory(legacyDir, { recursive: true }); | |
| 241 | yield* fs.writeFileString(entryPath, legacyCliLauncherScript("archive")); | |
| 242 | }, | |
| 243 | Effect.mapError( | |
| 244 | (cause) => | |
| 245 | new PinnedRuntimeInstallError({ | |
| 246 | step: "writing the legacy service entry point", | |
| 247 | cause, | |
| 248 | }), | |
| 249 | ), | |
| 250 | ); | |
| 232 | 251 | const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version, input.platform); |
| 233 | 252 | const [versionDirExists, entryExists, sentinel] = yield* Effect.all([ |
| 234 | 253 | fs.exists(paths.versionDir), |
| ... | ... | @@ -242,6 +261,7 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( |
| 242 | 261 | const alreadyPinned = |
| 243 | 262 | entryExists && Option.isSome(sentinel) && sentinel.value.trim() === input.version; |
| 244 | 263 | if (alreadyPinned) { |
| 264 | yield* ensureLegacyEntry(paths.versionDir); | |
| 245 | 265 | yield* input.validate(paths); |
| 246 | 266 | return paths; |
| 247 | 267 | } |
| ... | ... | @@ -290,6 +310,8 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( |
| 290 | 310 | return yield* Effect.gen(function* () { |
| 291 | 311 | yield* installFromArchive(input, stagingDir); |
| 292 | 312 | |
| 313 | yield* ensureLegacyEntry(stagingDir); | |
| 314 | ||
| 293 | 315 | yield* input.validate(stagingPaths); |
| 294 | 316 | yield* fs |
| 295 | 317 | .writeFileString(stagingPaths.sentinelPath, `${input.version}\n`) |
| ... | ... | @@ -328,7 +350,10 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( |
| 328 | 350 | ), |
| 329 | 351 | ), |
| 330 | 352 | ); |
| 331 | if (!published) yield* input.validate(paths); | |
| 353 | if (!published) { | |
| 354 | yield* ensureLegacyEntry(paths.versionDir); | |
| 355 | yield* input.validate(paths); | |
| 356 | } | |
| 332 | 357 | return paths; |
| 333 | 358 | }).pipe( |
| 334 | 359 | Effect.ensuring(fs.remove(stagingDir, { recursive: true, force: true }).pipe(Effect.ignore)), |
packages/shared/package.json+4| ... | ... | @@ -3,6 +3,10 @@ |
| 3 | 3 | "private": true, |
| 4 | 4 | "type": "module", |
| 5 | 5 | "exports": { |
| 6 | "./legacyCliLauncher": { | |
| 7 | "types": "./src/legacyCliLauncher.ts", | |
| 8 | "import": "./src/legacyCliLauncher.ts" | |
| 9 | }, | |
| 6 | 10 | "./delimitedPreview": { |
| 7 | 11 | "types": "./src/delimitedPreview.ts", |
| 8 | 12 | "import": "./src/delimitedPreview.ts" |
packages/shared/src/legacyCliLauncher.test.ts created+62| ... | ... | @@ -0,0 +1,62 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off - Exercises real Node IPC and process signals. | |
| 2 | import * as NodeChildProcess from "node:child_process"; | |
| 3 | import * as NodeEvents from "node:events"; | |
| 4 | import * as NodeFSP from "node:fs/promises"; | |
| 5 | import * as NodeOS from "node:os"; | |
| 6 | import * as NodePath from "node:path"; | |
| 7 | import { expect, it } from "vite-plus/test"; | |
| 8 | ||
| 9 | import { legacyCliLauncherScript } from "./legacyCliLauncher.ts"; | |
| 10 | ||
| 11 | // oxlint-disable-next-line t3code/no-global-process-runtime -- This test launches a real host executable. | |
| 12 | const hostPlatform = NodeOS.platform(); | |
| 13 | // oxlint-disable-next-line t3code/no-global-process-runtime -- Match the real executable used by the subprocess. | |
| 14 | const hostArch = NodeOS.arch(); | |
| 15 | ||
| 16 | // The fixture executable uses a POSIX shebang. The wrapper itself also runs on Windows. | |
| 17 | it.skipIf(hostPlatform === "win32").each(["npm", "archive"] as const)( | |
| 18 | "keeps %s service IPC, arguments, and termination connected", | |
| 19 | async (distribution) => { | |
| 20 | const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-legacy-launcher-")); | |
| 21 | const entry = NodePath.join(root, "node_modules/t3/dist/bin.mjs"); | |
| 22 | const executable = | |
| 23 | distribution === "archive" | |
| 24 | ? NodePath.join(root, "t3") | |
| 25 | : NodePath.join(root, `node_modules/@t3code/t3-${hostPlatform}-${hostArch}/t3`); | |
| 26 | await NodeFSP.mkdir(NodePath.dirname(entry), { recursive: true }); | |
| 27 | await NodeFSP.mkdir(NodePath.dirname(executable), { recursive: true }); | |
| 28 | await NodeFSP.writeFile( | |
| 29 | NodePath.join(NodePath.dirname(executable), "package.json"), | |
| 30 | '{"type":"commonjs"}', | |
| 31 | ); | |
| 32 | await NodeFSP.writeFile(entry, legacyCliLauncherScript(distribution)); | |
| 33 | await NodeFSP.writeFile( | |
| 34 | executable, | |
| 35 | `#!${process.execPath} | |
| 36 | process.on("SIGTERM", () => process.exit(23)); | |
| 37 | process.on("message", message => process.send({ reply: message })); | |
| 38 | process.send({ args: process.argv.slice(2) }); | |
| 39 | `, | |
| 40 | ); | |
| 41 | await NodeFSP.chmod(executable, 0o755); | |
| 42 | const child = NodeChildProcess.fork(entry, ["serve", "a path with spaces"], { silent: true }); | |
| 43 | try { | |
| 44 | expect((await NodeEvents.EventEmitter.once(child, "message"))[0]).toEqual({ | |
| 45 | args: ["serve", "a path with spaces"], | |
| 46 | }); | |
| 47 | const reply = NodeEvents.EventEmitter.once(child, "message"); | |
| 48 | child.send({ type: "trial-accepted" }); | |
| 49 | expect((await reply)[0]).toEqual({ reply: { type: "trial-accepted" } }); | |
| 50 | const exit = NodeEvents.EventEmitter.once(child, "exit"); | |
| 51 | child.kill("SIGTERM"); | |
| 52 | expect(await exit).toEqual([23, null]); | |
| 53 | } finally { | |
| 54 | if (child.exitCode === null && child.signalCode === null) { | |
| 55 | const exit = NodeEvents.EventEmitter.once(child, "exit"); | |
| 56 | child.kill("SIGTERM"); | |
| 57 | await exit; | |
| 58 | } | |
| 59 | await NodeFSP.rm(root, { recursive: true, force: true }); | |
| 60 | } | |
| 61 | }, | |
| 62 | ); |
packages/shared/src/legacyCliLauncher.ts created+36| ... | ... | @@ -0,0 +1,36 @@ |
| 1 | /** Node entry point for service launchers installed before executable releases. */ | |
| 2 | export function legacyCliLauncherScript(distribution: "npm" | "archive"): string { | |
| 3 | const executable = | |
| 4 | distribution === "npm" | |
| 5 | ? 'join(dirname(require.resolve("@t3code/t3-" + process.platform + "-" + process.arch + "/package.json")), executableName)' | |
| 6 | : 'resolve(dirname(fileURLToPath(import.meta.url)), "../../..", executableName)'; | |
| 7 | return `import { spawn } from "node:child_process"; | |
| 8 | import { constants } from "node:os"; | |
| 9 | import { dirname, join, resolve } from "node:path"; | |
| 10 | import { fileURLToPath } from "node:url"; | |
| 11 | import { createRequire } from "node:module"; | |
| 12 | const require = createRequire(import.meta.url); | |
| 13 | const executableName = process.platform === "win32" ? "t3.exe" : "t3"; | |
| 14 | const executable = ${executable}; | |
| 15 | const ipc = process.send !== undefined; | |
| 16 | const child = spawn(executable, process.argv.slice(2), { | |
| 17 | stdio: ipc ? ["inherit", "inherit", "inherit", "ipc"] : "inherit", | |
| 18 | }); | |
| 19 | const fail = (error) => { | |
| 20 | if (!error) return; | |
| 21 | process.stderr.write("t3: " + error.message + "\\n"); | |
| 22 | child.kill("SIGTERM"); | |
| 23 | process.exitCode = 1; | |
| 24 | }; | |
| 25 | if (ipc) { | |
| 26 | process.on("message", (message) => { if (child.connected) child.send(message, fail); }); | |
| 27 | child.on("message", (message) => { if (process.connected) process.send(message, fail); }); | |
| 28 | process.on("disconnect", () => { if (child.connected) child.disconnect(); }); | |
| 29 | } | |
| 30 | for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) { | |
| 31 | process.on(signal, () => child.kill(signal)); | |
| 32 | } | |
| 33 | child.on("error", (error) => { fail(error); process.exit(1); }); | |
| 34 | child.on("exit", (code, signal) => process.exit(code ?? 128 + (constants.signals[signal] || 1))); | |
| 35 | `; | |
| 36 | } |
scripts/build-npm-platform-packages.test.ts+45-7| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 1 | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 3 | import { assert, it } from "@effect/vitest"; |
| 3 | 4 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -151,7 +152,7 @@ it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => { |
| 151 | 152 | assert.equal(launcherManifest.name, "t3"); |
| 152 | 153 | assert.equal(launcherManifest.version, VERSION); |
| 153 | 154 | assert.deepStrictEqual(launcherManifest.bin, { t3: "./bin/t3.js" }); |
| 154 | assert.deepStrictEqual(launcherManifest.files, ["bin"]); | |
| 155 | assert.deepStrictEqual(launcherManifest.files, ["bin", "dist"]); | |
| 155 | 156 | assert.deepStrictEqual(launcherManifest.optionalDependencies, { |
| 156 | 157 | "@t3code/t3-darwin-arm64": VERSION, |
| 157 | 158 | "@t3code/t3-linux-x64": VERSION, |
| ... | ... | @@ -182,13 +183,50 @@ it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => { |
| 182 | 183 | |
| 183 | 184 | // NODE_PATH stands in for node_modules: require.resolve finds the |
| 184 | 185 | // platform package there exactly as it would after `npm install`. |
| 186 | const hostPlatform = yield* HostProcessPlatform; | |
| 187 | const hostArch = yield* HostProcessArchitecture; | |
| 185 | 188 | const env = { ...process.env, NODE_PATH: fixture.outputDir } as Record<string, string>; |
| 186 | const passthrough = yield* run(process.execPath, ["bin/t3.js", "serve", "--port", "1234"], { | |
| 187 | cwd: launcherDir, | |
| 188 | env, | |
| 189 | }); | |
| 190 | assert.equal(passthrough.stdout.trim(), "stub linux-x64 serve --port 1234"); | |
| 191 | assert.equal(passthrough.exitCode, 7); | |
| 189 | if (KEYS.some((key) => key === `${hostPlatform}-${hostArch}`)) { | |
| 190 | const passthrough = yield* run(process.execPath, ["bin/t3.js", "serve", "--port", "1234"], { | |
| 191 | cwd: launcherDir, | |
| 192 | env, | |
| 193 | }); | |
| 194 | assert.equal( | |
| 195 | passthrough.stdout.trim(), | |
| 196 | `stub ${hostPlatform}-${hostArch} serve --port 1234`, | |
| 197 | ); | |
| 198 | assert.equal(passthrough.exitCode, 7); | |
| 199 | ||
| 200 | // Run the entry point used by already-installed service updaters from | |
| 201 | // the published tarball, including their preflight arguments. | |
| 202 | const installedLauncher = path.join(fixture.root, "installed-launcher"); | |
| 203 | yield* fs.makeDirectory(installedLauncher); | |
| 204 | const unpack = yield* run( | |
| 205 | "tar", | |
| 206 | ["-xf", path.join(fixture.outputDir, "t3.tgz"), "-C", installedLauncher], | |
| 207 | { | |
| 208 | cwd: fixture.root, | |
| 209 | }, | |
| 210 | ); | |
| 211 | assert.equal(unpack.exitCode, 0, unpack.stderr); | |
| 212 | const legacy = yield* run( | |
| 213 | process.execPath, | |
| 214 | [ | |
| 215 | "dist/bin.mjs", | |
| 216 | "__service-preflight", | |
| 217 | "--database-path", | |
| 218 | "a database.sqlite", | |
| 219 | "--launcher-protocol", | |
| 220 | "2", | |
| 221 | ], | |
| 222 | { cwd: path.join(installedLauncher, "package"), env }, | |
| 223 | ); | |
| 224 | assert.equal( | |
| 225 | legacy.stdout.trim(), | |
| 226 | `stub ${hostPlatform}-${hostArch} __service-preflight --database-path a database.sqlite --launcher-protocol 2`, | |
| 227 | ); | |
| 228 | assert.equal(legacy.exitCode, 7); | |
| 229 | } | |
| 192 | 230 | |
| 193 | 231 | const unsupported = yield* run(process.execPath, ["bin/t3.js", "--version"], { |
| 194 | 232 | cwd: launcherDir, |
scripts/build-npm-platform-packages.ts+6-1| ... | ... | @@ -19,6 +19,7 @@ |
| 19 | 19 | * bundleDependencies needs an arborist tree these flattened installs are |
| 20 | 20 | * not), whereas `npm publish <tarball>` uploads the bytes as given. |
| 21 | 21 | */ |
| 22 | import { legacyCliLauncherScript } from "@t3tools/shared/legacyCliLauncher"; | |
| 22 | 23 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; |
| 23 | 24 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 24 | 25 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -137,7 +138,7 @@ export function npmLauncherPackageManifest( |
| 137 | 138 | license: serverPackageJson.license, |
| 138 | 139 | repository: serverPackageJson.repository, |
| 139 | 140 | bin: { t3: "./bin/t3.js" }, |
| 140 | files: ["bin"], | |
| 141 | files: ["bin", "dist"], | |
| 141 | 142 | optionalDependencies: Object.fromEntries( |
| 142 | 143 | platformKeys.map((key) => [npmPlatformPackageName(key), version]), |
| 143 | 144 | ), |
| ... | ... | @@ -342,6 +343,10 @@ const stageLauncherPackage = Effect.fn("stageLauncherPackage")(function* (input: |
| 342 | 343 | const launcherScript = path.join(stageDir, "bin/t3.js"); |
| 343 | 344 | yield* fs.writeFileString(launcherScript, NPM_LAUNCHER_SCRIPT); |
| 344 | 345 | yield* fs.chmod(launcherScript, 0o755); |
| 346 | // Older service updaters and launchers run this exact path with Node. | |
| 347 | // Keep it in the package so they can preflight and start the new executable. | |
| 348 | yield* fs.makeDirectory(path.join(stageDir, "dist")); | |
| 349 | yield* fs.writeFileString(path.join(stageDir, "dist/bin.mjs"), legacyCliLauncherScript("npm")); | |
| 345 | 350 | const readme = yield* path.fromFileUrl(new URL("../apps/server/README.md", import.meta.url)); |
| 346 | 351 | if (yield* fs.exists(readme)) { |
| 347 | 352 | yield* fs.copyFile(readme, path.join(stageDir, "README.md")); |