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
3 changes: 3 additions & 0 deletions src/runtime/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ pub enum Error {
AsyncModule,
#[error("BlobNotFound")]
BlobNotFound,
#[error("BlobNotInMemory")]
BlobNotInMemory,
#[error("InvalidRoutePattern")]
InvalidRoutePattern,
#[error("InvalidRequest")]
Expand Down Expand Up @@ -401,6 +403,7 @@ impl Error {
Self::UnexpectedPendingResolution => "UnexpectedPendingResolution",
Self::AsyncModule => "AsyncModule",
Self::BlobNotFound => "BlobNotFound",
Self::BlobNotInMemory => "BlobNotInMemory",
Self::InvalidRoutePattern => "InvalidRoutePattern",
Self::InvalidRequest => "InvalidRequest",
Self::FailedToCreateCoreFoudationSourceLoop => "FailedToCreateCoreFoudationSourceLoop",
Expand Down
34 changes: 31 additions & 3 deletions src/runtime/jsc_hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3972,12 +3972,20 @@ unsafe fn get_loader_and_virtual_source<'a>(
if blob.needs_to_read_file() {
path = Fs::Path::init(filename);
}
} else if blob.needs_to_read_file() {
// A `Bun.file(fd)` has no path to read the module from.
return Err(crate::Error::BlobNotInMemory);
}

if !blob.needs_to_read_file() {
virtual_source = Some(virtual_source_to_use.insert(
bun_ast::Source::init_path_string(path.text, blob.shared_view()),
));
// An S3-backed Blob has no bytes in memory.
let Some(view) = blob.in_memory_view() else {
return Err(crate::Error::BlobNotInMemory);
Comment thread
robobun marked this conversation as resolved.
};
virtual_source = Some(
virtual_source_to_use
.insert(bun_ast::Source::init_path_string(path.text, view)),
);
}
}
None => return Err(crate::Error::BlobNotFound),
Expand Down Expand Up @@ -4063,6 +4071,21 @@ fn intern_transpile_path(value: &[u8]) -> &'static [u8] {
/// Modules that must always transpile on-thread (see [`Bun__transpileFile`]).
const ALWAYS_SYNC_MODULES: &[&[u8]] = &[b"reflect-metadata"];

/// What a module load from a `blob:` URL throws when the Blob is not in memory.
#[cold]
#[inline(never)]
fn blob_not_in_memory_error(global: &JSGlobalObject, specifier: &[u8]) -> JSValue {
global
.err(
bun_jsc::ErrCode::ERR_INVALID_ARG_TYPE,
format_args!(
"Cannot load a module from {}: the Blob is S3-backed or file-descriptor-backed, so its bytes are not in memory. Read it first: URL.createObjectURL(new File([await blob.bytes()], blob.name ?? \"\", {{ type: blob.type }}))",
bun_core::fmt::quote(specifier),
),
)
.to_js()
}

/// Concurrent-transpiler entry. Returns the in-flight `JSInternalPromise*`
/// when `allow_promise && async`, else null (result is in `*ret`).
///
Expand Down Expand Up @@ -4120,6 +4143,11 @@ pub(crate) unsafe extern "C" fn Bun__transpileFile(
)
} {
Ok(lr) => lr,
Err(crate::Error::BlobNotInMemory) => {
*ret =
ErrorableResolvedSource::err(blob_not_in_memory_error(global, _specifier.slice()));
return ptr::null_mut();
}
Err(_) => {
let js = global
.err(
Expand Down
170 changes: 169 additions & 1 deletion test/js/web/fetch/blob.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { S3Client } from "bun";
import { estimateShallowMemoryUsageOf } from "bun:jsc";
import { describe, expect, test } from "bun:test";
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
import { bunEnv, bunExe, gcTick, isASAN, tempDir } from "harness";
import type { BlobPropertyBag } from "node:buffer";
import type { BinaryLike } from "node:crypto";
import { closeSync, openSync } from "node:fs";
import Module, { createRequire } from "node:module";
import path from "node:path";
import vm from "node:vm";

test("blob: imports have sourcemapped stacktraces", async () => {
const blob = new Blob(
Expand Down Expand Up @@ -164,6 +168,170 @@ test("blob: can be imported", async () => {
}).toThrow();
});

describe("blob: URL of a Blob that is not in memory, loaded as a module", () => {
// A local server is the S3 endpoint. It records every request.
const objects: Record<string, string> = {
"/bucket/module.mjs": "export default 42;\n",
"/bucket/data.json": `{ "answer": 42 }`,
"/bucket/note.txt": "forty-two",
"/bucket/config.toml": "answer = 42\n",
"/bucket/after": "after",
};
const requests: string[] = [];
const urls: string[] = [];
let server: Bun.Server<undefined>;
let endpoint: Bun.S3Options;
let s3: S3Client;
let fd: number;

beforeAll(() => {
server = Bun.serve({
port: 0,
fetch(request) {
const { pathname } = new URL(request.url);
requests.push(`${request.method} ${pathname}`);
return new Response(objects[pathname]);
},
});
endpoint = { endpoint: server.url.href, accessKeyId: "test", secretAccessKey: "test", bucket: "bucket" };
s3 = new S3Client(endpoint);
fd = openSync(path.join(import.meta.dir, "fixtures", "file.txt"), "r");
});
afterEach(() => {
for (const url of urls.splice(0)) URL.revokeObjectURL(url);
requests.length = 0;
});
afterAll(async () => {
closeSync(fd);
await server.stop(true);
});

function urlOf(blob: Blob) {
const url = URL.createObjectURL(blob);
urls.push(url);
return url;
}
// A module in memory that loads `url` with an import statement.
const importerOf = (url: string) =>
urlOf(new File([`export { default } from ${JSON.stringify(url)};`], "importer.mjs"));
// What a load gives: the module, or the error.
async function outcome(load: () => unknown) {
try {
return { loaded: await load() };
} catch (e: any) {
return { name: e.name, code: e.code, message: e.message };
}
}
// A request that a load started reaches the endpoint before this read is answered.
async function requestsAfterOneRead() {
expect(await s3.file("after").text()).toBe("after");
return requests;
}
const refused = (url: string) => ({
name: "TypeError",
code: "ERR_INVALID_ARG_TYPE",
message: `Cannot load a module from "${url}": the Blob is S3-backed or file-descriptor-backed, so its bytes are not in memory. Read it first: URL.createObjectURL(new File([await blob.bytes()], blob.name ?? "", { type: blob.type }))`,
});

test.each<[string, (url: string) => unknown]>([
["import()", url => import(url)],
["import() with { type: 'text' }", url => import(url, { with: { type: "text" } })],
["import() with { type: 'file' }", url => import(url, { with: { type: "file" } })],
["an import statement", url => import(importerOf(url))],
["require()", url => require(url)],
["import.meta.require()", url => import.meta.require(url)],
["createRequire()", url => createRequire(import.meta.url)(url)],
["require() of a module that imports it", url => require(importerOf(url))],
["require.extensions['.js']()", url => require.extensions[".js"](new Module(url), url)],
[
"Bun.ModuleGraph#import()",
async url => {
using graph = new Bun.ModuleGraph();
return await graph.import(url);
},
],
[
"node:vm with the default loader",
url =>
vm.runInThisContext(`import(${JSON.stringify(url)})`, {
importModuleDynamically: vm.constants.USE_MAIN_CONTEXT_DEFAULT_LOADER,
}),
],
])("%s refuses an S3File and sends no request", async (_, load) => {
const url = urlOf(s3.file("module.mjs"));
expect(await outcome(() => load(url))).toEqual(refused(url));
expect(await requestsAfterOneRead()).toEqual(["GET /bucket/after"]);
});

test.each<[string, () => Blob, Record<string, string>?]>([
["a slice of an S3File", () => s3.file("module.mjs").slice(4)],
["a zero-length slice of an S3File", () => s3.file("module.mjs").slice(0, 0)],
["a File made of one S3File", () => new File([s3.file("module.mjs")], "module.mjs")],
["an S3File with a JSON key", () => s3.file("data.json")],
["an S3File with a CSS key", () => s3.file("style.css")],
["Bun.file() of an s3:// URL", () => Bun.file("s3://bucket/module.mjs", endpoint)],
["Bun.file(fd)", () => Bun.file(fd)],
["Bun.file(fd) with { type: 'css' }", () => Bun.file(fd), { type: "css" }],
["a File made of one Bun.file(fd)", () => new File([Bun.file(fd)], "module.mjs")],
])("import() refuses %s and sends no request", async (_, blob, attributes = {}) => {
const url = urlOf(blob());
expect(await outcome(() => import(url, { with: attributes }))).toEqual(refused(url));
expect(await requestsAfterOneRead()).toEqual(["GET /bucket/after"]);
});

test("the module loads after the Blob is read, as the error message says", async () => {
using dir = tempDir("blob-url-module-read-first", {
"module": "export default 42;\n",
"object": `{ "answer": 42 }`,
"array": "[1, 2, 3]",
});
// The expression that the error message gives.
const load = async (blob: Bun.BunFile | Bun.S3File) => {
const url = urlOf(new File([await blob.bytes()], blob.name ?? "", { type: blob.type }));
return { ...(await import(url)) };
};
const loadFd = async (name: string, type?: string) => {
const fd = openSync(path.join(String(dir), name), "r");
try {
return await load(Bun.file(fd, { type }));
} finally {
closeSync(fd);
}
};
expect({
"S3File with an .mjs key": await load(s3.file("module.mjs")),
"S3File with a .json key": await load(s3.file("data.json")),
"S3File with a .txt key": await load(s3.file("note.txt")),
"S3File with a .toml key": await load(s3.file("config.toml")),
"Bun.file(fd)": await loadFd("module"),
"Bun.file(fd) of a JSON object": await loadFd("object", "application/json"),
"Bun.file(fd) of a JSON array": await loadFd("array", "application/json"),
}).toEqual({
"S3File with an .mjs key": { default: 42 },
"S3File with a .json key": { default: { answer: 42 }, answer: 42 },
"S3File with a .txt key": { default: "forty-two" },
"S3File with a .toml key": { default: { answer: 42 }, answer: 42 },
"Bun.file(fd)": { default: 42 },
"Bun.file(fd) of a JSON object": { default: { answer: 42 }, answer: 42 },
"Bun.file(fd) of a JSON array": { default: [1, 2, 3], __esModule: true },
});
expect(requests).toEqual([
"GET /bucket/module.mjs",
"GET /bucket/data.json",
"GET /bucket/note.txt",
"GET /bucket/config.toml",
]);
});

test("an empty Blob and a Bun.file(path) still load", async () => {
using dir = tempDir("blob-url-module", { "module.mjs": "export default 42;\n" });
expect({
empty: { ...(await import(urlOf(new Blob([])))) },
file: { ...(await import(urlOf(Bun.file(path.join(String(dir), "module.mjs"))))) },
}).toEqual({ empty: {}, file: { default: 42 } });
});
});

test("blob: can reliable get type from fetch #10072", async () => {
using server = Bun.serve({
fetch() {
Expand Down
88 changes: 87 additions & 1 deletion test/js/web/workers/worker_blob.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect, test } from "bun:test";
import { S3Client } from "bun";
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";

test("Worker from a Blob", async () => {
Expand Down Expand Up @@ -228,3 +229,88 @@ test("object URL created in one worker is usable and revocable across threads",
expect(stdout.trim()).toBe('{"count":100,"ok":true}\na exited 0');
expect(exitCode).toBe(0);
});

describe("blob: URL of an S3File in a Worker", () => {
// Stands in for S3 on the loopback, and records each request.
function s3StandIn() {
const requests: string[] = [];
const server = Bun.serve({
port: 0,
hostname: "127.0.0.1",
fetch(request) {
requests.push(`${request.method} ${new URL(request.url).pathname}`);
return new Response(`postMessage("from-s3");`);
},
});
const client = new S3Client({
endpoint: server.url.href,
accessKeyId: "test",
secretAccessKey: "test",
bucket: "bucket",
});
return {
file: (key: string) => client.file(key),
// A request that an earlier call started reaches the stand-in before this one is answered.
async requestsAfterOneRead() {
expect(await client.file("after").text()).toBe(`postMessage("from-s3");`);
return requests;
},
[Symbol.dispose]: () => void server.stop(true),
};
}
const refusal = (url: string) =>
`Cannot load a module from "${url}": the Blob is S3-backed or file-descriptor-backed, so its bytes are not in memory. Read it first: URL.createObjectURL(new File([await blob.bytes()], blob.name ?? "", { type: blob.type }))`;

test.each(["worker.mjs", "worker.css"])(
"as the entry point, a %s key gives the Worker an error event and sends no request",
async key => {
using s3 = s3StandIn();
using cleanup = new DisposableStack();
const url = cleanup.adopt(URL.createObjectURL(s3.file(key)), URL.revokeObjectURL);
const worker = cleanup.adopt(new Worker(url), worker => worker.terminate());
const { promise, resolve } = Promise.withResolvers<Event>();
worker.onerror = worker.onmessage = resolve;
worker.addEventListener("close", resolve);
const event = await promise;

// The message of the event is the printed error. Its first line has the name and the message.
expect({ type: event.type, message: (event as ErrorEvent).message?.split("\n")[0] }).toEqual({
type: "error",
message: `TypeError: ${refusal(url)}`,
});
expect(await s3.requestsAfterOneRead()).toEqual(["GET /bucket/after"]);
},
);

test("import() in a Worker refuses it and sends no request", async () => {
using s3 = s3StandIn();
using cleanup = new DisposableStack();
const url = cleanup.adopt(URL.createObjectURL(s3.file("module.mjs")), URL.revokeObjectURL);
const script = cleanup.adopt(
URL.createObjectURL(
new Blob(
[
`self.onmessage = async ({ data: url }) => {
postMessage(
await import(url).then(
module => ({ loaded: { ...module } }),
error => ({ name: error.name, code: error.code, message: error.message }),
),
);
};`,
],
{ type: "application/javascript" },
),
),
URL.revokeObjectURL,
);
const worker = cleanup.adopt(new Worker(script), worker => worker.terminate());
const { promise, resolve, reject } = Promise.withResolvers<unknown>();
worker.onmessage = event => resolve(event.data);
worker.onerror = event => reject(new Error(event.message));
worker.postMessage(url);

expect(await promise).toEqual({ name: "TypeError", code: "ERR_INVALID_ARG_TYPE", message: refusal(url) });
expect(await s3.requestsAfterOneRead()).toEqual(["GET /bucket/after"]);
});
});
Loading