diff --git a/packages/comms/.vscode/launch.json b/packages/comms/.vscode/launch.json index 0ff93a7e86..f41798cc0a 100644 --- a/packages/comms/.vscode/launch.json +++ b/packages/comms/.vscode/launch.json @@ -33,7 +33,7 @@ "name": "index.html", "request": "launch", "type": "msedge", - "url": "http://localhost:5521/index.html", + "url": "http://localhost:5173/index.html", "runtimeArgs": [ "--disable-web-security" ], diff --git a/packages/comms/index.html b/packages/comms/index.html index 6352e4931d..61cd95c040 100644 --- a/packages/comms/index.html +++ b/packages/comms/index.html @@ -35,14 +35,19 @@

ESM Quick Test

Workunit.submit({ baseUrl: "http://localhost:8010" }, "hthor", "'Hello and Welcome!';") .then((wu) => { + console.log("Submitted WU: " + wu.Wuid); return wu.watchUntilComplete(); }).then((wu) => { + console.log("WU: " + wu.Wuid, wu.StateID, wu.State, wu.isComplete()); return wu.fetchResults().then((results) => { + console.log("Results: " + results.length); return results[0].fetchRows(); }).then((rows) => { + console.log("Rows: " + rows.length); return wu; }); }).then((wu) => { + console.log("Deleting WU: " + wu.Wuid); return wu.delete().then(() => wu); }).then(wu => { console.log("Deleted WU: " + wu.Wuid, wu.isDeleted()); diff --git a/packages/comms/src/connection.ts b/packages/comms/src/connection.ts index 4a94ac9b89..2e3b3296dd 100644 --- a/packages/comms/src/connection.ts +++ b/packages/comms/src/connection.ts @@ -1,4 +1,4 @@ -import { join, promiseTimeout, scopedLogger } from "@hpcc-js/util"; +import { join, promiseTimeout, scopedLogger, utf8ToBase64 } from "@hpcc-js/util"; const logger = scopedLogger("comms/connection.ts"); @@ -137,7 +137,7 @@ export function jsonp(opts: IOptions, action: string, request: any = {}, respons } function authHeader(opts: IOptions): object { - return opts.userID ? { Authorization: `Basic ${btoa(`${opts.userID}:${opts.password}`)}` } : {}; + return opts.userID ? { Authorization: `Basic ${utf8ToBase64(`${opts.userID}:${opts.password}`)}` } : {}; } // _omitMap is a workaround for older HPCC-Platform instances without credentials --- diff --git a/packages/comms/src/index.common.ts b/packages/comms/src/index.common.ts index 340a510c8e..340977de85 100644 --- a/packages/comms/src/index.common.ts +++ b/packages/comms/src/index.common.ts @@ -1,5 +1,4 @@ export * from "./__package__.ts"; - export * from "./services/fileSpray.ts"; export * from "./services/wsAccess.ts"; export * from "./services/wsAccount.ts"; diff --git a/packages/comms/src/index.node.ts b/packages/comms/src/index.node.ts index 8863d57161..a6ef38d8b3 100644 --- a/packages/comms/src/index.node.ts +++ b/packages/comms/src/index.node.ts @@ -7,6 +7,7 @@ root.DOMParser = DOMParser; import fetch, { Headers, Request, Response, } from "node-fetch"; import * as https from "node:https"; +import { Buffer } from "node:buffer"; import { Agent, setGlobalDispatcher } from "undici"; if (typeof root.fetch === "undefined") { @@ -57,14 +58,6 @@ root.fetch.__trustwaveAgent = new https.Agent({ ca: globalCA + trustwave }); -// btoa polyfill --- -import { Buffer } from "node:buffer"; -if (typeof root.btoa === "undefined") { - root.btoa = function (str: string) { - return Buffer.from(str || "", "utf8").toString("base64"); - }; -} - export * from "./index.common.ts"; // Client Tools --- diff --git a/packages/util/src/index.ts b/packages/util/src/index.ts index 6a0f803f92..ee69c6aaec 100644 --- a/packages/util/src/index.ts +++ b/packages/util/src/index.ts @@ -19,3 +19,4 @@ export * from "./stack.ts"; export * from "./stateful.ts"; export * from "./string.ts"; export * from "./url.ts"; +export * from "./utf8ToBase64.ts"; \ No newline at end of file diff --git a/packages/util/src/utf8ToBase64.ts b/packages/util/src/utf8ToBase64.ts new file mode 100644 index 0000000000..40b2e70a1e --- /dev/null +++ b/packages/util/src/utf8ToBase64.ts @@ -0,0 +1,47 @@ +const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +function toUTF8Bytes(value: string): Uint8Array { + if (typeof TextEncoder !== "undefined") { + return new TextEncoder().encode(value); + } + + const encoded = encodeURIComponent(value); + const bytes: number[] = []; + for (let i = 0; i < encoded.length; ++i) { + if (encoded[i] === "%") { + bytes.push(parseInt(encoded.substring(i + 1, i + 3), 16)); + i += 2; + } else { + bytes.push(encoded.charCodeAt(i)); + } + } + return Uint8Array.from(bytes); +} + +function bytesToBase64(bytes: Uint8Array): string { + let output = ""; + for (let i = 0; i < bytes.length; i += 3) { + const byte1 = bytes[i]; + const hasByte2 = i + 1 < bytes.length; + const hasByte3 = i + 2 < bytes.length; + const byte2 = hasByte2 ? bytes[i + 1] : 0; + const byte3 = hasByte3 ? bytes[i + 2] : 0; + + output += BASE64_ALPHABET[byte1 >> 2]; + output += BASE64_ALPHABET[((byte1 & 0x03) << 4) | (byte2 >> 4)]; + output += hasByte2 ? BASE64_ALPHABET[((byte2 & 0x0f) << 2) | (byte3 >> 6)] : "="; + output += hasByte3 ? BASE64_ALPHABET[byte3 & 0x3f] : "="; + } + return output; +} + +export function utf8ToBase64(value: string = ""): string { + const normalized = value == null ? "" : String(value); + + const maybeBuffer = (globalThis as any)?.Buffer; + if (maybeBuffer?.from) { + return maybeBuffer.from(normalized, "utf8").toString("base64"); + } + + return bytesToBase64(toUTF8Bytes(normalized)); +} diff --git a/packages/util/tests/utf8ToBase64.node.spec.ts b/packages/util/tests/utf8ToBase64.node.spec.ts new file mode 100644 index 0000000000..bdb419d03a --- /dev/null +++ b/packages/util/tests/utf8ToBase64.node.spec.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest"; +import { utf8ToBase64 } from "@hpcc-js/util"; + +describe("utf8ToBase64", () => { + it("encodes multi-byte characters", () => { + const sample = "Привет, 世界 👋"; + const expected = Buffer.from(sample, "utf8").toString("base64"); + expect(utf8ToBase64(sample)).toEqual(expected); + }); + + it("falls back when Buffer is unavailable", () => { + const sample = "mañana ☀️"; + const expected = Buffer.from(sample, "utf8").toString("base64"); + const original = (globalThis as any).Buffer; + (globalThis as any).Buffer = undefined; + try { + expect(utf8ToBase64(sample)).toEqual(expected); + } finally { + (globalThis as any).Buffer = original; + } + }); +}); diff --git a/packages/util/tests/utf8ToBase64.spec.ts b/packages/util/tests/utf8ToBase64.spec.ts new file mode 100644 index 0000000000..7360fc34b9 --- /dev/null +++ b/packages/util/tests/utf8ToBase64.spec.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { utf8ToBase64 } from "@hpcc-js/util"; + +describe("utf8ToBase64", () => { + it("encodes ASCII input", () => { + expect(utf8ToBase64("hello world")) + .toEqual("aGVsbG8gd29ybGQ="); + }); + + it("treats nullish values as empty string", () => { + expect(utf8ToBase64(undefined as unknown as string)).toEqual(""); + expect(utf8ToBase64(null as unknown as string)).toEqual(""); + }); + + it("check frequest characters", () => { + expect(utf8ToBase64("hello world")).toEqual(btoa("hello world")); + expect(utf8ToBase64("g@s*!")).toEqual(btoa("g@s*!")); + expect(utf8ToBase64("~@:!$%^&*()_-;'#***")).toEqual(btoa("~@:!$%^&*()_-;'#***")); + expect(utf8ToBase64("!$%^&*()_-;'#:@~")).toEqual(btoa("!$%^&*()_-;'#:@~")); + // expect(utf8ToBase64("¬!£$%^&*()_-;'#:@~")).toEqual(btoa("¬!£$%^&*()_-;'#:@~")); + }); + + describe("incremental ASCII coverage", () => { + const printableAscii = Array.from({ length: 95 }, (_, i) => String.fromCharCode(32 + i)).join(""); + const checkpoints = [1, 5, 10, 20, 32, 48, 64, 80, 95]; + const cases = checkpoints.map((len) => [len, printableAscii.slice(0, len)] as const); + + it.each(cases)("encodes first %i printable ASCII chars", (_length, sample) => { + expect(utf8ToBase64(sample)).toEqual(btoa(sample)); + }); + }); +});