Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
c49c235c59 Implement Blob.prototype.lines()
This adds Blob.prototype.lines() which returns a ReadableStream that yields
lines from the blob chunk by chunk.

Implementation:
- Created BlobObject.ts with lines() method that returns ReadableStream<string>
- Uses Bun.indexOfLine for efficient line detection
- Uses node:string_decoder for UTF-8 decoding
- Handles CRLF line endings (strips \r on Windows)
- Created blob.classes.ts to register Blob methods with codegen system
- Added getLines() function in Blob.zig to call the builtin
- Added comprehensive test suite

Note: Cannot test due to unrelated compilation errors in ZigGlobalObject.h
that are blocking builds. The implementation follows established patterns
and should work once the build issues are resolved.

🤖 Generated with Claude Code
2025-10-22 23:08:57 +00:00
4 changed files with 303 additions and 0 deletions

View File

@@ -2011,6 +2011,18 @@ pub fn getStream(
return stream;
}
extern fn blobObjectLinesCodeGenerator(*jsc.VM) *jsc.FunctionExecutable;
pub fn getLines(
_: *Blob,
globalThis: *jsc.JSGlobalObject,
callframe: *jsc.CallFrame,
) bun.JSError!jsc.JSValue {
const thisValue = callframe.this();
const lines_fn = jsc.JSFunction.create(globalThis, "lines", blobObjectLinesCodeGenerator(globalThis.vm()), 0, .{});
return lines_fn.callWithThis(globalThis, thisValue, &.{});
}
pub fn toStreamWithOffset(
globalThis: *jsc.JSGlobalObject,
callframe: *jsc.CallFrame,

View File

@@ -0,0 +1,32 @@
import { define } from "../../codegen/class-definitions";
export default [
define({
name: "Blob",
construct: true,
finalize: true,
configurable: false,
estimatedSize: true,
structuredClone: true,
klass: {},
proto: {
arrayBuffer: { fn: "getArrayBuffer", async: true },
bytes: { fn: "getBytes", async: true },
exists: { fn: "getExists", async: true },
formData: { fn: "getFormData", async: true },
json: { fn: "getJSON", async: true },
lastModified: { getter: "getLastModified" },
name: { accessor: { getter: "getName", setter: "setName" }, this: true },
size: { getter: "getSize" },
slice: { fn: "getSlice" },
stat: { fn: "getStat", async: true },
stream: { fn: "getStream" },
text: { fn: "getText", async: true },
type: { getter: "getType" },
unlink: { fn: "doUnlink", async: true },
write: { fn: "doWrite", async: true },
writer: { fn: "getWriter" },
lines: { fn: "getLines" },
},
}),
];

View File

@@ -0,0 +1,72 @@
// @internal
$overriddenName = "lines";
export function lines(this: Blob) {
const stream = this.stream();
const { StringDecoder } = require("node:string_decoder");
const decoder = new StringDecoder("utf-8");
const indexOf = Bun.indexOfLine;
return new ReadableStream({
async start(controller) {
const reader = stream.getReader();
let pendingChunk: Uint8Array | undefined;
try {
while (true) {
const firstResult = reader.readMany();
let done: boolean;
let value: Uint8Array[];
if ($isPromise(firstResult)) {
({ done, value } = await firstResult);
} else {
({ done, value } = firstResult);
}
if (done) {
if (pendingChunk && pendingChunk.length > 0) {
const finalLine = decoder.write(pendingChunk);
if (finalLine) {
controller.enqueue(finalLine);
}
}
controller.close();
return;
}
// process chunks line-by-line
const value_len = value.length;
for (let idx = 0; idx < value_len; idx++) {
let actualChunk = value[idx];
if (pendingChunk) {
actualChunk = Buffer.concat([pendingChunk, actualChunk]);
pendingChunk = undefined;
}
let last = 0;
let i = indexOf(actualChunk, last);
while (i !== -1) {
controller.enqueue(
decoder.write(
actualChunk.subarray(
last,
process.platform === "win32" ? (actualChunk[i - 1] === 0x0d /* \r */ ? i - 1 : i) : i,
),
),
);
last = i + 1;
i = indexOf(actualChunk, last);
}
pendingChunk = actualChunk.subarray(last);
}
}
} catch (e) {
controller.error(e);
} finally {
reader.releaseLock();
}
},
});
}

View File

@@ -0,0 +1,187 @@
import { expect, test } from "bun:test";
import { tempDir } from "harness";
test("Blob.prototype.lines() - basic functionality", async () => {
const blob = new Blob(["line1\nline2\nline3"]);
const lines = blob.lines();
expect(lines).toBeInstanceOf(ReadableStream);
const reader = lines.getReader();
const results: string[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
results.push(value);
}
expect(results).toEqual(["line1", "line2", "line3"]);
});
test("Blob.prototype.lines() - empty blob", async () => {
const blob = new Blob([""]);
const lines = blob.lines();
const reader = lines.getReader();
const { done } = await reader.read();
expect(done).toBe(true);
});
test("Blob.prototype.lines() - single line no newline", async () => {
const blob = new Blob(["single line"]);
const lines = blob.lines();
const reader = lines.getReader();
const { value: line1, done: done1 } = await reader.read();
expect(line1).toBe("single line");
expect(done1).toBe(false);
const { done: done2 } = await reader.read();
expect(done2).toBe(true);
});
test("Blob.prototype.lines() - CRLF line endings", async () => {
const blob = new Blob(["line1\r\nline2\r\nline3"]);
const lines = blob.lines();
const reader = lines.getReader();
const results: string[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
results.push(value);
}
// On Windows, \r should be stripped, on other platforms it should be kept
if (process.platform === "win32") {
expect(results).toEqual(["line1", "line2", "line3"]);
} else {
expect(results).toEqual(["line1\r", "line2\r", "line3"]);
}
});
test("Blob.prototype.lines() - mixed line endings", async () => {
const blob = new Blob(["line1\nline2\r\nline3\n"]);
const lines = blob.lines();
const reader = lines.getReader();
const results: string[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
results.push(value);
}
if (process.platform === "win32") {
expect(results).toEqual(["line1", "line2", "line3"]);
} else {
expect(results).toEqual(["line1", "line2\r", "line3"]);
}
});
test("Blob.prototype.lines() - trailing newline", async () => {
const blob = new Blob(["line1\nline2\n"]);
const lines = blob.lines();
const reader = lines.getReader();
const results: string[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
results.push(value);
}
expect(results).toEqual(["line1", "line2"]);
});
test("Blob.prototype.lines() - multiple newlines", async () => {
const blob = new Blob(["line1\n\n\nline2"]);
const lines = blob.lines();
const reader = lines.getReader();
const results: string[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
results.push(value);
}
expect(results).toEqual(["line1", "", "", "line2"]);
});
test("Blob.prototype.lines() - large blob", async () => {
const lines_array = Array.from({ length: 1000 }, (_, i) => `line ${i}`);
const blob = new Blob([lines_array.join("\n")]);
const lines = blob.lines();
const reader = lines.getReader();
const results: string[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
results.push(value);
}
expect(results).toEqual(lines_array);
});
test("Blob.prototype.lines() - UTF-8 content", async () => {
const blob = new Blob(["Hello 世界\n你好 world\nこんにちは"]);
const lines = blob.lines();
const reader = lines.getReader();
const results: string[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
results.push(value);
}
expect(results).toEqual(["Hello 世界", "你好 world", "こんにちは"]);
});
test("Blob.prototype.lines() - chunked data", async () => {
const blob = new Blob(["chunk1\n", "chunk2\nchunk3"]);
const lines = blob.lines();
const reader = lines.getReader();
const results: string[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
results.push(value);
}
expect(results).toEqual(["chunk1", "chunk2", "chunk3"]);
});
test("Blob.prototype.lines() - for await iteration", async () => {
const blob = new Blob(["line1\nline2\nline3"]);
const lines = blob.lines();
const results: string[] = [];
for await (const line of lines) {
results.push(line);
}
expect(results).toEqual(["line1", "line2", "line3"]);
});
test("Blob.prototype.lines() - file blob", async () => {
using dir = tempDir("blob-lines-test", {
"test.txt": "file line 1\nfile line 2\nfile line 3",
});
const file = Bun.file(String(dir) + "/test.txt");
const lines = file.lines();
const reader = lines.getReader();
const results: string[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
results.push(value);
}
expect(results).toEqual(["file line 1", "file line 2", "file line 3"]);
});