Compare commits

..

1 Commits

Author SHA1 Message Date
Dylan Conway
6c7e97231b fix(bundler): barrel optimization drops exports used by dynamic import (#27695)
## What does this PR do?

Fixes invalid JS output when a `sideEffects: false` barrel is used by
both a **static named import** and a **dynamic `import()`** in the same
build with `--splitting --format=esm`.

## Root Cause

`src/bundler/barrel_imports.zig` had a heuristic that skipped escalating
`requested_exports` to `.all` for `import()` when the target already had
a partial entry from a static import:

```zig
} else if (ir.kind == .dynamic) {
    // Only escalate to .all if no prior requests exist for this target.
    if (!this.requested_exports.contains(target)) {
        try this.requested_exports.put(this.allocator(), target, .all);
    }
}
```

This is unsafe — `await import()` returns the **full module namespace**
at runtime. The consumer can destructure or access any export and we
can't statically determine which ones.

## Failure chain

1. Static `import { a } from "barrel"` seeds `requested_exports[barrel]
= .partial{"a"}`
2. Dynamic `import("barrel")` is ignored because
`requested_exports.contains(barrel)` is true
3. Barrel optimization marks `export { b } from "./b.js"` as `is_unused`
→ `source_index` cleared
4. Code splitting makes the barrel a chunk entry point (dynamic import →
own chunk)
5. Linker can't resolve the barrel's re-export symbol for `b` → uses the
unbound re-export ref directly
6. Output: `export { b2 as b }` where `b2` has no declaration →
**SyntaxError at runtime**

With `--bytecode --compile` this manifests as an `OutputFileListBuilder`
assertion (`total_insertions != output_files.items.len`) because JSC
rejects the chunk during bytecode generation, leaving an unfilled slot.

## Real-world trigger

`@smithy/credential-provider-imds` is a `sideEffects: false` barrel used
by:
- `@aws-sdk/credential-providers` — static: `import {
fromInstanceMetadata } from "@smithy/credential-provider-imds"`
- `@smithy/util-defaults-mode-node` — dynamic: `const {
getInstanceMetadataEndpoint, httpRequest } = await
import("@smithy/credential-provider-imds")`

## Fix

Dynamic import **always** marks the target as `.all` in both Phase 1
seeding and Phase 2 BFS. This is the same treatment as `require()`.

## Tests

- **New:** `barrel/DynamicImportWithStaticImportSameTarget` — repros the
bug with `--splitting`, verifies both exports work at runtime. Fails on
system bun with `SyntaxError: Exported binding 'b' needs to refer to a
top-level declared variable.`
- **Updated:** `barrel/DynamicImportInSubmodule` — was testing that a
fire-and-forget `import()` doesn't force unused submodules to load. That
optimization can't be safely applied, so the test now verifies the
conservative (correct) behavior: all barrel exports are preserved.
2026-03-02 14:33:22 -08:00
10 changed files with 123 additions and 173 deletions

View File

@@ -42,9 +42,6 @@
#include <JavaScriptCore/ExceptionScope.h>
#include <JavaScriptCore/FunctionConstructor.h>
#include <JavaScriptCore/Heap.h>
#include <JavaScriptCore/Integrity.h>
#include <JavaScriptCore/MarkedBlock.h>
#include <JavaScriptCore/PreciseAllocation.h>
#include <JavaScriptCore/Identifier.h>
#include <JavaScriptCore/InitializeThreading.h>
#include <JavaScriptCore/IteratorOperations.h>
@@ -2418,18 +2415,6 @@ extern "C" napi_status napi_typeof(napi_env env, napi_value val,
if (value.isCell()) {
JSCell* cell = value.asCell();
// Validate that the cell pointer is a real GC-managed object.
// Native modules may accidentally pass garbage (e.g. a C string pointer)
// as napi_value, which would crash when we dereference the cell.
// isSanePointer rejects obviously invalid addresses (null-near, non-canonical).
// The bloom filter provides fast rejection of pointers not in any known
// MarkedBlock, using only pointer arithmetic (no dereference).
if (!JSC::Integrity::isSanePointer(cell)
|| (!JSC::PreciseAllocation::isPreciseAllocation(cell)
&& toJS(env)->vm().heap.objectSpace().blocks().filter().ruleOut(
std::bit_cast<uintptr_t>(JSC::MarkedBlock::blockFor(cell))))) [[unlikely]]
return napi_set_last_error(env, napi_invalid_arg);
switch (cell->type()) {
case JSC::JSFunctionType:
case JSC::InternalFunctionType:

View File

@@ -301,10 +301,9 @@ pub fn scheduleBarrelDeferredImports(this: *BundleV2, result: *ParseTask.Result.
// Handle import records without named bindings (not in named_imports).
// - `import "x"` (bare statement): tree-shakeable with sideEffects: false — skip.
// - `require("x")`: synchronous, needs full module — always mark as .all.
// - `import("x")`: mark as .all ONLY if the barrel has no prior requests,
// meaning this is the sole reference. If the barrel already has a .partial
// entry from a static import, the dynamic import is likely a secondary
// (possibly circular) reference and should not escalate requirements.
// - `import("x")`: returns the full module namespace at runtime — consumer
// can destructure or access any export. Must mark as .all. We cannot
// safely assume which exports will be used.
for (file_import_records.slice(), 0..) |ir, idx| {
const target = if (ir.source_index.isValid())
ir.source_index.get()
@@ -319,10 +318,9 @@ pub fn scheduleBarrelDeferredImports(this: *BundleV2, result: *ParseTask.Result.
const gop = try this.requested_exports.getOrPut(this.allocator(), target);
gop.value_ptr.* = .all;
} else if (ir.kind == .dynamic) {
// Only escalate to .all if no prior requests exist for this target.
if (!this.requested_exports.contains(target)) {
try this.requested_exports.put(this.allocator(), target, .all);
}
// import() returns the full module namespace — must preserve all exports.
const gop = try this.requested_exports.getOrPut(this.allocator(), target);
gop.value_ptr.* = .all;
}
}
@@ -354,8 +352,8 @@ pub fn scheduleBarrelDeferredImports(this: *BundleV2, result: *ParseTask.Result.
}
}
// Add bare require/dynamic-import targets to BFS as star imports (matching
// the seeding logic above — require always, dynamic only when sole reference).
// Add bare require/dynamic-import targets to BFS as star imports — both
// always need the full namespace.
for (file_import_records.slice(), 0..) |ir, idx| {
const target = if (ir.source_index.isValid())
ir.source_index.get()
@@ -366,8 +364,7 @@ pub fn scheduleBarrelDeferredImports(this: *BundleV2, result: *ParseTask.Result.
if (ir.flags.is_internal) continue;
if (named_ir_indices.contains(@intCast(idx))) continue;
if (ir.flags.was_originally_bare_import) continue;
const is_all = if (this.requested_exports.get(target)) |re| re == .all else false;
const should_add = ir.kind == .require or (ir.kind == .dynamic and is_all);
const should_add = ir.kind == .require or ir.kind == .dynamic;
if (should_add) {
try queue.append(queue_alloc, .{ .barrel_source_index = target, .alias = "", .is_star = true });
}

View File

@@ -167,18 +167,8 @@ fn onClose(this: *WindowsNamedPipe) void {
log("onClose", .{});
if (!this.flags.is_closed) {
this.flags.is_closed = true; // only call onClose once
// Stop reading and clear the timer to prevent further callbacks,
// but don't call deinit() here. The context (owner) will call
// named_pipe.deinit() when it runs its own deferred deinit.
// Calling deinit() synchronously here causes use-after-free when
// this callback is triggered from within the SSL wrapper's
// handleTraffic() call chain (the wrapper.deinit() frees the SSL
// state while we're still on the wrapper's call stack).
this.setTimeout(0);
if (this.writer.getStream()) |stream| {
_ = stream.readStop();
}
this.handlers.onClose(this.handlers.ctx);
this.deinit();
}
}
@@ -595,7 +585,6 @@ pub fn deinit(this: *WindowsNamedPipe) void {
wrapper.deinit();
this.wrapper = null;
}
this.incoming.clearAndFree(bun.default_allocator);
var ssl_error = this.ssl_error;
ssl_error.deinit();
this.ssl_error = .{};

View File

@@ -968,7 +968,7 @@ pub const SystemErrno = enum(u16) {
if (@TypeOf(code) == u16 or (@TypeOf(code) == c_int and code > 0)) {
// Win32Error and WSA Error codes
if (code <= @intFromEnum(Win32Error.IO_REISSUE_AS_CACHED) or (code >= @intFromEnum(Win32Error.WSAEINTR) and code <= @intFromEnum(Win32Error.WSA_QOS_RESERVED_PETYPE))) {
return init(std.meta.intToEnum(Win32Error, code) catch return null);
return init(@as(Win32Error, @enumFromInt(code)));
} else {
// uv error codes
inline for (@typeInfo(SystemErrno).@"enum".fields) |field| {
@@ -988,7 +988,7 @@ pub const SystemErrno = enum(u16) {
}
if (comptime @TypeOf(code) == Win32Error or @TypeOf(code) == std.os.windows.Win32Error) {
return switch (std.meta.intToEnum(Win32Error, @intFromEnum(code)) catch return null) {
return switch (@as(Win32Error, @enumFromInt(@intFromEnum(code)))) {
Win32Error.NOACCESS => SystemErrno.EACCES,
Win32Error.WSAEACCES => SystemErrno.EACCES,
Win32Error.ELEVATION_REQUIRED => SystemErrno.EACCES,

View File

@@ -760,7 +760,7 @@ pub const WindowsBufferedReader = struct {
return Type.onReaderError(@as(*Type, @ptrCast(@alignCast(this))), err);
}
fn loop(this: *anyopaque) *Async.Loop {
return Type.loop(@as(*Type, @ptrCast(@alignCast(this))));
return Type.loop(@as(*Type, @alignCast(@ptrCast(this))));
}
};
return .{
@@ -955,14 +955,14 @@ pub const WindowsBufferedReader = struct {
}
fn onStreamAlloc(handle: *uv.Handle, suggested_size: usize, buf: *uv.uv_buf_t) callconv(.c) void {
const this: *WindowsBufferedReader = @ptrCast(@alignCast(handle.data orelse return));
var this = bun.cast(*WindowsBufferedReader, handle.data);
const result = this.getReadBufferWithStableMemoryAddress(suggested_size);
buf.* = uv.uv_buf_t.init(result);
}
fn onStreamRead(handle: *uv.uv_handle_t, nread: uv.ReturnCodeI64, buf: *const uv.uv_buf_t) callconv(.c) void {
const stream = bun.cast(*uv.uv_stream_t, handle);
const this: *WindowsBufferedReader = @ptrCast(@alignCast(stream.data orelse return));
var this = bun.cast(*WindowsBufferedReader, stream.data);
const nread_int = nread.int();
@@ -1199,15 +1199,13 @@ pub const WindowsBufferedReader = struct {
}
fn onPipeClose(handle: *uv.Pipe) callconv(.c) void {
// Use the handle directly for destroy, not handle.data which may be null
// during cleanup races.
bun.destroy(handle);
const this = bun.cast(*uv.Pipe, handle.data);
bun.destroy(this);
}
fn onTTYClose(handle: *uv.uv_tty_t) callconv(.c) void {
// Use the handle directly for destroy, not handle.data which may be null
// during cleanup races.
bun.default_allocator.destroy(handle);
const this = bun.cast(*uv.uv_tty_t, handle.data);
bun.default_allocator.destroy(this);
}
pub fn onRead(this: *WindowsBufferedReader, amount: bun.sys.Maybe(usize), slice: []u8, hasMore: ReadState) void {

View File

@@ -804,28 +804,22 @@ fn BaseWindowsPipeWriter(
}
fn onPipeClose(handle: *uv.Pipe) callconv(.c) void {
// Use the handle directly for destroy, not handle.data which may be
// stale during cleanup races.
bun.default_allocator.destroy(handle);
const this = bun.cast(*uv.Pipe, handle.data);
bun.default_allocator.destroy(this);
}
fn onTTYClose(handle: *uv.uv_tty_t) callconv(.c) void {
// Use the handle directly for destroy, not handle.data which may be
// stale during cleanup races.
bun.default_allocator.destroy(handle);
const this = bun.cast(*uv.uv_tty_t, handle.data);
bun.default_allocator.destroy(this);
}
pub fn close(this: *WindowsPipeWriter) void {
this.is_done = true;
const source = this.source orelse return;
// Check if there's a pending async write before closing.
// If so, we must defer onCloseSource() to the write-complete
// callback, because the parent's onClose handler may free
// resources that the pending callback still needs to access.
const has_pending_async_write = this.hasPendingAsyncWrite();
// For StreamingWriter: also check the file state for in-flight
// writes so we can balance processSend's ref().
const has_inflight_file_write = if (@hasField(WindowsPipeWriter, "current_payload")) switch (source) {
// Check for in-flight file write before detaching. detach()
// nulls fs.data so onFsWriteComplete can't recover the writer
// to call deref(). We must balance processSend's ref() here.
const has_inflight_write = if (@hasField(WindowsPipeWriter, "current_payload")) switch (source) {
.sync_file, .file => |file| file.state == .operating or file.state == .canceling,
else => false,
} else false;
@@ -850,16 +844,9 @@ fn BaseWindowsPipeWriter(
},
}
this.source = null;
if (!has_pending_async_write) {
// Safe to notify parent immediately — no pending callback.
this.onCloseSource();
}
// When has_pending_async_write is true, onCloseSource() will
// be called from onWriteComplete/onFsWriteComplete after the
// pending write callback completes safely.
this.onCloseSource();
// Deref last — this may free the parent and `this`.
if (has_inflight_file_write) {
if (has_inflight_write) {
this.parent.deref();
}
}
@@ -1012,22 +999,7 @@ pub fn WindowsBufferedWriter(Parent: type, function_table: anytype) type {
return .success;
}
/// Returns true if there is an outstanding async write request
/// (uv_write or uv_fs_write) that hasn't completed yet.
pub fn hasPendingAsyncWrite(this: *const WindowsWriter) bool {
return this.pending_payload_size > 0;
}
fn onWriteComplete(this: *WindowsWriter, status: uv.ReturnCode) void {
// If the source was closed while a write was in-flight,
// close() deferred onCloseSource(). Complete it now that
// the write callback has safely finished.
if (this.source == null) {
this.pending_payload_size = 0;
this.onCloseSource();
return;
}
const written = this.pending_payload_size;
this.pending_payload_size = 0;
if (status.toError(.write)) |err| {
@@ -1066,14 +1038,6 @@ pub fn WindowsBufferedWriter(Parent: type, function_table: anytype) type {
const this = bun.cast(*WindowsWriter, parent_ptr);
// If source was closed while write was in-flight, close()
// deferred onCloseSource(). Complete it now.
if (this.source == null) {
this.pending_payload_size = 0;
this.onCloseSource();
return;
}
if (was_canceled) {
// Canceled write - clear pending state
this.pending_payload_size = 0;
@@ -1338,12 +1302,6 @@ pub fn WindowsStreamingWriter(comptime Parent: type, function_table: anytype) ty
return (this.outgoing.isNotEmpty() or this.current_payload.isNotEmpty());
}
/// Returns true if there is an outstanding async write request
/// (uv_write or uv_fs_write) that hasn't completed yet.
pub fn hasPendingAsyncWrite(this: *const WindowsWriter) bool {
return this.current_payload.isNotEmpty();
}
fn isDone(this: *WindowsWriter) bool {
// done is flags andd no more data queued? so we are done!
return this.is_done and !this.hasPendingData();
@@ -1354,16 +1312,6 @@ pub fn WindowsStreamingWriter(comptime Parent: type, function_table: anytype) ty
// processSend before submitting the async write request.
defer this.parent.deref();
// If the source was closed while a write was in-flight,
// close() deferred onCloseSource(). Complete it now that
// the write callback has safely finished.
if (this.source == null) {
this.current_payload.reset();
this.outgoing.reset();
this.onCloseSource();
return;
}
if (status.toError(.write)) |err| {
this.last_write_result = .{ .err = err };
log("onWrite() = {s}", .{err.name()});
@@ -1421,16 +1369,6 @@ pub fn WindowsStreamingWriter(comptime Parent: type, function_table: anytype) ty
const this = bun.cast(*WindowsWriter, parent_ptr);
// If source was closed while write was in-flight, close()
// deferred onCloseSource(). Complete it now and deref.
if (this.source == null) {
this.current_payload.reset();
this.outgoing.reset();
this.parent.deref();
this.onCloseSource();
return;
}
if (was_canceled) {
// Canceled write - reset buffers and deref to balance processSend ref
this.current_payload.reset();

View File

@@ -149,7 +149,7 @@ pub extern "kernel32" fn SetCurrentDirectoryW(
lpPathName: win32.LPCWSTR,
) callconv(.winapi) win32.BOOL;
pub const SetCurrentDirectory = SetCurrentDirectoryW;
pub extern "ntdll" fn RtlNtStatusToDosError(win32.NTSTATUS) callconv(.winapi) u32;
pub extern "ntdll" fn RtlNtStatusToDosError(win32.NTSTATUS) callconv(.winapi) Win32Error;
pub extern "advapi32" fn SaferiIsExecutableFileType(szFullPathname: win32.LPCWSTR, bFromShellExecute: win32.BOOLEAN) callconv(.winapi) win32.BOOL;
// This was originally copied from Zig's standard library
/// Codes are from https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-erref/18d8fbe8-a967-4f1c-ae50-99ca8e491d2d
@@ -2952,8 +2952,7 @@ pub const Win32Error = enum(u16) {
pub const WSA_QOS_RESERVED_PETYPE: Win32Error = @enumFromInt(11031);
pub fn get() Win32Error {
const raw: u16 = @intFromEnum(bun.windows.kernel32.GetLastError());
return std.meta.intToEnum(Win32Error, raw) catch .MR_MID_NOT_FOUND;
return @enumFromInt(@intFromEnum(bun.windows.kernel32.GetLastError()));
}
pub fn int(this: Win32Error) u16 {
@@ -2972,12 +2971,7 @@ pub const Win32Error = enum(u16) {
}
pub fn fromNTStatus(status: win32.NTSTATUS) Win32Error {
// RtlNtStatusToDosError returns a u32 Win32 error code that may not be
// in our Win32Error enum subset. Safely convert to avoid panic on
// invalid enum values.
const raw = RtlNtStatusToDosError(status);
if (raw > std.math.maxInt(u16)) return .MR_MID_NOT_FOUND;
return std.meta.intToEnum(Win32Error, @as(u16, @intCast(raw))) catch .MR_MID_NOT_FOUND;
return RtlNtStatusToDosError(status);
}
};

View File

@@ -541,7 +541,9 @@ describe("bundler", () => {
});
// --- Ported from Rolldown: dynamic-import-entry ---
// A submodule dynamically imports the barrel back
// A submodule dynamically imports the barrel back. import() returns the full
// module namespace — all barrel exports must be preserved, even if the
// import() result is discarded (we can't statically prove it isn't used).
itBundled("barrel/DynamicImportInSubmodule", {
files: {
@@ -562,17 +564,103 @@ describe("bundler", () => {
export const a = 'dyn-a';
import('./index.js');
`,
// b.js has a syntax error — only a is imported, so b should be skipped
"/node_modules/dynlib/b.js": /* js */ `
export const b = <<<SYNTAX_ERROR>>>;
export const b = 'dyn-b';
`,
},
outdir: "/out",
onAfterBundle(api) {
api.expectFile("/out/entry.js").toContain("dyn-a");
// b must be included — import() needs the full namespace
api.expectFile("/out/entry.js").toContain("dyn-b");
},
});
// Dynamic import returns the full namespace at runtime — consumer can access any export.
// When a file also has a static named import of the same barrel, the barrel
// optimization must not drop exports the dynamic import might use.
// Previously, the dynamic import was ignored if a static import already seeded
// requested_exports, producing invalid JS (export clause referencing undeclared symbol).
itBundled("barrel/DynamicImportWithStaticImportSameTarget", {
files: {
"/entry.js": /* js */ `
import { a } from "barrel";
console.log(a);
const run = async () => {
const { b } = await import("barrel");
console.log(b);
};
run();
`,
"/node_modules/barrel/package.json": JSON.stringify({
name: "barrel",
main: "./index.js",
sideEffects: false,
}),
"/node_modules/barrel/index.js": /* js */ `
export { a } from "./a.js";
export { b } from "./b.js";
`,
"/node_modules/barrel/a.js": /* js */ `
export const a = "A";
`,
"/node_modules/barrel/b.js": /* js */ `
export const b = "B";
`,
},
splitting: true,
format: "esm",
target: "bun",
outdir: "/out",
run: {
stdout: "A\nB",
},
});
// Same as above but static and dynamic importers are in separate files.
// This was parse-order dependent — if the static importer's
// scheduleBarrelDeferredImports ran first, it seeded .partial and the dynamic
// importer's escalation was skipped. Now import() always escalates to .all.
itBundled("barrel/DynamicImportWithStaticImportSeparateFiles", {
files: {
"/static-user.js": /* js */ `
import { a } from "barrel2";
console.log(a);
`,
"/dynamic-user.js": /* js */ `
const run = async () => {
const { b } = await import("barrel2");
console.log(b);
};
run();
`,
"/node_modules/barrel2/package.json": JSON.stringify({
name: "barrel2",
main: "./index.js",
sideEffects: false,
}),
"/node_modules/barrel2/index.js": /* js */ `
export { a } from "./a.js";
export { b } from "./b.js";
`,
"/node_modules/barrel2/a.js": /* js */ `
export const a = "A";
`,
"/node_modules/barrel2/b.js": /* js */ `
export const b = "B";
`,
},
entryPoints: ["/static-user.js", "/dynamic-user.js"],
splitting: true,
format: "esm",
target: "bun",
outdir: "/out",
run: [
{ file: "/out/static-user.js", stdout: "A" },
{ file: "/out/dynamic-user.js", stdout: "B" },
],
});
// --- Ported from Rolldown: multiple-entries ---
// Multiple entry points that each import different things from barrels

View File

@@ -2119,35 +2119,6 @@ static napi_value test_napi_create_tsfn_async_context_frame(const Napi::Callback
return env.Undefined();
}
// Test for BUN-1PYR: napi_typeof should not crash when given an invalid
// napi_value that is actually a raw C string pointer. This simulates the
// scenario where a native module passes garbage data (e.g., a string pointer
// like "Tensor ...") as a napi_value to napi_typeof.
static napi_value test_napi_typeof_invalid_pointer(const Napi::CallbackInfo &info) {
Napi::Env env = info.Env();
// Simulate the exact crash scenario: a C string pointer reinterpreted as napi_value.
// The crash address 0x6F20726F736E6554 decoded to ASCII is "Tensor o",
// meaning a string pointer was being used as a JSValue.
// Use aligned_alloc to ensure 16-byte alignment (bit 3 = 0), so the pointer
// goes through the MarkedBlock validation path (not the PreciseAllocation path).
char *fake_string = static_cast<char *>(aligned_alloc(16, 64));
memcpy(fake_string, "Tensor operation test string", 29);
napi_value bad_value = reinterpret_cast<napi_value>(fake_string);
napi_valuetype type;
napi_status status = napi_typeof(env, bad_value, &type);
if (status != napi_ok) {
printf("PASS: napi_typeof returned error status %d for invalid pointer\n", status);
} else {
printf("PASS: napi_typeof did not crash for invalid pointer (returned type %d)\n", type);
}
free(fake_string);
return ok(env);
}
void register_standalone_tests(Napi::Env env, Napi::Object exports) {
REGISTER_FUNCTION(env, exports, test_issue_7685);
REGISTER_FUNCTION(env, exports, test_issue_11949);
@@ -2186,7 +2157,6 @@ void register_standalone_tests(Napi::Env env, Napi::Object exports) {
REGISTER_FUNCTION(env, exports, test_issue_25933);
REGISTER_FUNCTION(env, exports, test_napi_make_callback_async_context_frame);
REGISTER_FUNCTION(env, exports, test_napi_create_tsfn_async_context_frame);
REGISTER_FUNCTION(env, exports, test_napi_typeof_invalid_pointer);
}
} // namespace napitests

View File

@@ -829,15 +829,6 @@ describe("cleanup hooks", () => {
expect(output).toContain("PASS: napi_create_threadsafe_function accepted AsyncContextFrame");
});
it("should not crash when given an invalid pointer (BUN-1PYR)", async () => {
// Test that napi_typeof validates cell pointers before dereferencing.
// Native modules may accidentally pass garbage (e.g., a C string pointer)
// as napi_value. This should return an error, not crash.
// Bun-only: Node.js doesn't have this validation and would crash/UB.
const output = await runOn(bunExe(), "test_napi_typeof_invalid_pointer", []);
expect(output).toContain("PASS");
});
it("should return napi_object for boxed primitives (String, Number, Boolean)", async () => {
// Regression test for https://github.com/oven-sh/bun/issues/25351
// napi_typeof was incorrectly returning napi_string for String objects (new String("hello"))