diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig
index 932c3655..ffc0a19f 100644
--- a/src/browser/js/bridge.zig
+++ b/src/browser/js/bridge.zig
@@ -827,6 +827,7 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/encoding/TextDecoder.zig"),
@import("../webapi/encoding/TextEncoder.zig"),
@import("../webapi/encoding/TextEncoderStream.zig"),
+ @import("../webapi/encoding/TextDecoderStream.zig"),
@import("../webapi/Event.zig"),
@import("../webapi/event/CompositionEvent.zig"),
@import("../webapi/event/CustomEvent.zig"),
diff --git a/src/browser/tests/streams/text_decoder_stream.html b/src/browser/tests/streams/text_decoder_stream.html
new file mode 100644
index 00000000..37bae8b4
--- /dev/null
+++ b/src/browser/tests/streams/text_decoder_stream.html
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/webapi/encoding/TextDecoderStream.zig b/src/browser/webapi/encoding/TextDecoderStream.zig
new file mode 100644
index 00000000..2c4605ed
--- /dev/null
+++ b/src/browser/webapi/encoding/TextDecoderStream.zig
@@ -0,0 +1,107 @@
+// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const js = @import("../../js/js.zig");
+const Page = @import("../../Page.zig");
+
+const ReadableStream = @import("../streams/ReadableStream.zig");
+const WritableStream = @import("../streams/WritableStream.zig");
+const TransformStream = @import("../streams/TransformStream.zig");
+
+const TextDecoderStream = @This();
+
+_transform: *TransformStream,
+_fatal: bool,
+_ignore_bom: bool,
+
+const Label = enum {
+ utf8,
+ @"utf-8",
+ @"unicode-1-1-utf-8",
+};
+
+const InitOpts = struct {
+ fatal: bool = false,
+ ignoreBOM: bool = false,
+};
+
+pub fn init(label_: ?[]const u8, opts_: ?InitOpts, page: *Page) !TextDecoderStream {
+ if (label_) |label| {
+ _ = std.meta.stringToEnum(Label, label) orelse return error.RangeError;
+ }
+
+ const opts = opts_ orelse InitOpts{};
+ const transform = try TransformStream.initWithZigTransform(&decodeTransform, page);
+ return .{
+ ._transform = transform,
+ ._fatal = opts.fatal,
+ ._ignore_bom = opts.ignoreBOM,
+ };
+}
+
+fn decodeTransform(controller: *TransformStream.DefaultController, chunk: js.Value) !void {
+ // chunk should be a Uint8Array; decode it as UTF-8 string
+ const typed_array = try chunk.toZig(js.TypedArray(u8));
+ var input = typed_array.values;
+
+ // Strip UTF-8 BOM if present
+ if (std.mem.startsWith(u8, input, &.{ 0xEF, 0xBB, 0xBF })) {
+ input = input[3..];
+ }
+
+ try controller.enqueue(.{ .string = input });
+}
+
+pub fn getReadable(self: *const TextDecoderStream) *ReadableStream {
+ return self._transform.getReadable();
+}
+
+pub fn getWritable(self: *const TextDecoderStream) *WritableStream {
+ return self._transform.getWritable();
+}
+
+pub fn getFatal(self: *const TextDecoderStream) bool {
+ return self._fatal;
+}
+
+pub fn getIgnoreBOM(self: *const TextDecoderStream) bool {
+ return self._ignore_bom;
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(TextDecoderStream);
+
+ pub const Meta = struct {
+ pub const name = "TextDecoderStream";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const constructor = bridge.constructor(TextDecoderStream.init, .{});
+ pub const encoding = bridge.property("utf-8", .{ .template = false });
+ pub const readable = bridge.accessor(TextDecoderStream.getReadable, null, .{});
+ pub const writable = bridge.accessor(TextDecoderStream.getWritable, null, .{});
+ pub const fatal = bridge.accessor(TextDecoderStream.getFatal, null, .{});
+ pub const ignoreBOM = bridge.accessor(TextDecoderStream.getIgnoreBOM, null, .{});
+};
+
+const testing = @import("../../../testing.zig");
+test "WebApi: TextDecoderStream" {
+ try testing.htmlRunner("streams/text_decoder_stream.html", .{});
+}