From 1d88634b43521803bb340fc98b51a88d363b2f19 Mon Sep 17 00:00:00 2001 From: David Sherret Date: Sun, 4 Feb 2024 16:42:56 -0500 Subject: [PATCH] feat: npm distribution for Node.js (#237) --- .github/workflows/ci.yml | 16 +++ .gitignore | 1 + deno.json | 2 + mod.test.ts | 105 +++++++++------- scripts/build_npm.ts | 150 ++++++++++++++++++++++ src/command.ts | 10 +- src/commands/cat.ts | 5 +- src/commands/cd.ts | 4 +- src/commands/cp_mv.ts | 6 +- src/commands/echo.ts | 3 +- src/commands/exit.ts | 3 +- src/commands/mkdir.ts | 4 +- src/commands/printenv.ts | 3 +- src/commands/pwd.ts | 3 +- src/commands/rm.ts | 4 +- src/commands/sleep.ts | 3 +- src/commands/test.ts | 4 +- src/commands/touch.ts | 3 +- src/commands/unset.ts | 3 +- src/common.ts | 35 ++++++ src/console/progress/interval.ts | 2 +- src/console/utils.ts | 6 +- src/deps.test.ts | 16 ++- src/deps.ts | 2 +- src/lib/mod.ts | 3 +- src/path.test.ts | 37 ++++-- src/path.ts | 5 + src/pipes.ts | 42 ++++++- src/request.test.ts | 61 +++++---- src/request.ts | 7 +- src/runtimes/process.common.ts | 19 +++ src/runtimes/process.deno.ts | 23 ++++ src/runtimes/process.node.ts | 58 +++++++++ src/shell.ts | 79 ++++++------ src/test/server.common.ts | 10 ++ src/test/server.deno.ts | 18 +++ src/test/server.node.ts | 50 ++++++++ src/vendor/outdent.ts | 210 +++++++++++++++++++++++++++++++ 38 files changed, 858 insertions(+), 157 deletions(-) create mode 100644 scripts/build_npm.ts create mode 100644 src/runtimes/process.common.ts create mode 100644 src/runtimes/process.deno.ts create mode 100644 src/runtimes/process.node.ts create mode 100644 src/test/server.common.ts create mode 100644 src/test/server.deno.ts create mode 100644 src/test/server.node.ts create mode 100644 src/vendor/outdent.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f05a121..0f7b1bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,3 +33,19 @@ jobs: - name: test run: deno test -A + + - name: Get tag version + if: startsWith(github.ref, 'refs/tags/') + id: get_tag_version + run: echo TAG_VERSION=${GITHUB_REF/refs\/tags\//} >> $GITHUB_OUTPUT + - uses: actions/setup-node@v3 + with: + node-version: '20.x' + registry-url: 'https://registry.npmjs.org' + - name: npm build + run: deno run -A ./scripts/build_npm.ts ${{steps.get_tag_version.outputs.TAG_VERSION}} + - name: npm publish + if: startsWith(github.ref, 'refs/tags/') + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: cd npm && npm publish diff --git a/.gitignore b/.gitignore index e1f6b82..7838b1f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target .vscode deno.lock +npm diff --git a/deno.json b/deno.json index 4ce5e02..9c30823 100644 --- a/deno.json +++ b/deno.json @@ -1,4 +1,5 @@ { + "lock": false, "tasks": { "test": "deno test -A", "wasmbuild": "deno run -A https://deno.land/x/wasmbuild@0.15.6/main.ts --sync --out ./src/lib" @@ -17,6 +18,7 @@ } }, "exclude": [ + "npm/", "target/" ] } diff --git a/mod.test.ts b/mod.test.ts index dc2a6ed..fadb5df 100644 --- a/mod.test.ts +++ b/mod.test.ts @@ -7,10 +7,18 @@ import { assertRejects, assertStringIncludes, assertThrows, + isNode, toWritableStream, + usingTempDir, withTempDir, } from "./src/deps.test.ts"; import { Buffer, colors, path } from "./src/deps.ts"; +import { setNotTtyForTesting } from "./src/console/utils.ts"; + +// Deno will not be a tty because it captures the pipes, but Node +// will be, so manually say that we're not a tty for testing so +// the tests behave somewhat similarly in Node.js +setNotTtyForTesting(); Deno.test("should get stdout when piped", async () => { const output = await $`echo 5`.stdout("piped"); @@ -76,20 +84,26 @@ Deno.test("should not get stderr when null", async () => { }); Deno.test("should capture stderr when piped", async () => { - const output = await $`deno eval 'console.error(5);'`.stderr("piped"); + const output = await $`deno eval 'console.error(5);'` + .env("NO_COLOR", "1") // deno uses colors when only stderr is piped + .stderr("piped"); assertEquals(output.code, 0); assertEquals(output.stderr, "5\n"); }); Deno.test("should capture stderr when inherited and piped", async () => { - const output = await $`deno eval -q 'console.error(5);'`.stderr("inheritPiped"); + const output = await $`deno eval -q 'console.error(5);'` + .env("NO_COLOR", "1") + .stderr("inheritPiped"); assertEquals(output.code, 0); assertEquals(output.stderr, "5\n"); }); Deno.test("should not get stderr when set to writer", async () => { const buffer = new Buffer(); - const output = await $`deno eval 'console.error(5); console.log(1);'`.stderr(buffer); + const output = await $`deno eval 'console.error(5); console.log(1);'` + .env("NO_COLOR", "1") + .stderr(buffer); assertEquals(output.code, 0); assertEquals(new TextDecoder().decode(buffer.bytes()), "5\n"); assertThrows( @@ -713,15 +727,13 @@ Deno.test("unset with -f should error", async () => { }); Deno.test("cwd should be resolved based on cwd at time of method call and not execution", async () => { - const previousCwd = Deno.cwd(); - try { + await withTempDir(async (tempDir) => { + await tempDir.join("./src/rs_lib").ensureDir(); const command = $`echo $PWD`.cwd("./src"); Deno.chdir("./src/rs_lib"); const result = await command.text(); assertEquals(result.slice(-3), "src"); - } finally { - Deno.chdir(previousCwd); - } + }); }); Deno.test("should handle the PWD variable", async () => { @@ -738,7 +750,7 @@ Deno.test("should handle the PWD variable", async () => { }); Deno.test("timeout", async () => { - const command = $`deno eval 'await new Promise(resolve => setTimeout(resolve, 1_000));'` + const command = $`deno eval 'await new Promise(resolve => setTimeout(resolve, 10_000));'` .timeout(200); await assertRejects(async () => await command, Error, "Timed out with exit code: 124"); @@ -764,46 +776,42 @@ Deno.test("abort", async () => { assertEquals(result.code, 124); }); -Deno.test("piping to stdin", async () => { - // Reader - { +Deno.test("piping to stdin", async (t) => { + await t.step("reader", async () => { const bytes = new TextEncoder().encode("test\n"); const result = await $`deno eval "const b = new Uint8Array(4); await Deno.stdin.read(b); await Deno.stdout.write(b);"` .stdin(new Buffer(bytes)) .text(); assertEquals(result, "test"); - } + }); - // string - { + await t.step("string", async () => { const command = $`deno eval "const b = new Uint8Array(4); await Deno.stdin.read(b); await Deno.stdout.write(b);"` .stdinText("test\n"); // should support calling multiple times assertEquals(await command.text(), "test"); assertEquals(await command.text(), "test"); - } + }); - // Uint8Array - { + await t.step("Uint8Array", async () => { const result = await $`deno eval "const b = new Uint8Array(4); await Deno.stdin.read(b); await Deno.stdout.write(b);"` .stdin(new TextEncoder().encode("test\n")) .text(); assertEquals(result, "test"); - } + }); - // readable stream - { + await t.step("readable stream", async () => { const child = $`echo 1 && echo 2`.stdout("piped").spawn(); const result = await $`deno eval 'await Deno.stdin.readable.pipeTo(Deno.stdout.writable);'` .stdin(child.stdout()) .text(); assertEquals(result, "1\n2"); - } + }); - // PathRef - await withTempDir(async (tempDir) => { + await t.step("PathRef", async () => { + await using tempDir = usingTempDir(); const tempFile = tempDir.join("temp_file.txt"); const fileText = "1 testing this out\n".repeat(1_000); tempFile.writeTextSync(fileText); @@ -811,17 +819,15 @@ Deno.test("piping to stdin", async () => { assertEquals(output, fileText.trim()); }); - // command via stdin - { + await t.step("command via stdin", async () => { const child = $`echo 1 && echo 2`; const result = await $`deno eval 'await Deno.stdin.readable.pipeTo(Deno.stdout.writable);'` .stdin(child) .text(); assertEquals(result, "1\n2"); - } + }); - // command that exits via stdin - { + await t.step("command that exits via stdin", async () => { const child = $`echo 1 && echo 2 && exit 1`; const result = await $`deno eval 'await Deno.stdin.readable.pipeTo(Deno.stdout.writable);'` .stdin(child) @@ -829,7 +835,7 @@ Deno.test("piping to stdin", async () => { .noThrow(); assertEquals(result.code, 1); assertEquals(result.stderr, "stdin pipe broken. Exited with code: 1\n"); - } + }); }); Deno.test("pipe", async () => { @@ -902,7 +908,9 @@ Deno.test("piping stdout/stderr to a file", async () => { await withTempDir(async (tempDir) => { const tempFile = tempDir.join("temp_file.txt"); - await $`deno eval 'console.error(1);'`.stderr(tempFile); + await $`deno eval 'console.error(1);'` + .env("NO_COLOR", "1") + .stderr(tempFile); assertEquals(tempFile.readTextSync(), "1\n"); }); @@ -984,7 +992,10 @@ Deno.test("streaming api", async () => { // stderr { - const child = $`deno eval -q 'console.error(1); console.error(2)'`.stderr("piped").spawn(); + const child = $`deno eval -q 'console.error(1); console.error(2)'` + .env("NO_COLOR", "1") + .stderr("piped") + .spawn(); const text = await $`deno eval 'await Deno.stdin.readable.pipeTo(Deno.stdout.writable);'` .stdin(child.stderr()) .text(); @@ -1193,16 +1204,17 @@ Deno.test("output redirects", async () => { assertEquals(tempDir.join("sub_dir/temp_file.txt").readTextSync(), "3\n"); // stderr - await $`deno eval 'console.log(2); console.error(5);' 2> ./temp_file.txt`; + await $`deno eval 'console.log(2); console.error(5);' 2> ./temp_file.txt`.env("NO_COLOR", "1"); assertEquals(tempFile.readTextSync(), "5\n"); // append - await $`deno eval 'console.error(1);' 2> ./temp_file.txt && echo 2 >> ./temp_file.txt && echo 3 >> ./temp_file.txt`; + await $`deno eval 'console.error(1);' 2> ./temp_file.txt && echo 2 >> ./temp_file.txt && echo 3 >> ./temp_file.txt` + .env("NO_COLOR", "1"); assertEquals(tempFile.readTextSync(), "1\n2\n3\n"); // /dev/null assertEquals(await $`echo 1 > /dev/null`.text(), ""); - assertEquals(await $`deno eval 'console.error(1); console.log(2)' 2> /dev/null`.text(), "2"); + assertEquals(await $`deno eval 'console.error(1); console.log(2)' 2> /dev/null`.env("NO_COLOR", "1").text(), "2"); // not supported fd { @@ -1597,7 +1609,9 @@ Deno.test("test remove", async () => { { const error = await $`rm ${nonEmptyDir}`.noThrow().stderr("piped").spawn() .then((r) => r.stderr); - const expectedText = Deno.build.os === "linux" || Deno.build.os === "darwin" + const expectedText = isNode + ? "rm: directory not empty, rmdir" + : Deno.build.os === "linux" || Deno.build.os === "darwin" ? "rm: Directory not empty" : "rm: The directory is not empty"; assertEquals(error.substring(0, expectedText.length), expectedText); @@ -1611,7 +1625,9 @@ Deno.test("test remove", async () => { { const [error, code] = await $`rm ${notExists}`.noThrow().stderr("piped").spawn() .then((r) => [r.stderr, r.code] as const); - const expectedText = Deno.build.os === "linux" || Deno.build.os === "darwin" + const expectedText = isNode + ? "rm: no such file or directory, lstat" + : Deno.build.os === "linux" || Deno.build.os === "darwin" ? "rm: No such file or directory" : "rm: The system cannot find the file specified"; assertEquals(error.substring(0, expectedText.length), expectedText); @@ -1645,7 +1661,9 @@ Deno.test("test mkdir", async () => { .then( (r) => r.stderr, ); - const expectedError = Deno.build.os === "windows" + const expectedError = isNode + ? "mkdir: no such file or directory, mkdir" + : Deno.build.os === "windows" ? "mkdir: The system cannot find the path specified." : "mkdir: No such file or directory"; assertEquals(error.slice(0, expectedError.length), expectedError); @@ -1766,11 +1784,11 @@ Deno.test("pwd: pwd", async () => { assertEquals(await $`pwd`.text(), Deno.cwd()); }); -Deno.test("progress", async () => { +Deno.test("progress", () => { const logs: string[] = []; $.setInfoLogger((...data) => logs.push(data.join(" "))); const pb = $.progress("Downloading Test"); - await pb.forceRender(); // should not throw; + pb.forceRender(); // should not throw; assertEquals(logs, [ "Downloading Test", ]); @@ -1862,10 +1880,13 @@ Deno.test("cd", () => { try { $.cd("./src"); assert(Deno.cwd().endsWith("src")); - $.cd(import.meta); + // todo: this originally passed in import.meta, but that + // didn't work in the node cjs tests, so now it's doing this + // thing that doesn't really test it + $.cd($.path(new URL(import.meta.url)).parentOrThrow()); $.cd("./src"); assert(Deno.cwd().endsWith("src")); - const path = $.path(import.meta).parentOrThrow(); + const path = $.path(import.meta.url).parentOrThrow(); $.cd(path); $.cd("./src"); assert(Deno.cwd().endsWith("src")); diff --git a/scripts/build_npm.ts b/scripts/build_npm.ts new file mode 100644 index 0000000..c476afb --- /dev/null +++ b/scripts/build_npm.ts @@ -0,0 +1,150 @@ +import { + build, + emptyDir, +} from "https://raw.githubusercontent.com/denoland/dnt/2537df1c38851088bf1f504ae89dd7f037219f8b/mod.ts"; +import $ from "../mod.ts"; + +$.cd($.path(import.meta).parentOrThrow().parentOrThrow()); + +await emptyDir("./npm"); + +await build({ + entryPoints: ["./mod.ts"], + outDir: "./npm", + shims: { + deno: true, + custom: [{ + package: { + name: "node:stream/web", + }, + globalNames: [ + "ReadableStream", + "WritableStream", + "TextDecoderStream", + "TransformStream", + { + name: "ReadableStreamDefaultReader", + typeOnly: true, + }, + { + name: "WritableStreamDefaultWriter", + typeOnly: true, + }, + { + name: "PipeOptions", + exportName: "StreamPipeOptions", + typeOnly: true, + }, + { + name: "QueuingStrategy", + typeOnly: true, + }, + ], + }, { + package: { + name: "undici-types", + }, + globalNames: [{ + name: "BodyInit", + typeOnly: true, + }, { + name: "RequestCache", + typeOnly: true, + }, { + name: "RequestMode", + typeOnly: true, + }, { + name: "RequestRedirect", + typeOnly: true, + }, { + name: "ReferrerPolicy", + typeOnly: true, + }], + }], + }, + compilerOptions: { + target: "ES2022", + }, + mappings: { + "./src/runtimes/process.deno.ts": "./src/runtimes/process.node.ts", + "./src/test/server.deno.ts": "./src/test/server.node.ts", + }, + package: { + name: "dax-sh", + version: Deno.args[0], + description: "Cross platform shell tools inspired by zx.", + license: "MIT", + repository: { + type: "git", + url: "git+https://github.com/dsherret/dax.git", + }, + bugs: { + url: "https://github.com/dsherret/dax/issues", + }, + dependencies: { + "@deno/shim-deno": "~0.19.0", + "undici-types": "^5.26", + }, + devDependencies: { + "@types/node": "^20.11.9", + }, + }, + postBuild() { + Deno.copyFileSync("LICENSE", "npm/LICENSE"); + Deno.copyFileSync("README.md", "npm/README.md"); + }, +}); + +// create bundles to improve startup time +await $`deno run -A npm:esbuild@0.20.0 --bundle --platform=node --packages=external --outfile=npm/bundle.cjs npm/script/mod.js`; +await $`deno run -A npm:esbuild@0.20.0 --bundle --platform=node --packages=external --format=esm --outfile=npm/bundle.mjs npm/esm/mod.js`; + +const npmPath = $.path("npm"); + +// remove all the javascript files in the script folder +for (const entry of npmPath.join("script").walkSync({ exts: ["js"] })) { + entry.path.removeSync(); +} +for (const entry of npmPath.join("esm").walkSync({ exts: ["js"] })) { + entry.path.removeSync(); +} + +// move the bundle to the script folder +npmPath.join("bundle.cjs").renameSync(npmPath.join("script/mod.js")); +npmPath.join("bundle.mjs").renameSync(npmPath.join("esm/mod.js")); + +// basic mjs test +{ + const tempFile = $.path("temp_file.mjs"); + tempFile.writeText( + `import $ from "./npm/esm/mod.js"; + +await $\`echo 1\`; +`, + ); + try { + // just ensure it doesn't throw + await $`node ${tempFile}`.quiet(); + } finally { + tempFile.removeSync(); + } +} + +// basic cjs test +{ + const tempFile = $.path("temp_file.cjs"); + tempFile.writeText( + `const $ = require("./npm/script/mod.js").$; + +$\`echo 1\`.then(() => { +console.log("DONE"); +}); +`, + ); + try { + // just ensure it doesn't throw + await $`node ${tempFile}`.quiet(); + } finally { + tempFile.removeSync(); + } +} diff --git a/src/command.ts b/src/command.ts index 9e534c0..65c4818 100644 --- a/src/command.ts +++ b/src/command.ts @@ -13,7 +13,7 @@ import { sleepCommand } from "./commands/sleep.ts"; import { testCommand } from "./commands/test.ts"; import { touchCommand } from "./commands/touch.ts"; import { unsetCommand } from "./commands/unset.ts"; -import { Box, delayToMs, LoggerTreeBox } from "./common.ts"; +import { Box, delayToMs, errorToString, LoggerTreeBox } from "./common.ts"; import { Delay } from "./common.ts"; import { Buffer, colors, path, readerFromStreamReader, writerFromStreamWriter } from "./deps.ts"; import { @@ -1205,7 +1205,7 @@ function sendSignalToState(state: KillSignalState, signal: Deno.Signal) { } } -function getSignalAbortCode(signal: Deno.Signal) { +export function getSignalAbortCode(signal: Deno.Signal) { // consider the command aborted if the signal is any one of these switch (signal) { case "SIGTERM": @@ -1304,7 +1304,7 @@ function templateInner( } catch (err) { throw new Error( `Error getting ReadableStream from function at ` + - `expression ${i + 1}/${exprsCount}. ${err?.message ?? err}`, + `expression ${i + 1}/${exprsCount}. ${errorToString(err)}`, ); } }); @@ -1354,7 +1354,7 @@ function templateInner( } catch (err) { throw new Error( `Error getting WritableStream from function at ` + - `expression ${i + 1}/${exprsCount}. ${err?.message ?? err}`, + `expression ${i + 1}/${exprsCount}. ${errorToString(err)}`, ); } }); @@ -1372,7 +1372,7 @@ function templateInner( const startMessage = exprsCount === 1 ? "Failed resolving expression in command." : `Failed resolving expression ${i + 1}/${exprsCount} in command.`; - throw new Error(`${startMessage} ${err?.message ?? err}`); + throw new Error(`${startMessage} ${errorToString(err)}`); } } } diff --git a/src/commands/cat.ts b/src/commands/cat.ts index 85ec2fc..79ce3b1 100644 --- a/src/commands/cat.ts +++ b/src/commands/cat.ts @@ -2,6 +2,7 @@ import { CommandContext } from "../command_handler.ts"; import { ExecuteResult } from "../result.ts"; import { bailUnsupported, parseArgKinds } from "./args.ts"; import { path as pathUtils } from "../deps.ts"; +import { errorToString } from "../common.ts"; interface CatFlags { paths: string[]; @@ -14,7 +15,7 @@ export async function catCommand( const code = await executeCat(context); return { code }; } catch (err) { - return context.error(`cat: ${err?.message ?? err}`); + return context.error(`cat: ${errorToString(err)}`); } } @@ -60,7 +61,7 @@ async function executeCat(context: CommandContext) { } exitCode = context.signal.abortedExitCode ?? 0; } catch (err) { - const maybePromise = context.stderr.writeLine(`cat ${path}: ${err?.message ?? err}`); + const maybePromise = context.stderr.writeLine(`cat ${path}: ${errorToString(err)}`); if (maybePromise instanceof Promise) { await maybePromise; } diff --git a/src/commands/cd.ts b/src/commands/cd.ts index 02badf9..1a8d6ca 100644 --- a/src/commands/cd.ts +++ b/src/commands/cd.ts @@ -1,5 +1,5 @@ import { CommandContext } from "../command_handler.ts"; -import { resolvePath } from "../common.ts"; +import { errorToString, resolvePath } from "../common.ts"; import { ExecuteResult } from "../result.ts"; export async function cdCommand(context: CommandContext): Promise { @@ -13,7 +13,7 @@ export async function cdCommand(context: CommandContext): Promise }], }; } catch (err) { - return context.error(`cd: ${err?.message ?? err}`); + return context.error(`cd: ${errorToString(err)}`); } } diff --git a/src/commands/cp_mv.ts b/src/commands/cp_mv.ts index a6d9218..96b2462 100644 --- a/src/commands/cp_mv.ts +++ b/src/commands/cp_mv.ts @@ -1,7 +1,7 @@ import { CommandContext } from "../command_handler.ts"; import { ExecuteResult } from "../result.ts"; import { bailUnsupported, parseArgKinds } from "./args.ts"; -import { resolvePath, safeLstat } from "../common.ts"; +import { errorToString, resolvePath, safeLstat } from "../common.ts"; import { path } from "../deps.ts"; export async function cpCommand( @@ -11,7 +11,7 @@ export async function cpCommand( await executeCp(context.cwd, context.args); return { code: 0 }; } catch (err) { - return context.error(`cp: ${err?.message ?? err}`); + return context.error(`cp: ${errorToString(err)}`); } } @@ -100,7 +100,7 @@ export async function mvCommand( await executeMove(context.cwd, context.args); return { code: 0 }; } catch (err) { - return context.error(`mv: ${err?.message ?? err}`); + return context.error(`mv: ${errorToString(err)}`); } } diff --git a/src/commands/echo.ts b/src/commands/echo.ts index f7b2165..76a06f8 100644 --- a/src/commands/echo.ts +++ b/src/commands/echo.ts @@ -1,4 +1,5 @@ import { CommandContext } from "../command_handler.ts"; +import { errorToString } from "../common.ts"; import { ExecuteResult } from "../result.ts"; export function echoCommand(context: CommandContext): ExecuteResult | Promise { @@ -15,5 +16,5 @@ export function echoCommand(context: CommandContext): ExecuteResult | Promise { @@ -9,7 +10,7 @@ export function exitCommand(context: CommandContext): ExecuteResult | Promise { @@ -25,7 +26,7 @@ export function printEnvCommand(context: CommandContext): ExecuteResult | Promis } function handleError(context: CommandContext, err: any): ExecuteResult | Promise { - return context.error(`printenv: ${err?.message ?? err}`); + return context.error(`printenv: ${errorToString(err)}`); } /** diff --git a/src/commands/pwd.ts b/src/commands/pwd.ts index 8b5195e..ba2c784 100644 --- a/src/commands/pwd.ts +++ b/src/commands/pwd.ts @@ -1,4 +1,5 @@ import { CommandContext } from "../command_handler.ts"; +import { errorToString } from "../common.ts"; import { path } from "../deps.ts"; import { ExecuteResult } from "../result.ts"; import { bailUnsupported, parseArgKinds } from "./args.ts"; @@ -19,7 +20,7 @@ export function pwdCommand(context: CommandContext): ExecuteResult | Promise { @@ -26,7 +27,7 @@ export async function sleepCommand(context: CommandContext): Promise { @@ -8,7 +9,7 @@ export function unsetCommand(context: CommandContext): ExecuteResult | Promise ({ kind: "unsetvar", name })), }; } catch (err) { - return context.error(`unset: ${err?.message ?? err}`); + return context.error(`unset: ${errorToString(err)}`); } } diff --git a/src/common.ts b/src/common.ts index b9167c5..a1809ea 100644 --- a/src/common.ts +++ b/src/common.ts @@ -287,3 +287,38 @@ export async function getExecutableShebang(reader: Reader): Promise(); + + const listener = () => { + signal.removeEventListener("abort", listener); + resolve(); + }; + signal.addEventListener("abort", listener); + return { + [Symbol.dispose]() { + signal.removeEventListener("abort", listener); + }, + promise, + }; +} + +const nodeENotEmpty = "ENOTEMPTY: "; +const nodeENOENT = "ENOENT: "; + +export function errorToString(err: unknown) { + let message: string; + if (err instanceof Error) { + message = err.message; + } else { + message = String(err); + } + if (message.startsWith(nodeENotEmpty)) { + return message.slice(nodeENotEmpty.length); + } else if (message.startsWith(nodeENOENT)) { + return message.slice(nodeENOENT.length); + } else { + return message; + } +} diff --git a/src/console/progress/interval.ts b/src/console/progress/interval.ts index d108fbd..c9f6140 100644 --- a/src/console/progress/interval.ts +++ b/src/console/progress/interval.ts @@ -7,7 +7,7 @@ export interface RenderIntervalProgressBar { const intervalMs = 60; const progressBars: RenderIntervalProgressBar[] = []; -let renderIntervalId: number | undefined; +let renderIntervalId: ReturnType | undefined; export function addProgressBar(render: (size: ConsoleSize) => TextItem[]): RenderIntervalProgressBar { const pb = { diff --git a/src/console/utils.ts b/src/console/utils.ts index 69295d6..a4700b3 100644 --- a/src/console/utils.ts +++ b/src/console/utils.ts @@ -68,7 +68,11 @@ export function showCursor() { Deno.stderr.writeSync(encoder.encode("\x1B[?25h")); } -export const isOutputTty = safeConsoleSize() != null && isTerminal(Deno.stderr); +export let isOutputTty = safeConsoleSize() != null && isTerminal(Deno.stderr); + +export function setNotTtyForTesting() { + isOutputTty = false; +} function isTerminal(pipe: { isTerminal?(): boolean; rid?: number }) { if (typeof pipe.isTerminal === "function") { diff --git a/src/deps.test.ts b/src/deps.test.ts index 27a9029..9530214 100644 --- a/src/deps.test.ts +++ b/src/deps.test.ts @@ -10,23 +10,29 @@ export { } from "https://deno.land/std@0.213.0/assert/mod.ts"; export { toWritableStream } from "https://deno.land/std@0.213.0/io/to_writable_stream.ts"; export { toReadableStream } from "https://deno.land/std@0.213.0/io/to_readable_stream.ts"; +export { isNode } from "https://deno.land/x/which_runtime@0.2.0/mod.ts"; /** * Creates a temporary directory, changes the cwd to this directory, * then cleans up and restores the cwd when complete. */ export async function withTempDir(action: (path: PathRef) => Promise | void) { + await using dirPath = usingTempDir(); + await action(createPathRef(dirPath).resolve()); +} + +export function usingTempDir(): PathRef & AsyncDisposable { const originalDirPath = Deno.cwd(); - const dirPath = await Deno.makeTempDir(); + const dirPath = Deno.makeTempDirSync(); Deno.chdir(dirPath); - try { - await action(createPathRef(dirPath).resolve()); - } finally { + const pathRef = createPathRef(dirPath).resolve(); + (pathRef as any)[Symbol.asyncDispose] = async () => { try { await Deno.remove(dirPath, { recursive: true }); } catch { // ignore } Deno.chdir(originalDirPath); - } + }; + return pathRef as PathRef & AsyncDisposable; } diff --git a/src/deps.ts b/src/deps.ts index 58abbaf..3083e47 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -6,7 +6,7 @@ export * as path from "https://deno.land/std@0.213.0/path/mod.ts"; export { readAll } from "https://deno.land/std@0.213.0/io/read_all.ts"; export { readerFromStreamReader } from "https://deno.land/std@0.213.0/streams/reader_from_stream_reader.ts"; export { writeAll, writeAllSync } from "https://deno.land/std@0.213.0/io/write_all.ts"; -export { outdent } from "https://deno.land/x/outdent@v0.8.0/src/index.ts"; +export { outdent } from "./vendor/outdent.ts"; export { RealEnvironment as DenoWhichRealEnvironment, which, whichSync } from "https://deno.land/x/which@0.3.0/mod.ts"; export { writerFromStreamWriter } from "https://deno.land/std@0.213.0/streams/writer_from_stream_writer.ts"; diff --git a/src/lib/mod.ts b/src/lib/mod.ts index 37f8964..1a033f8 100644 --- a/src/lib/mod.ts +++ b/src/lib/mod.ts @@ -1,4 +1,3 @@ import { instantiate } from "./rs_lib.generated.js"; -export type WasmInstance = Awaited>; -export const wasmInstance = await instantiate(); +export const wasmInstance = instantiate(); diff --git a/src/path.test.ts b/src/path.test.ts index 13c3ee1..fc5f6c3 100644 --- a/src/path.test.ts +++ b/src/path.test.ts @@ -1,4 +1,4 @@ -import { assert, assertEquals, assertRejects, assertThrows, withTempDir } from "./deps.test.ts"; +import { assert, assertEquals, assertRejects, assertThrows, isNode, withTempDir } from "./deps.test.ts"; import { createPathRef, PathRef } from "./path.ts"; import { path as stdPath } from "./deps.ts"; @@ -39,16 +39,24 @@ Deno.test("normalize", () => { assertEquals(path.toString(), stdPath.normalize("src")); }); -Deno.test("isDir", () => { - assert(createPathRef("src").isDirSync()); - assert(!createPathRef("mod.ts").isDirSync()); - assert(!createPathRef("nonExistent").isDirSync()); +Deno.test("isDir", async () => { + await withTempDir((dir) => { + assert(dir.isDirSync()); + const file = dir.join("mod.ts"); + file.writeTextSync(""); + assert(!file.isDirSync()); + assert(!dir.join("nonExistent").isDirSync()); + }); }); -Deno.test("isFile", () => { - assert(!createPathRef("src").isFileSync()); - assert(createPathRef("mod.ts").isFileSync()); - assert(!createPathRef("nonExistent").isFileSync()); +Deno.test("isFile", async () => { + await withTempDir((dir) => { + const file = dir.join("mod.ts"); + file.writeTextSync(""); + assert(!dir.isFileSync()); + assert(file.isFileSync()); + assert(!dir.join("nonExistent").isFileSync()); + }); }); Deno.test("isSymlink", async () => { @@ -317,11 +325,16 @@ Deno.test("exists", async () => { }); Deno.test("realpath", async () => { - await withTempDir(async () => { - let file = createPathRef("file").resolve(); + await withTempDir(async (tempDir) => { + let file = tempDir.join("file").resolve(); file.writeTextSync(""); // need to do realPathSync for GH actions CI file = file.realPathSync(); + // for the comparison, node doesn't canonicalize + // RUNNER~1 to runneradmin for some reason + if (isNode && Deno.build.os === "windows") { + file = createPathRef(file.toString().replace("\\RUNNER~1\\", "\\runneradmin\\")); + } const symlink = createPathRef("other"); symlink.createSymlinkToSync(file, { kind: "absolute" }); assertEquals( @@ -898,7 +911,7 @@ Deno.test("instanceof check", () => { }); Deno.test("toFileUrl", () => { - const path = createPathRef(import.meta); + const path = createPathRef(import.meta.url); assertEquals(path.toString(), stdPath.fromFileUrl(import.meta.url)); assertEquals(path.toFileUrl(), new URL(import.meta.url)); }); diff --git a/src/path.ts b/src/path.ts index 9aece83..367e35d 100644 --- a/src/path.ts +++ b/src/path.ts @@ -95,6 +95,11 @@ export class PathRef { return `PathRef("${this.#path}")`; } + /** @internal */ + [Symbol.for("nodejs.util.inspect.custom")](): string { + return `PathRef("${this.#path}")`; + } + /** Gets the string representation of this path. */ toString(): string { return this.#path; diff --git a/src/pipes.ts b/src/pipes.ts index 5b085df..2c7103f 100644 --- a/src/pipes.ts +++ b/src/pipes.ts @@ -2,7 +2,8 @@ import { type FsFileWrapper, PathRef } from "./path.ts"; import { logger } from "./console/logger.ts"; import { Buffer, writeAll, writeAllSync } from "./deps.ts"; import type { RequestBuilder } from "./request.ts"; -import type { CommandBuilder } from "./command.ts"; +import type { CommandBuilder, KillSignal } from "./command.ts"; +import { abortSignalToPromise } from "./common.ts"; const encoder = new TextEncoder(); @@ -288,3 +289,42 @@ export class PipeSequencePipe implements Reader, WriterSync { } } } + +export async function pipeReaderToWritable( + reader: Reader, + writable: WritableStream, + signal: AbortSignal, +) { + using abortedPromise = abortSignalToPromise(signal); + const writer = writable.getWriter(); + try { + while (!signal.aborted) { + const buffer = new Uint8Array(1024); + const length = await Promise.race([abortedPromise.promise, reader.read(buffer)]); + if (length === 0 || length == null) { + break; + } + await writer.write(buffer.subarray(0, length)); + } + } finally { + await writer.close(); + } +} + +export async function pipeReadableToWriterSync( + readable: ReadableStream, + writer: ShellPipeWriter, + signal: AbortSignal | KillSignal, +) { + const reader = readable.getReader(); + while (!signal.aborted) { + const result = await reader.read(); + if (result.done) { + break; + } + const maybePromise = writer.writeAll(result.value); + if (maybePromise) { + await maybePromise; + } + } +} diff --git a/src/request.test.ts b/src/request.test.ts index ba6985e..cb62f88 100644 --- a/src/request.test.ts +++ b/src/request.test.ts @@ -1,26 +1,13 @@ import { Buffer, path } from "./deps.ts"; -import { assertEquals, assertRejects, toWritableStream } from "./deps.test.ts"; +import { assert, assertEquals, assertRejects, isNode, toWritableStream } from "./deps.test.ts"; import { RequestBuilder } from "./request.ts"; +import { startServer } from "./test/server.deno.ts"; import $ from "../mod.ts"; import { TimeoutError } from "./common.ts"; -import { assert } from "./deps.test.ts"; -function withServer(action: (serverUrl: URL) => Promise) { - return new Promise((resolve, reject) => { - const server = Deno.serve({ - hostname: "localhost", - async onListen(details) { - const url = new URL(`http://${details.hostname}:${details.port}/`); - try { - await action(url); - await server.shutdown(); - resolve(); - } catch (err) { - await server.shutdown(); - reject(err); - } - }, - }, (request) => { +async function withServer(action: (serverUrl: URL) => Promise) { + const server = await startServer({ + handle(request) { const url = new URL(request.url); if (url.pathname === "/text-file") { const data = "text".repeat(1000); @@ -55,7 +42,7 @@ function withServer(action: (serverUrl: URL) => Promise) { return new Response( new ReadableStream({ start(controller) { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { if (signal.aborted || abortController.signal.aborted) { return; } @@ -84,8 +71,20 @@ function withServer(action: (serverUrl: URL) => Promise) { } else { return new Response("Not Found", { status: 404 }); } - }); + }, }); + + try { + await action(server.rootUrl); + } catch (err) { + throw err; + } finally { + try { + await server.shutdown(); + } catch { + // ignore + } + } } Deno.test("$.request", (t) => { @@ -284,7 +283,7 @@ Deno.test("$.request", (t) => { step("ensure times out waiting for body", async () => { const request = new RequestBuilder() .url(new URL("/sleep-body/10000", serverUrl)) - .timeout(100) + .timeout(200) // so high because CI was slow .showProgress(); const response = await request.fetch(); let caughtErr: TimeoutError | undefined; @@ -293,8 +292,14 @@ Deno.test("$.request", (t) => { } catch (err) { caughtErr = err; } - assertEquals(caughtErr!, new TimeoutError("Request timed out after 100 milliseconds.")); - assert(caughtErr!.stack!.includes("request.test.ts")); // current file + if (isNode) { + // seems like a bug in Node and Chrome where they throw a + // DOMException instead, but not sure + assert(caughtErr != null); + } else { + assertEquals(caughtErr!, new TimeoutError("Request timed out after 200 milliseconds.")); + assert(caughtErr!.stack!.includes("request.test.ts")); // current file + } }); step("ability to abort while waiting", async () => { @@ -309,7 +314,13 @@ Deno.test("$.request", (t) => { } catch (err) { caughtErr = err; } - assertEquals(caughtErr, "Cancel."); + if (isNode) { + // seems like a bug in Node and Chrome where they throw a + // DOMException instead, but not sure + assert(caughtErr != null); + } else { + assertEquals(caughtErr, "Cancel."); + } }); step("use in a redirect", async () => { @@ -327,7 +338,7 @@ Deno.test("$.request", (t) => { const result = await $`cat - < ${request}`.noThrow().stderr("piped"); assertEquals( result.stderr, - "cat: Error making request to http://localhost:8000/code/500: Internal Server Error\n", + `cat: Error making request to ${new URL("/code/500", serverUrl).toString()}: Internal Server Error\n`, ); assertEquals(result.code, 1); }); diff --git a/src/request.ts b/src/request.ts index 221d16a..43aafc9 100644 --- a/src/request.ts +++ b/src/request.ts @@ -559,7 +559,7 @@ export class RequestResponse { await this.#response.body?.cancel(); return undefined as any; } - return await this.#downloadResponse.json(); + return (await this.#downloadResponse.json()) as TResult; }); } @@ -632,6 +632,10 @@ export class RequestResponse { await body.pipeTo(file.writable, { preventClose: true, }); + // Need to do this for node.js for some reason + // in order to fully flush to the file. Maybe + // it's a bug in node_shims + await file.writable.close(); } finally { try { file.close(); @@ -693,6 +697,7 @@ export async function makeRequest(state: RequestBuilderState) { }; const response = await fetch(state.url, { body: state.body, + // @ts-ignore not supported in Node.js yet? cache: state.cache, headers: filterEmptyRecordValues(state.headers), integrity: state.integrity, diff --git a/src/runtimes/process.common.ts b/src/runtimes/process.common.ts new file mode 100644 index 0000000..3c24c68 --- /dev/null +++ b/src/runtimes/process.common.ts @@ -0,0 +1,19 @@ +export interface SpawnCommandOptions { + args: string[]; + cwd: string; + env: Record; + clearEnv: boolean; + stdin: "inherit" | "null" | "piped"; + stdout: "inherit" | "null" | "piped"; + stderr: "inherit" | "null" | "piped"; +} + +export interface SpawnedChildProcess { + stdin(): WritableStream; + stdout(): ReadableStream; + stderr(): ReadableStream; + kill(signo?: Deno.Signal): void; + waitExitCode(): Promise; +} + +export type SpawnCommand = (path: string, options: SpawnCommandOptions) => SpawnedChildProcess; diff --git a/src/runtimes/process.deno.ts b/src/runtimes/process.deno.ts new file mode 100644 index 0000000..142294e --- /dev/null +++ b/src/runtimes/process.deno.ts @@ -0,0 +1,23 @@ +import { SpawnCommand } from "./process.common.ts"; + +export const spawnCommand: SpawnCommand = (path, options) => { + const child = new Deno.Command(path, options).spawn(); + child.status; + return { + stdin() { + return child.stdin; + }, + kill(signo?: Deno.Signal) { + child.kill(signo); + }, + waitExitCode() { + return child.status.then((status) => status.code); + }, + stdout() { + return child.stdout; + }, + stderr() { + return child.stderr; + }, + }; +}; diff --git a/src/runtimes/process.node.ts b/src/runtimes/process.node.ts new file mode 100644 index 0000000..fc7594c --- /dev/null +++ b/src/runtimes/process.node.ts @@ -0,0 +1,58 @@ +import * as cp from "node:child_process"; +import { Readable, Writable } from "node:stream"; +import { SpawnCommand } from "./process.common.ts"; +import { getSignalAbortCode } from "../command.ts"; + +function toNodeStdio(stdio: "inherit" | "null" | "piped") { + switch (stdio) { + case "inherit": + return "inherit"; + case "null": + return "ignore"; + case "piped": + return "pipe"; + } +} + +export const spawnCommand: SpawnCommand = (path, options) => { + let receivedSignal: Deno.Signal | undefined; + const child = cp.spawn(path, options.args, { + cwd: options.cwd, + // todo: clearEnv on node? + env: options.env, + stdio: [ + toNodeStdio(options.stdin), + toNodeStdio(options.stdout), + toNodeStdio(options.stderr), + ], + }); + const exitResolvers = Promise.withResolvers(); + child.on("exit", (code) => { + if (code == null && receivedSignal != null) { + exitResolvers.resolve(getSignalAbortCode(receivedSignal) ?? 1); + } else { + exitResolvers.resolve(code ?? 0); + } + }); + child.on("error", (err) => { + exitResolvers.reject(err); + }); + return { + stdin() { + return Writable.toWeb(child.stdin!); + }, + kill(signo?: Deno.Signal) { + receivedSignal = signo; + child.kill(signo as any); + }, + waitExitCode() { + return exitResolvers.promise; + }, + stdout() { + return Readable.toWeb(child.stdout!) as ReadableStream; + }, + stderr() { + return Readable.toWeb(child.stderr!) as ReadableStream; + }, + }; +}; diff --git a/src/shell.ts b/src/shell.ts index ac93668..55fb2fa 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -1,11 +1,12 @@ import { KillSignal } from "./command.ts"; import { CommandContext, CommandHandler, type CommandPipeReader } from "./command_handler.ts"; -import { getExecutableShebangFromPath, ShebangInfo } from "./common.ts"; +import { errorToString, getExecutableShebangFromPath, ShebangInfo } from "./common.ts"; import { DenoWhichRealEnvironment, fs, path, which } from "./deps.ts"; import { wasmInstance } from "./lib/mod.ts"; import { NullPipeReader, NullPipeWriter, + pipeReadableToWriterSync, PipeSequencePipe, PipeWriter, Reader, @@ -14,6 +15,10 @@ import { ShellPipeWriterKind, } from "./pipes.ts"; import { EnvChange, ExecuteResult, getAbortedResult } from "./result.ts"; +import { SpawnedChildProcess } from "./runtimes/process.common.ts"; +import { spawnCommand } from "./runtimes/process.deno.ts"; + +const neverAbortedSignal = new AbortController().signal; export interface SequentialList { items: SequentialListItem[]; @@ -693,7 +698,7 @@ async function executeCommand(command: Command, context: Context): Promise { function handleFileOpenError(outputPath: string, err: any) { - return context.error(`failed opening file for redirect (${outputPath}). ${err?.message ?? err}`); + return context.error(`failed opening file for redirect (${outputPath}). ${errorToString(err)}`); } const toFd = resolveRedirectToFd(redirect, context); @@ -891,6 +896,16 @@ async function executeSimpleCommand(command: SimpleCommand, parentContext: Conte return await executeCommandArgs(commandArgs, context); } +function checkMapCwdNotExistsError(cwd: string, err: unknown) { + if ((err as any).code === "ENOENT" && !fs.existsSync(cwd)) { + throw new Error(`Failed to launch command because the cwd does not exist (${cwd}).`, { + cause: err, + }); + } else { + throw err; + } +} + async function executeCommandArgs(commandArgs: string[], context: Context): Promise { // look for a registered command first const command = context.getCommand(commandArgs[0]); @@ -909,24 +924,19 @@ async function executeCommandArgs(commandArgs: string[], context: Context): Prom stdout: getStdioStringValue(context.stdout.kind), stderr: getStdioStringValue(context.stderr.kind), }; - let p: Deno.ChildProcess; + let p: SpawnedChildProcess; const cwd = context.getCwd(); try { - p = new Deno.Command(resolvedCommand.path, { + p = spawnCommand(resolvedCommand.path, { args: commandArgs.slice(1), cwd, env: context.getEnvVars(), clearEnv: true, ...pipeStringVals, - }).spawn(); + }); } catch (err) { - if (err.code === "ENOENT" && !fs.existsSync(cwd)) { - throw new Error(`Failed to launch command because the cwd does not exist (${cwd}).`, { - cause: err, - }); - } else { - throw err; - } + // Deno throws this sync, Node.js throws it async + throw checkMapCwdNotExistsError(cwd, err); } const listener = (signal: Deno.Signal) => p.kill(signal); context.signal.addListener(listener); @@ -940,7 +950,7 @@ async function executeCommandArgs(commandArgs: string[], context: Context): Prom return; } - const maybePromise = context.stderr.writeLine(`stdin pipe broken. ${err?.message ?? err}`); + const maybePromise = context.stderr.writeLine(`stdin pipe broken. ${errorToString(err)}`); if (maybePromise != null) { await maybePromise; } @@ -955,14 +965,18 @@ async function executeCommandArgs(commandArgs: string[], context: Context): Prom } }); try { + // don't abort stdout and stderr reads... ensure all of stdout/stderr is + // read in case the process exits before this finishes const readStdoutTask = pipeStringVals.stdout === "piped" - ? readStdOutOrErr(p.stdout, context.stdout) + ? readStdOutOrErr(p.stdout(), context.stdout) : Promise.resolve(); const readStderrTask = pipeStringVals.stderr === "piped" - ? readStdOutOrErr(p.stderr, context.stderr) + ? readStdOutOrErr(p.stderr(), context.stderr) : Promise.resolve(); - const [status] = await Promise.all([ - p.status, + const [exitCode] = await Promise.all([ + p.waitExitCode() + // for node.js, which throws this async + .catch((err) => Promise.reject(checkMapCwdNotExistsError(cwd, err))), readStdoutTask, readStderrTask, ]); @@ -972,7 +986,7 @@ async function executeCommandArgs(commandArgs: string[], context: Context): Prom kind: "exit", }; } else { - return { code: status.code }; + return { code: exitCode }; } } finally { completeController.abort(); @@ -982,13 +996,14 @@ async function executeCommandArgs(commandArgs: string[], context: Context): Prom await stdinPromise; } - async function writeStdin(stdin: CommandPipeReader, p: Deno.ChildProcess, signal: AbortSignal) { + async function writeStdin(stdin: CommandPipeReader, p: SpawnedChildProcess, signal: AbortSignal) { if (typeof stdin === "string") { return; } - await pipeReaderToWritable(stdin, p.stdin, signal); + const processStdin = p.stdin(); + await pipeReaderToWritable(stdin, processStdin, signal); try { - await p.stdin.close(); + await processStdin.close(); } catch { // ignore } @@ -1000,7 +1015,7 @@ async function executeCommandArgs(commandArgs: string[], context: Context): Prom } // don't abort... ensure all of stdout/stderr is read in case the process // exits before this finishes - await pipeReadableToWriterSync(readable, writer, new AbortController().signal); + await pipeReadableToWriterSync(readable, writer, neverAbortedSignal); } function getStdioStringValue(value: ShellPipeReaderKind | ShellPipeWriterKind) { @@ -1043,24 +1058,6 @@ async function pipeReaderToWritable(reader: Reader, writable: WritableStream, - writer: ShellPipeWriter, - signal: AbortSignal | KillSignal, -) { - const reader = readable.getReader(); - while (!signal.aborted) { - const result = await reader.read(); - if (result.done) { - break; - } - const maybePromise = writer.writeAll(result.value); - if (maybePromise) { - await maybePromise; - } - } -} - async function pipeReaderToWriterSync( reader: Reader, writer: ShellPipeWriter, diff --git a/src/test/server.common.ts b/src/test/server.common.ts new file mode 100644 index 0000000..f7e1452 --- /dev/null +++ b/src/test/server.common.ts @@ -0,0 +1,10 @@ +export interface ServerOptions { + handle(request: Request): Promise | Response; +} + +export interface Server { + rootUrl: URL; + shutdown(): Promise; +} + +export type StartServerHandler = (options: ServerOptions) => Promise; diff --git a/src/test/server.deno.ts b/src/test/server.deno.ts new file mode 100644 index 0000000..cb82043 --- /dev/null +++ b/src/test/server.deno.ts @@ -0,0 +1,18 @@ +import { Server, StartServerHandler } from "./server.common.ts"; + +export const startServer: StartServerHandler = (options) => { + return new Promise((resolve, _reject) => { + const server = Deno.serve({ + hostname: "localhost", + onListen(details) { + const url = new URL(`http://${details.hostname}:${details.port}/`); + resolve({ + rootUrl: url, + shutdown: () => server.shutdown(), + }); + }, + }, (request) => { + return options.handle(request); + }); + }); +}; diff --git a/src/test/server.node.ts b/src/test/server.node.ts new file mode 100644 index 0000000..1212c97 --- /dev/null +++ b/src/test/server.node.ts @@ -0,0 +1,50 @@ +import http from "node:http"; +import { Server, StartServerHandler } from "./server.common.ts"; + +export const startServer: StartServerHandler = (options) => { + return new Promise((resolve, reject) => { + const server = http.createServer(async (request, response) => { + try { + const webRequest = new Request(new URL(request.url!, `http://${request.headers.host!}`), { + headers: new Headers(request.headers as any), + }); + const handlerResponse = await options.handle(webRequest); + response.writeHead(handlerResponse.status); + response.flushHeaders(); + // todo: improve + const body = await handlerResponse.arrayBuffer(); + response.end(new Uint8Array(body)); + } catch (error) { + // deno-lint-ignore no-console + console.error("Error", error); + if (!response.headersSent) { + response.writeHead(500, { "Content-Type": "text/plain" }); + response.end(new TextEncoder().encode(`Server error: ${error.message}`)); + } + if (!response.writableEnded) { + // forcefully close the connection + response.socket?.destroy(); + } + reject(error); + } + }); + + server.listen(0, "localhost", () => { + const address = server.address() as import("node:net").AddressInfo; + const url = new URL(`http://localhost:${address.port}/`); + // deno-lint-ignore no-console + console.log(`\n\nServer listening at ${url}...\n`); + resolve({ + rootUrl: url, + shutdown() { + return new Promise((resolve, reject) => { + server.close((err) => { + if (err) reject(err); + else resolve(); + }); + }); + }, + }); + }); + }); +}; diff --git a/src/vendor/outdent.ts b/src/vendor/outdent.ts new file mode 100644 index 0000000..5abe4c0 --- /dev/null +++ b/src/vendor/outdent.ts @@ -0,0 +1,210 @@ +// Vendored and modified from: https://github.com/cspotcode/outdent/blob/1aaf39e4788a41412eb0aab2943da5afe63d7dd1/src/index.ts +// Modified because it had some CommonJS code in it that was causing warnings in the esbuild build. + +// The MIT License (MIT) +// +// Copyright (c) 2016 Andrew Bradley + +// Copy all own enumerable properties from source to target +function extend(target: T, source: S) { + type Extended = T & S; + for (const prop in source) { + if (Object.hasOwn(source, prop)) { + (target as any)[prop] = source[prop]; + } + } + return target as Extended; +} + +const reLeadingNewline = /^[ \t]*(?:\r\n|\r|\n)/; +const reTrailingNewline = /(?:\r\n|\r|\n)[ \t]*$/; +const reStartsWithNewlineOrIsEmpty = /^(?:[\r\n]|$)/; +const reDetectIndentation = /(?:\r\n|\r|\n)([ \t]*)(?:[^ \t\r\n]|$)/; +const reOnlyWhitespaceWithAtLeastOneNewline = /^[ \t]*[\r\n][ \t\r\n]*$/; + +function _outdentArray( + strings: ReadonlyArray, + firstInterpolatedValueSetsIndentationLevel: boolean, + options: Options, +) { + // If first interpolated value is a reference to outdent, + // determine indentation level from the indentation of the interpolated value. + let indentationLevel = 0; + + const match = strings[0].match(reDetectIndentation); + if (match) { + indentationLevel = match[1].length; + } + + const reSource = `(\\r\\n|\\r|\\n).{0,${indentationLevel}}`; + const reMatchIndent = new RegExp(reSource, "g"); + + if (firstInterpolatedValueSetsIndentationLevel) { + strings = strings.slice(1); + } + + const { newline, trimLeadingNewline, trimTrailingNewline } = options; + const normalizeNewlines = typeof newline === "string"; + const l = strings.length; + const outdentedStrings = strings.map((v, i) => { + // Remove leading indentation from all lines + v = v.replace(reMatchIndent, "$1"); + // Trim a leading newline from the first string + if (i === 0 && trimLeadingNewline) { + v = v.replace(reLeadingNewline, ""); + } + // Trim a trailing newline from the last string + if (i === l - 1 && trimTrailingNewline) { + v = v.replace(reTrailingNewline, ""); + } + // Normalize newlines + if (normalizeNewlines) { + v = v.replace(/\r\n|\n|\r/g, (_) => newline as string); + } + return v; + }); + return outdentedStrings; +} + +function concatStringsAndValues( + strings: ReadonlyArray, + values: ReadonlyArray, +): string { + let ret = ""; + for (let i = 0, l = strings.length; i < l; i++) { + ret += strings[i]; + if (i < l - 1) { + ret += values[i]; + } + } + return ret; +} + +function isTemplateStringsArray(v: any): v is TemplateStringsArray { + return Object.hasOwn(v, "raw") && Object.hasOwn(v, "length"); +} + +/** + * It is assumed that opts will not change. If this is a problem, clone your options object and pass the clone to + * makeInstance + * @param options + * @return {outdent} + */ +function createInstance(options: Options): Outdent { + /** Cache of pre-processed template literal arrays */ + const arrayAutoIndentCache = new WeakMap< + TemplateStringsArray, + Array + >(); + /** + * Cache of pre-processed template literal arrays, where first interpolated value is a reference to outdent, + * before interpolated values are injected. + */ + const arrayFirstInterpSetsIndentCache = new WeakMap< + TemplateStringsArray, + Array + >(); + + /* tslint:disable:no-shadowed-variable */ + function outdent( + stringsOrOptions: TemplateStringsArray, + ...values: Array + ): string; + function outdent(stringsOrOptions: Options): Outdent; + function outdent( + stringsOrOptions: TemplateStringsArray | Options, + ...values: Array + ): string | Outdent { + /* tslint:enable:no-shadowed-variable */ + if (isTemplateStringsArray(stringsOrOptions)) { + const strings = stringsOrOptions; + + // Is first interpolated value a reference to outdent, alone on its own line, without any preceding non-whitespace? + const firstInterpolatedValueSetsIndentationLevel = (values[0] === outdent || values[0] === defaultOutdent) && + reOnlyWhitespaceWithAtLeastOneNewline.test(strings[0]) && + reStartsWithNewlineOrIsEmpty.test(strings[1]); + + // Perform outdentation + const cache = firstInterpolatedValueSetsIndentationLevel ? arrayFirstInterpSetsIndentCache : arrayAutoIndentCache; + let renderedArray = cache.get(strings); + if (!renderedArray) { + renderedArray = _outdentArray( + strings, + firstInterpolatedValueSetsIndentationLevel, + options, + ); + cache.set(strings, renderedArray); + } + /** If no interpolated values, skip concatenation step */ + if (values.length === 0) { + return renderedArray[0]; + } + /** Concatenate string literals with interpolated values */ + const rendered = concatStringsAndValues( + renderedArray, + firstInterpolatedValueSetsIndentationLevel ? values.slice(1) : values, + ); + + return rendered; + } else { + // Create and return a new instance of outdent with the given options + return createInstance( + extend(extend({}, options), stringsOrOptions || {}), + ); + } + } + + const fullOutdent = extend(outdent, { + string(str: string): string { + return _outdentArray([str], false, options)[0]; + }, + }); + + return fullOutdent; +} + +const defaultOutdent = createInstance({ + trimLeadingNewline: true, + trimTrailingNewline: true, +}); + +export interface Outdent { + /** + * Remove indentation from a template literal. + */ + (strings: TemplateStringsArray, ...values: Array): string; + /** + * Create and return a new Outdent instance with the given options. + */ + (options: Options): Outdent; + + /** + * Remove indentation from a string + */ + string(str: string): string; + + // /** + // * Remove indentation from a template literal, but return a tuple of the + // * outdented TemplateStringsArray and + // */ + // pass(strings: TemplateStringsArray, ...values: Array): [TemplateStringsArray, ...Array]; +} +export interface Options { + trimLeadingNewline?: boolean; + trimTrailingNewline?: boolean; + /** + * Normalize all newlines in the template literal to this value. + * + * If `null`, newlines are left untouched. + * + * Newlines that get normalized are '\r\n', '\r', and '\n'. + * + * Newlines within interpolated values are *never* normalized. + * + * Although intended for normalizing to '\n' or '\r\n', + * you can also set to any string; for example ' '. + */ + newline?: string | null; +} + +export { defaultOutdent as outdent };