Skip to content
Merged
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
10 changes: 8 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ tokio-test = "0.4.4"
tower-lsp = "0.20.0"
ureq = { version = "3.0.12", features = ["json"] }
url = { version = "2.5.4", features = ["serde", "expose_internals"] }
uuid = { version = "1.17.0", features = ["v4"] }
wgpu = { version = "26.0.1", features = ["wgsl", "webgpu"] }

[profile.release]
Expand Down
32 changes: 32 additions & 0 deletions examples/blob.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
const blob = new Blob(["Hello, ", "World!"], { type: "text/plain" });
console.log(" Blob size:", blob.size);
console.log(" Blob type:", blob.type);
const sliced = blob.slice(0, 5);
console.log(" Sliced size:", sliced.size);

const file = new File(["File content here"], "document.txt", {
type: "text/plain",
lastModified: Date.now() - 10000,
});
console.log(" File name:", file.name);
console.log(" File size:", file.size);
console.log(" File type:", file.type);
console.log(" File lastModified:", file.lastModified);

const fileSliced = file.slice(0, 4);
console.log(" File sliced size:", fileSliced.size);

const formData = new FormData();
formData.append("text", "hello world");
formData.append("file", file);
formData.append("blob", blob, "data.txt");

console.log(" FormData has text:", formData.has("text"));
console.log(" FormData get text:", formData.get("text"));
console.log(" FormData has file:", formData.has("file"));
console.log(" FormData has blob:", formData.has("blob"));

console.log(" FormData keys:");
for (const key of formData.keys()) {
console.log(" -", key);
}
1 change: 1 addition & 0 deletions runtime/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,5 @@ rusqlite = { workspace = true, optional = true, features = ["bundled"] }
signal-hook.workspace = true
lazy_static.workspace = true
thiserror.workspace = true
uuid.workspace = true
wgpu = { workspace = true, optional = true }
199 changes: 199 additions & 0 deletions runtime/src/ext/file/blob.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

/**
* Implementation of the File API Blob interface
* Based on: https://w3c.github.io/FileAPI/#blob-section
* WinterTC Compliance: https://min-common-api.proposal.wintertc.org/
*/

type BlobPart = BufferSource | string | Blob;

interface BlobPropertyBag {
type?: string;
endings?: "transparent" | "native";
}

/**
* Utility function to convert various input types to byte array
*/
function convertBlobPartsToBytes(blobParts: BlobPart[]): number[] {
const bytes: number[] = [];

for (const part of blobParts) {
if (typeof part === "string") {
// Convert string to UTF-8 bytes
const encoder = new TextEncoder();
const stringBytes = encoder.encode(part);
for (let i = 0; i < stringBytes.length; i++) {
bytes.push(stringBytes[i]);
}
} else if (part instanceof Blob) {
// Get bytes from another blob
const partBlob = part as Blob & { _blobId: string };
const blobBytes = internal_blob_get_data(partBlob._blobId);
if (blobBytes) {
const blobByteArray = blobBytes.split(",").map((b) =>
parseInt(b, 10)
).filter((b) => !isNaN(b));
bytes.push(...blobByteArray);
}
} else if (part instanceof ArrayBuffer) {
// Convert ArrayBuffer to bytes
const view = new Uint8Array(part);
for (let i = 0; i < view.length; i++) {
bytes.push(view[i]);
}
} else if (ArrayBuffer.isView(part)) {
// Handle TypedArray views
const view = new Uint8Array(
part.buffer,
part.byteOffset,
part.byteLength,
);
for (let i = 0; i < view.length; i++) {
bytes.push(view[i]);
}
}
}

return bytes;
}

/**
* Blob represents a file-like object of immutable, raw data
*/
class Blob {
#blobId: string;

constructor(
blobParts: BlobPart[] = [],
options: BlobPropertyBag = {},
existingBlobId?: string,
) {
if (existingBlobId) {
// Use existing blob ID (for internal operations like slice)
this.#blobId = existingBlobId;
} else {
// Normal blob creation
const type = options.type || "";

// Validate and normalize type
let normalizedType = "";
if (type) {
// Basic MIME type validation - should be lowercase and ASCII printable
if (
/^[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_.]*$/
.test(type)
) {
normalizedType = type.toLowerCase();
}
}

// Convert blob parts to bytes
const bytes = convertBlobPartsToBytes(blobParts);
const bytesString = bytes.join(",");

// Create blob through native implementation
this.#blobId = internal_blob_create(bytesString, normalizedType);
}
}

/**
* The size of the blob in bytes
*/
get size(): number {
return internal_blob_get_size(this.#blobId);
}

/**
* The MIME type of the blob
*/
get type(): string {
return internal_blob_get_type(this.#blobId);
}

/**
* Returns a new Blob containing the data in the specified range
*/
slice(start?: number, end?: number, contentType?: string): Blob {
const actualStart = start ?? 0;
const actualEnd = end ?? this.size;
const actualContentType = contentType ?? "";

const newBlobId = internal_blob_slice(
this.#blobId,
actualStart,
actualEnd,
actualContentType,
);

// Create a new Blob instance with the sliced blob ID
return new Blob([], {}, newBlobId);
}

/**
* Returns a ReadableStream of the blob data
*/
stream(): ReadableStream<Uint8Array> {
// TODO: return a proper ReadableStream
const data = internal_blob_stream(this.#blobId);
const bytes = data
? data.split(",").map((b) => parseInt(b, 10)).filter((b) =>
!isNaN(b)
)
: [];
const uint8Array = new Uint8Array(bytes);

return new ReadableStream({
start(controller) {
controller.enqueue(uint8Array);
controller.close();
},
});
}

/**
* Returns a Promise that resolves with the blob data as an ArrayBuffer
*/
arrayBuffer(): Promise<ArrayBuffer> {
return new Promise((resolve) => {
const data = internal_blob_array_buffer(this.#blobId);
const bytes = data
? data.split(",").map((b) => parseInt(b, 10)).filter((b) =>
!isNaN(b)
)
: [];

const buffer = new ArrayBuffer(bytes.length);
const view = new Uint8Array(buffer);
for (let i = 0; i < bytes.length; i++) {
view[i] = bytes[i];
}

resolve(buffer);
});
}

/**
* Returns a Promise that resolves with the blob data as a string
*/
text(): Promise<string> {
return new Promise((resolve) => {
resolve(internal_blob_text(this.#blobId));
});
}

/**
* Returns the blob ID (internal method for File implementation)
*/
get [Symbol.toStringTag]() {
return "Blob";
}

// Internal accessor for other implementations
get _blobId(): string {
return this.#blobId;
}
}
103 changes: 103 additions & 0 deletions runtime/src/ext/file/file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// deno-lint-ignore-file no-unused-vars
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

/**
* Implementation of the File API File interface
* Based on: https://w3c.github.io/FileAPI/#file-section
* WinterTC Compliance: https://min-common-api.proposal.wintertc.org/
*/

interface FilePropertyBag extends BlobPropertyBag {
lastModified?: number;
}

/**
* File represents a file-like object of immutable, raw data that implements the Blob interface
*/
class File {
#blob: Blob;
#name: string;
#lastModified: number;

constructor(fileBits: BlobPart[], fileName: string, options: FilePropertyBag = {}) {
// Create the underlying blob
this.#blob = new Blob(fileBits, { type: options.type || "" });

// Set file-specific properties
this.#name = fileName;
this.#lastModified = options.lastModified ?? Date.now();
}

/**
* The size of the file in bytes
*/
get size(): number {
return this.#blob.size;
}

/**
* The MIME type of the file
*/
get type(): string {
return this.#blob.type;
}

/**
* The name of the file
*/
get name(): string {
return this.#name;
}

/**
* The last modified timestamp of the file in milliseconds since Unix epoch
*/
get lastModified(): number {
return this.#lastModified;
}

/**
* The last modified date as a Date object
*/
get lastModifiedDate(): Date {
return new Date(this.#lastModified);
}

/**
* Returns a new Blob containing the data in the specified range
*/
slice(start?: number, end?: number, contentType?: string): Blob {
// Delegate to the underlying blob's slice method
return this.#blob.slice(start, end, contentType);
}

/**
* Returns a Promise that resolves with the contents of the blob as text
*/
async text(): Promise<string> {
return await this.#blob.text();
}

/**
* Returns a Promise that resolves with the contents of the blob as an ArrayBuffer
*/
async arrayBuffer(): Promise<ArrayBuffer> {
return await this.#blob.arrayBuffer();
}

/**
* Returns a ReadableStream of the blob's data
*/
stream(): ReadableStream<Uint8Array> {
return this.#blob.stream();
}

/**
* Returns the string tag for Object.prototype.toString
*/
get [Symbol.toStringTag]() {
return "File";
}
}
Loading
Loading