Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions packages/bun-types/bun.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ declare module "bun" {
* The labels of the `replacement` encoding are excluded: the `TextDecoder`
* constructor rejects them with a `RangeError`.
*
* The labels are listed in lower case. A `new TextDecoder(...)` expression
* also accepts each label in any ASCII case, such as `"UTF-8"`.
*
* @see https://encoding.spec.whatwg.org/#names-and-labels
*/
type Encoding =
Expand Down Expand Up @@ -348,6 +351,39 @@ declare module "bun" {
: Otherwise // Not defined in lib dom (or anywhere else), so no conflict. We can safely use our own definition
: Otherwise; // Lib dom not loaded anyway, so no conflict. We can safely use our own definition

type EncodingLabelSpace = " " | "\t" | "\n" | "\f" | "\r";
type TrimEncodingLabel<S extends string> = S extends `${EncodingLabelSpace}${infer R}`
? TrimEncodingLabel<R>
: S extends `${infer R}${EncodingLabelSpace}`
? TrimEncodingLabel<R>
: S;
Comment thread
robobun marked this conversation as resolved.
Resolved

/**
* The parameter type for a `TextDecoder` label of type `L`: `L` itself when
* the runtime resolves the label to an encoding, otherwise
* {@link Bun.Encoding}, which makes the call an error.
*
* The runtime removes ASCII whitespace from both ends of a label and
* ignores ASCII case, so `"UTF-8"` and `" Shift_JIS "` are labels. Only a
* label whose type is a string literal, or a union of them, is checked: a
* value of type `string` is not accepted. `Lowercase` maps U+212A KELVIN
* SIGN to `"k"` and the runtime does not, so a label that contains it is
* rejected first.
*
* The first arm is true only for `any`. TypeScript puts `any` in place of
* `L` when it compares the constructor with another constructor type, and
* the parameter must stay {@link Bun.Encoding} there.
*/
type EncodingLabel<L extends string> = unknown extends L
? Bun.Encoding
: [L] extends [Bun.Encoding]
? L
: L extends `${string}\u212A${string}`
? Bun.Encoding
: Lowercase<TrimEncodingLabel<L>> extends Bun.Encoding
? L
: Bun.Encoding;

/**
* Like Omit, but correctly distributes over unions. Most useful for removing
* properties from union options objects, like {@link Bun.SQL.Options}
Expand Down
13 changes: 13 additions & 0 deletions packages/bun-types/globals.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,15 @@ declare var TextDecoder: Bun.__internal.UseLibDomIfAvailable<
"TextDecoder",
{
prototype: TextDecoder;
// The first signature keeps `<E extends Bun.Encoding>(e: E) => new TextDecoder(e)` compiling and
// resolves a lower-case label with no inference. The generic one takes a label in another ASCII
// case, or with ASCII whitespace around it. A class that extends `TextDecoder` gets the generic
// one with `L` set to its default, so `new Sub("UTF-8")` and `super("UTF-8")` stay errors.
new (encoding?: Bun.Encoding, options?: { fatal?: boolean; ignoreBOM?: boolean }): TextDecoder;
new <const L extends string = Bun.Encoding>(
encoding?: Bun.__internal.EncodingLabel<L>,
options?: { fatal?: boolean; ignoreBOM?: boolean },
): TextDecoder;
Comment thread
robobun marked this conversation as resolved.
Resolved
}
>;

Expand Down Expand Up @@ -1917,7 +1925,12 @@ declare var TextDecoderStream: Bun.__internal.UseLibDomIfAvailable<
"TextDecoderStream",
{
prototype: TextDecoderStream;
// The same two signatures as `TextDecoder`, for the same reasons.
new (encoding?: Bun.Encoding, options?: { fatal?: boolean; ignoreBOM?: boolean }): TextDecoderStream;
new <const L extends string = Bun.Encoding>(
encoding?: Bun.__internal.EncodingLabel<L>,
options?: { fatal?: boolean; ignoreBOM?: boolean },
): TextDecoderStream;
}
>;

Expand Down
61 changes: 61 additions & 0 deletions test/cli/check/text-decoder-takes-a-label-in-any-case.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// `bun compile` type checks what it builds, so a label that runs on Bun and that the declarations of Bun refuse stops the build.
// The project is the one that `bun init` writes: no library for browsers, and the types of Bun.
import { expect, test } from "bun:test";
import { bunExe, tempDirWithBunTypes } from "harness";
import { run } from "../../aot/harness/run";

const tsconfig = JSON.stringify({
compilerOptions: {
lib: ["ESNext"],
target: "ESNext",
module: "Preserve",
moduleDetection: "force",
moduleResolution: "bundler",
types: ["bun"],
strict: true,
skipLibCheck: true,
noEmit: true,
},
});

const program = `console.log(new TextDecoder("UTF-8").encoding, new TextDecoder(" Shift_JIS ", { fatal: true }).encoding);
console.log(new TextDecoderStream("LATIN1").encoding);
`;

const printed = `utf-8 shift_jis
windows-1252
`;

// The first two labels throw on Bun, and so does the label in the tuple and in the class. The third is known only at run time,
// and a constructor type whose label is a string could be given any of them.
const refused = `declare const charset: string;
// @ts-expect-error
new TextDecoder("utf-99");
// @ts-expect-error
new TextDecoder("\\u212Aoi8-r");
// @ts-expect-error
new TextDecoder(charset);
// @ts-expect-error
export const args: ConstructorParameters<typeof TextDecoder> = ["utf-99"];
class Sub extends TextDecoder {}
// @ts-expect-error
new Sub("utf-99");
// @ts-expect-error
export const Alias: new (label: string) => TextDecoder = TextDecoder;
`;

// One check reads every declaration of Bun and of Node.js, which takes a debug build several seconds.
test("TextDecoder takes a label in any ASCII case", async () => {
using dir = tempDirWithBunTypes("check-text-decoder-labels", {
"tsconfig.json": tsconfig,
"index.ts": program,
"refused.ts": refused,
});
expect(await run([bunExe(), "index.ts"], String(dir))).toEqual({ stdout: printed, stderr: "", exitCode: 0 });
const checked = await run([bunExe(), "check", "--no-pretty"], String(dir));
// The summary on stderr has a time in it. Where the check fails, the message shows all three.
expect(checked.exitCode === 0 ? { stdout: checked.stdout, exitCode: 0 } : checked).toEqual({
stdout: "",
exitCode: 0,
});
}, 60_000);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Comment thread
robobun marked this conversation as resolved.
Resolved
56 changes: 55 additions & 1 deletion test/integration/bun-types/bun-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -843,10 +843,51 @@ describe("@types/bun integration test", () => {
new TextDecoder("utf8");
new TextDecoder("latin1");
new TextDecoder("gb18030", { fatal: true, ignoreBOM: true });
// The runtime ignores ASCII case and removes ASCII whitespace from both ends of a label.
new TextDecoder("UTF-8");
new TextDecoder("Shift_JIS", { fatal: true });
new TextDecoder(" \\t\\n\\f\\rutf-8 ");
new TextDecoderStream("LATIN1");
// @ts-expect-error - the TextDecoder constructor rejects the replacement encoding
"hz-gb-2312" satisfies Bun.Encoding;
// @ts-expect-error - not a label the Encoding Standard defines
"utf-99" satisfies Bun.Encoding;`,
"utf-99" satisfies Bun.Encoding;
// @ts-expect-error - not a label the Encoding Standard defines
new TextDecoder("utf-99");
// @ts-expect-error - the TextDecoder constructor rejects the replacement encoding
new TextDecoder("REPLACEMENT");
// @ts-expect-error - the empty label
new TextDecoder("");
// @ts-expect-error - U+000B is not ASCII whitespace
new TextDecoder("\\vutf-8");
// @ts-expect-error - U+212A KELVIN SIGN is not an ASCII "k"
new TextDecoder("\\u212Aoi8-r");
// @ts-expect-error - not a label the Encoding Standard defines
new TextDecoderStream("utf-99");
declare const charset: string;
// @ts-expect-error - a value of type string can be a label that throws
new TextDecoder(charset);
// @ts-expect-error - a value of type string can be a label that throws
new TextDecoderStream(charset);

// A class below TextDecoder takes the lower-case labels only.
class Sub extends TextDecoder {}
new Sub("utf-8");
// @ts-expect-error
new Sub("UTF-8");
// @ts-expect-error
new Sub("utf-99");
class SubStream extends TextDecoderStream {}
// @ts-expect-error
new SubStream("utf-99");
// @ts-expect-error - the parameter tuple rejects it too
export const args: ConstructorParameters<typeof TextDecoder> = ["utf-99"];
// @ts-expect-error - the parameter tuple rejects it too
export const streamArgs: ConstructorParameters<typeof TextDecoderStream> = ["utf-99"];
// @ts-expect-error - a constructor type whose label is a string does not take the class
export const Alias: new (label: string) => TextDecoder = TextDecoder;
// @ts-expect-error - a constructor type whose label is a string does not take the class
export const StreamAlias: new (label: string) => TextDecoderStream = TextDecoderStream;`,
});

await using proc = Bun.spawn({
Expand Down Expand Up @@ -899,6 +940,19 @@ describe("@types/bun integration test", () => {
]) {
expect(() => new TextDecoder(label as Bun.Encoding)).toThrow(RangeError);
}

// The spellings that the check above accepts, then the ones it rejects.
const spellings = ["UTF-8", "Shift_JIS", " \t\n\f\rutf-8 ", "LATIN1"];
const rejectedSpellings = ["utf-99", "REPLACEMENT", "", "\vutf-8", "\u212Aoi8-r"];
const encodingOrError = (label: string) => {
try {
return new TextDecoder(label as Bun.Encoding).encoding;
} catch (e) {
return (e as Error).name;
}
};
expect(spellings.map(encodingOrError)).toEqual(["utf-8", "shift_jis", "utf-8", "windows-1252"]);
expect(rejectedSpellings.map(encodingOrError)).toEqual(rejectedSpellings.map(() => "RangeError"));
});
});

Expand Down
20 changes: 20 additions & 0 deletions test/integration/bun-types/fixture/text-encode-decoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,4 +300,24 @@ new TextEncoder().encodeInto("hello");
// @ts-expect-error - encodeInto throws ERR_MISSING_ARGS without arguments
new TextEncoder().encodeInto();

// The runtime ignores ASCII case and removes ASCII whitespace from both ends of a label.
new TextDecoder("UTF-8");
new TextDecoder(" utf-8 ");
new TextDecoder("Shift_JIS", { fatal: true });
new TextDecoderStream("LATIN1");

function decoderFor<E extends Bun.Encoding>(encoding: E) {
return new TextDecoder(encoding);
}
decoderFor("utf-16le");

function streamFor<E extends Bun.Encoding>(encoding: E) {
return new TextDecoderStream(encoding);
}
streamFor("utf-16le");

// A subclass is assignable to the type of its base class.
export const DecoderClass: typeof TextDecoder = class extends TextDecoder {};
export const StreamClass: typeof TextDecoderStream = class extends TextDecoderStream {};

export {};
Loading