Compare commits

..

4 Commits

Author SHA1 Message Date
Claude Bot
6441149674 chore: remove duplicate test and document constructability issue
- Remove duplicate ArrowToBindNoTransformWithCallArgs test (already covered
  by ArrowToBindNoTransformWithArgs)
- Add constructability to the list of reasons the optimization is disabled
  (arrows are not constructable but bound functions may be)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 15:45:10 +00:00
Claude Bot
e5c8da3c99 fix(minify): disable arrow-to-bind transform due to semantic issues
The arrow-to-bind transformation (`() => obj.method()` -> `obj.method.bind(obj)`)
can change semantics in several cases:

1. Property reassignment: If `obj.method` is reassigned after the arrow is
   created, the bound function would still reference the original method.

2. Getter properties: If `method` is a getter, bind() would only call the
   getter once at bind time, while the arrow calls it on each invocation.

3. Object escaping: The object could be modified by code we can't analyze.

This commit disables the optimization until proper property tracking is
implemented. Added tests to document these edge cases:
- ArrowToBindNoTransformPropertyReassigned
- ArrowToBindNoTransformGetterProperty

Also fixed test naming to use consistent NoTransform prefix pattern.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 15:38:43 +00:00
Claude Bot
55fe522910 test(minify): add tests for arrow-to-bind edge cases
Add tests to verify the arrow-to-bind transformation correctly handles:
- Arrow functions where target is 'this' (not transformed)
- Arrow functions where call has arguments (not transformed)
- Arrow functions using arguments[N] (not transformed)
- Arrow functions with new.target references (not transformed)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 15:25:34 +00:00
Claude Bot
0b13ba1b7e feat(minify): convert () => obj.method() to obj.method.bind(obj)
When --minify-syntax is enabled, transform arrow functions of the form
`() => obj.method()` into `obj.method.bind(obj)`. This reduces closure
allocation overhead by using the more optimized `.bind()` mechanism.

The optimization uses a two-phase approach:
1. During visiting, mark eligible arrows with the receiver's symbol ref
2. During printing, check if the symbol was ever assigned to and only
   apply the transformation if not

This allows the optimization to work with:
- const bindings (never reassigned by definition)
- let/var bindings that are never reassigned in practice
- Function parameters that are never reassigned

The optimization is NOT applied when:
- The arrow has parameters
- The arrow is async
- The call has arguments
- Optional chaining is used anywhere
- The receiver is an unbound global (could be reassigned externally)
- The receiver symbol is assigned to anywhere in the code

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 15:17:31 +00:00
9 changed files with 468 additions and 580 deletions

View File

@@ -269,6 +269,12 @@ pub const Arrow = struct {
has_rest_arg: bool = false,
prefer_expr: bool = false, // Use shorthand if true and "Body" is a single return statement
/// When minify_syntax is enabled and this arrow is a candidate for the
/// `() => obj.method()` -> `obj.method.bind(obj)` transformation, this
/// stores the ref of the receiver identifier (obj). The printer will check
/// if this symbol was_assigned_to and only apply the transformation if not.
bind_call_target_ref: ?Ref = null,
pub const noop_return_undefined: Arrow = .{
.args = &.{},
.body = .{

View File

@@ -122,6 +122,7 @@ pub fn NewParser_(
pub const visitClass = astVisit.visitClass;
pub const visitStmts = astVisit.visitStmts;
pub const visitAndAppendStmt = astVisit.visitAndAppendStmt;
pub const tryMarkArrowForBindCallTransform = astVisit.tryMarkArrowForBindCallTransform;
pub const BinaryExpressionVisitor = @import("./visitBinaryExpression.zig").CreateBinaryExpressionVisitor(parser_feature__typescript, parser_feature__jsx, parser_feature__scan_only).BinaryExpressionVisitor;

View File

@@ -154,6 +154,31 @@ pub fn Visit(
return decs;
}
/// Check if this arrow is a candidate for the `() => obj.method()` to
/// `obj.method.bind(obj)` transformation. If so, store the receiver ref
/// in the arrow so the printer can check if the symbol was assigned to.
///
/// NOTE: This optimization is currently DISABLED because it can change
/// semantics in cases where:
/// 1. The property (method) is reassigned after the arrow is created
/// 2. The property is a getter that returns different values on each access
/// 3. Constructability differs: arrows are not constructable but bound
/// functions derived from regular methods may be
///
/// To safely enable this optimization, we would need to track:
/// - Property assignments to the receiver object
/// - Whether the property is defined as a getter
/// - Whether the object escapes to code that could modify it
/// - Whether the arrow could be used with `new`
///
/// For now, we conservatively disable the transformation entirely.
pub fn tryMarkArrowForBindCallTransform(p: *P, arrow: *E.Arrow) void {
_ = p;
_ = arrow;
// Disabled - see comment above for rationale
return;
}
pub fn visitDecls(noalias p: *P, decls: []G.Decl, was_const: bool, comptime is_possibly_decl_to_remove: bool) usize {
var j: usize = 0;
var out_decls = decls;

View File

@@ -1571,6 +1571,18 @@ pub fn VisitExpr(
p.fn_only_data_visit.is_inside_async_arrow_fn = old_inside_async_arrow_fn;
p.fn_or_arrow_data_visit = std.mem.bytesToValue(@TypeOf(p.fn_or_arrow_data_visit), &old_fn_or_arrow_data);
// Mark arrows that are candidates for the `() => obj.method()` to
// `obj.method.bind(obj)` transformation. The actual transformation is
// deferred to the printer, which can check if the captured symbol was
// assigned to anywhere in the code.
if (p.options.features.minify_syntax and
e_.args.len == 0 and
!e_.is_async and
e_.body.stmts.len == 1)
{
p.tryMarkArrowForBindCallTransform(e_);
}
if (react_hook_data) |*hook| try_mark_hook: {
const stmts = p.nearest_stmt_list orelse break :try_mark_hook;
bun.handleOom(stmts.append(p.getReactRefreshHookSignalDecl(hook.signature_cb)));

View File

@@ -412,42 +412,6 @@ const IncomingMessagePrototype = {
set socket(value) {
this[fakeSocketSymbol] = value;
},
// Used by HTTP parser for adding headers from raw socket parsing
// This is needed for allowHTTP1 fallback in HTTP/2 servers
_addHeaderLine(field, value, dest) {
const lowercased = field.toLowerCase();
if (lowercased === "set-cookie") {
if (dest[lowercased] !== undefined) {
dest[lowercased].push(value);
} else {
dest[lowercased] = [value];
}
} else {
if (dest[lowercased] !== undefined) {
dest[lowercased] += ", " + value;
} else {
dest[lowercased] = value;
}
}
},
_addHeaderLines(headers, n) {
if (headers?.length) {
if (!this.headers) {
this.headers = Object.create(null);
}
if (!this.rawHeaders) {
this.rawHeaders = [];
}
// Add to rawHeaders
for (let i = 0; i < n; i++) {
this.rawHeaders.push(headers[i]);
}
// Parse and add to headers object
for (let i = 0; i < n; i += 2) {
this._addHeaderLine(headers[i], headers[i + 1], this.headers);
}
}
},
} satisfies typeof import("node:http").IncomingMessage.prototype;
IncomingMessage.prototype = IncomingMessagePrototype;
$setPrototypeDirect.$call(IncomingMessage, Readable);

View File

@@ -29,8 +29,6 @@
const { isTypedArray } = require("node:util/types");
const { hideFromStack, throwNotImplemented } = require("internal/shared");
const { STATUS_CODES } = require("internal/http");
const http = require("node:http");
const { parsers, freeParser, kIncomingMessage, HTTPParser } = require("node:_http_common");
const tls = require("node:tls");
const net = require("node:net");
const fs = require("node:fs");
@@ -61,7 +59,6 @@ const RegExpPrototypeExec = RegExp.prototype.exec;
const ObjectAssign = Object.assign;
const ArrayIsArray = Array.isArray;
const ObjectKeys = Object.keys;
const ObjectEntries = Object.entries;
const FunctionPrototypeBind = Function.prototype.bind;
const StringPrototypeTrim = String.prototype.trim;
const ArrayPrototypePush = Array.prototype.push;
@@ -3714,278 +3711,13 @@ function closeAllSessions(server: Http2Server | Http2SecureServer) {
}
}
// Simple HTTP/1.1 ServerResponse for allowHTTP1 fallback
// This writes directly to the socket since we don't have a native Bun handle
class Http1FallbackResponse extends EventEmitter {
socket;
statusCode = 200;
statusMessage = "OK";
headersSent = false;
finished = false;
_headers = Object.create(null);
req;
sendDate = true;
_hasBody = true;
constructor(req, socket) {
super();
this.req = req;
this.socket = socket;
if (req.method === "HEAD") this._hasBody = false;
}
setHeader(name, value) {
this._headers[StringPrototypeToLowerCase.$call(name)] = value;
return this;
}
getHeader(name) {
return this._headers[StringPrototypeToLowerCase.$call(name)];
}
removeHeader(name) {
delete this._headers[StringPrototypeToLowerCase.$call(name)];
return this;
}
hasHeader(name) {
return StringPrototypeToLowerCase.$call(name) in this._headers;
}
getHeaderNames() {
return ObjectKeys(this._headers);
}
writeHead(statusCode, statusMessage?, headers?) {
if (this.headersSent) return this;
if (typeof statusMessage === "object") {
headers = statusMessage;
statusMessage = undefined;
}
this.statusCode = statusCode;
if (statusMessage) this.statusMessage = statusMessage;
else this.statusMessage = STATUS_CODES[statusCode] || "Unknown";
if (headers) {
for (const key of ObjectKeys(headers)) {
this.setHeader(key, headers[key]);
}
}
return this;
}
_flushHeaders() {
if (this.headersSent) return;
this.headersSent = true;
let head = `HTTP/1.1 ${this.statusCode} ${this.statusMessage}\r\n`;
if (this.sendDate && !this.hasHeader("date")) {
head += `Date: ${utcDate()}\r\n`;
}
for (const [name, value] of ObjectEntries(this._headers)) {
if (ArrayIsArray(value)) {
for (const v of value) {
head += `${name}: ${v}\r\n`;
}
} else {
head += `${name}: ${value}\r\n`;
}
}
head += "\r\n";
this.socket.write(head);
}
write(chunk, encoding?, callback?) {
if (typeof encoding === "function") {
callback = encoding;
encoding = undefined;
}
if (!this.headersSent) {
if (!this.hasHeader("transfer-encoding") && !this.hasHeader("content-length")) {
this.setHeader("transfer-encoding", "chunked");
}
this._flushHeaders();
}
if (this._hasBody && chunk) {
if (this.getHeader("transfer-encoding") === "chunked") {
const len = typeof chunk === "string" ? Buffer.byteLength(chunk, encoding) : chunk.length;
this.socket.write(len.toString(16) + "\r\n");
this.socket.write(chunk, encoding);
return this.socket.write("\r\n", undefined, callback);
} else {
return this.socket.write(chunk, encoding, callback);
}
} else if (callback) {
callback();
}
return true;
}
end(data?, encoding?, callback?) {
if (typeof data === "function") {
callback = data;
data = undefined;
} else if (typeof encoding === "function") {
callback = encoding;
encoding = undefined;
}
if (this.finished) {
if (callback) process.nextTick(callback);
return this;
}
if (!this.headersSent) {
if (data && !this.hasHeader("content-length") && !this.hasHeader("transfer-encoding")) {
const len = typeof data === "string" ? Buffer.byteLength(data, encoding) : (data?.length ?? 0);
this.setHeader("content-length", String(len));
} else if (!data && !this.hasHeader("content-length") && !this.hasHeader("transfer-encoding")) {
this.setHeader("content-length", "0");
}
this._flushHeaders();
}
if (this._hasBody && data) {
if (this.getHeader("transfer-encoding") === "chunked") {
const len = typeof data === "string" ? Buffer.byteLength(data, encoding) : data.length;
this.socket.write(len.toString(16) + "\r\n");
this.socket.write(data, encoding);
this.socket.write("\r\n0\r\n\r\n");
} else {
this.socket.write(data, encoding);
}
} else if (this.getHeader("transfer-encoding") === "chunked") {
this.socket.write("0\r\n\r\n");
}
this.finished = true;
this.emit("finish");
if (callback) process.nextTick(callback);
return this;
}
writeContinue() {
this.socket.write("HTTP/1.1 100 Continue\r\n\r\n");
}
destroy(err?) {
this.socket.destroy(err);
return this;
}
assignSocket(socket) {
this.socket = socket;
}
}
// HTTP/1.1 connection listener for allowHTTP1 fallback
// This sets up an HTTP parser to handle HTTP/1.1 requests on the TLS socket
function http1ConnectionListener(socket: Socket, options) {
const server = this;
// Ensure socket has server property set
socket.server = server;
// Get configurable classes from options (set by initializeOptions)
// Note: We default to Http1FallbackResponse because http.ServerResponse requires
// a native handle that isn't available when parsing raw sockets. Only use a custom
// ServerResponse if explicitly provided by the user.
const ServerResponse =
options.Http1ServerResponse && options.Http1ServerResponse !== http.ServerResponse
? options.Http1ServerResponse
: Http1FallbackResponse;
// Get the HTTP parser from the pool
const parser = parsers.alloc();
parser.socket = socket;
socket.parser = parser;
// Initialize parser for REQUEST mode
parser.initialize(HTTPParser.REQUEST, socket);
// When the parser completes parsing headers, create a response and emit the request event
parser.onIncoming = function onIncoming(req, shouldKeepAlive) {
// Create a response using the configured ServerResponse class
// For Http1FallbackResponse, pass (req, socket). For custom classes, just pass (req).
const res = ServerResponse === Http1FallbackResponse ? new ServerResponse(req, socket) : new ServerResponse(req);
// Emit the request event on the server
if (req.headers.expect !== undefined && req.httpVersionMajor === 1 && req.httpVersionMinor === 1) {
if (req.headers.expect === "100-continue") {
if (server.listenerCount("checkContinue") > 0) {
server.emit("checkContinue", req, res);
} else {
res.writeContinue();
server.emit("request", req, res);
}
} else {
if (server.listenerCount("checkExpectation") > 0) {
server.emit("checkExpectation", req, res);
} else {
res.writeHead(417);
res.end();
}
}
} else {
server.emit("request", req, res);
}
return 0;
};
// Set up socket event handlers
function onSocketData(data) {
const ret = parser.execute(data);
if (ret instanceof Error) {
socket.destroy(ret);
}
}
function onSocketEnd() {
const ret = parser.finish();
if (ret instanceof Error) {
socket.destroy(ret);
}
}
function onSocketClose() {
// Clean up parser
freeParser(parser, null, socket);
socket.parser = null;
}
function onSocketError(err) {
if (!server.emit("clientError", err, socket)) {
socket.destroy(err);
}
}
socket.on("data", onSocketData);
socket.on("end", onSocketEnd);
socket.on("close", onSocketClose);
socket.on("error", onSocketError);
// Emit connection event
server.emit("connection", socket);
}
function connectionListener(socket: Socket) {
const options = this[bunSocketServerOptions] || {};
if (socket.alpnProtocol === false || socket.alpnProtocol === "http/1.1") {
// Fallback to HTTP/1.1
if (options.allowHTTP1 === true) {
return http1ConnectionListener.$call(this, socket, options);
}
// TODO: Fallback to HTTP/1.1
// if (options.allowHTTP1 === true) {
// }
// Let event handler deal with the socket
if (!this.emit("unknownProtocol", socket)) {
@@ -4056,8 +3788,8 @@ function initializeOptions(options) {
else options.unknownProtocolTimeout = 10000;
// Used only with allowHTTP1
options.Http1IncomingMessage ||= http.IncomingMessage;
options.Http1ServerResponse ||= http.ServerResponse;
// options.Http1IncomingMessage ||= http.IncomingMessage;
// options.Http1ServerResponse ||= http.ServerResponse;
options.Http2ServerRequest ||= Http2ServerRequest;
options.Http2ServerResponse ||= Http2ServerResponse;
@@ -4156,10 +3888,10 @@ class Http2SecureServer extends tls.Server {
timeout = 0;
[kSessions] = new SafeSet();
constructor(options, onRequestHandler) {
//TODO: add 'http/1.1' on ALPNProtocols list after allowHTTP1 support
if (typeof options !== "undefined") {
if (options && typeof options === "object") {
const ALPNProtocols = options.allowHTTP1 === true ? ["h2", "http/1.1"] : ["h2"];
options = { ...options, ALPNProtocols };
options = { ...options, ALPNProtocols: ["h2"] };
} else {
throw $ERR_INVALID_ARG_TYPE("options", "object", options);
}
@@ -4167,9 +3899,16 @@ class Http2SecureServer extends tls.Server {
options = { ALPNProtocols: ["h2"] };
}
// Initialize options with defaults (including Http1IncomingMessage/ServerResponse)
options = initializeOptions(options);
const settings = options.settings;
if (typeof settings !== "undefined") {
validateObject(settings, "options.settings");
}
if (options.maxSessionInvalidFrames !== undefined)
validateUint32(options.maxSessionInvalidFrames, "maxSessionInvalidFrames");
if (options.maxSessionRejectedStreams !== undefined) {
validateUint32(options.maxSessionRejectedStreams, "maxSessionRejectedStreams");
}
super(options, connectionListener);
this[kSessions] = new SafeSet();
this.setMaxListeners(0);

View File

@@ -2565,6 +2565,26 @@ fn NewPrinter(
}
},
.e_arrow => |e| {
// Optimization: Convert `() => obj.method()` to `obj.method.bind(obj)`
// when the receiver symbol was never assigned to.
if (e.bind_call_target_ref) |target_ref| {
if (p.symbols().get(target_ref)) |symbol| {
// Only transform if the symbol was never assigned to.
// For const/unbound, has_been_assigned_to is always false.
// For hoisted (function params, var), it's true if assigned anywhere.
if (!symbol.has_been_assigned_to) {
// Get the call expression from the body
const call = e.body.stmts[0].data.s_return.value.?.data.e_call;
// Print: target.method.bind(target)
p.printExpr(call.target, .postfix, ExprFlag.Set{});
p.print(".bind(");
p.printSymbol(target_ref);
p.print(")");
return;
}
}
}
const wrap = level.gte(.assign);
if (wrap) {

View File

@@ -1192,4 +1192,391 @@ describe("bundler", () => {
stdout: "object\nobject\nobject",
},
});
// Arrow to bind optimization tests
// NOTE: The arrow-to-bind transformation is currently DISABLED because
// it can change semantics when properties are reassigned or are getters.
// These tests verify the transformation is NOT applied.
itBundled("minify/ArrowToBindConstIdentifier", {
files: {
"/entry.js": /* js */ `
const obj = { value: 42, method() { return this.value; } };
const fn = () => obj.method();
console.log(fn());
`,
},
minifySyntax: true,
target: "bun",
onAfterBundle(api) {
const code = api.readFile("/out.js");
// Optimization disabled - should NOT transform
expect(code).not.toContain(".bind(");
},
run: {
stdout: "42",
},
});
itBundled("minify/ArrowToBindNoTransformUnboundGlobal", {
files: {
"/entry.js": /* js */ `
const fn = () => console.log();
fn();
console.log("done");
`,
},
minifySyntax: true,
target: "bun",
onAfterBundle(api) {
const code = api.readFile("/out.js");
// Should NOT transform unbound globals - they could be reassigned externally
expect(code).not.toContain(".bind(");
expect(code).toMatch(/\(\)\s*=>/);
},
run: {
stdout: "\ndone",
},
});
itBundled("minify/ArrowToBindComputedProperty", {
files: {
"/entry.js": /* js */ `
const obj = { myMethod() { return this.value; }, value: 99 };
const key = "myMethod";
const fn = () => obj[key]();
console.log(fn());
`,
},
minifySyntax: true,
target: "bun",
onAfterBundle(api) {
const code = api.readFile("/out.js");
// Optimization disabled - should NOT transform
expect(code).not.toContain(".bind(");
},
run: {
stdout: "99",
},
});
itBundled("minify/ArrowToBindNoTransformLetReassigned", {
files: {
"/entry.js": /* js */ `
let obj = { method() { return "first"; } };
const fn = () => obj.method();
obj = { method() { return "second"; } };
console.log(fn());
`,
},
minifySyntax: true,
target: "bun",
onAfterBundle(api) {
const code = api.readFile("/out.js");
// Should NOT transform because obj is reassigned
expect(code).not.toContain(".bind(");
expect(code).toMatch(/\(\)\s*=>/);
},
run: {
stdout: "second",
},
});
itBundled("minify/ArrowToBindLetNotReassigned", {
files: {
"/entry.js": /* js */ `
let obj = { value: 42, method() { return this.value; } };
const fn = () => obj.method();
console.log(fn());
`,
},
minifySyntax: true,
target: "bun",
onAfterBundle(api) {
const code = api.readFile("/out.js");
// Optimization disabled - should NOT transform
expect(code).not.toContain(".bind(");
},
run: {
stdout: "42",
},
});
itBundled("minify/ArrowToBindFunctionParam", {
files: {
"/entry.js": /* js */ `
function fetch(init) {
return () => init.signal();
}
console.log(fetch({ signal: () => 555 })());
`,
},
minifySyntax: true,
target: "bun",
onAfterBundle(api) {
const code = api.readFile("/out.js");
// Optimization disabled - should NOT transform
expect(code).not.toContain(".bind(");
},
run: {
stdout: "555",
},
});
itBundled("minify/ArrowToBindNoTransformFunctionParamReassigned", {
files: {
"/entry.js": /* js */ `
function fetch(init) {
const cb = () => init.signal();
init = { signal: () => 666 };
return cb;
}
console.log(fetch({ signal: () => 555 })());
`,
},
minifySyntax: true,
target: "bun",
onAfterBundle(api) {
const code = api.readFile("/out.js");
// Should NOT transform because init is reassigned
expect(code).not.toContain(".bind(");
expect(code).toMatch(/\(\)\s*=>/);
},
run: {
stdout: "666",
},
});
itBundled("minify/ArrowToBindNoTransformWithArgs", {
files: {
"/entry.js": /* js */ `
const obj = { method(x) { return x * 2; } };
const fn = () => obj.method(21);
console.log(fn());
`,
},
minifySyntax: true,
target: "bun",
onAfterBundle(api) {
const code = api.readFile("/out.js");
// Should NOT transform because the call has arguments
expect(code).not.toContain(".bind(");
expect(code).toMatch(/\(\)\s*=>/);
},
run: {
stdout: "42",
},
});
itBundled("minify/ArrowToBindNoTransformArrowWithParams", {
files: {
"/entry.js": /* js */ `
const obj = { method() { return 123; } };
const fn = (x) => obj.method();
console.log(fn(1));
`,
},
minifySyntax: true,
target: "bun",
onAfterBundle(api) {
const code = api.readFile("/out.js");
// Should NOT transform because the arrow has parameters
expect(code).not.toContain(".bind(");
},
run: {
stdout: "123",
},
});
itBundled("minify/ArrowToBindNoTransformAsync", {
files: {
"/entry.js": /* js */ `
const obj = { async method() { return 456; } };
const fn = async () => obj.method();
fn().then(console.log);
`,
},
minifySyntax: true,
target: "bun",
onAfterBundle(api) {
const code = api.readFile("/out.js");
// Should NOT transform because the arrow is async
expect(code).not.toContain(".bind(");
expect(code).toMatch(/async\s*\(\)\s*=>/);
},
run: {
stdout: "456",
},
});
itBundled("minify/ArrowToBindNoTransformOptionalChain", {
files: {
"/entry.js": /* js */ `
const obj = { method() { return 789; } };
const fn = () => obj?.method();
console.log(fn());
`,
},
minifySyntax: true,
target: "bun",
onAfterBundle(api) {
const code = api.readFile("/out.js");
// Should NOT transform because of optional chaining
expect(code).not.toContain(".bind(");
expect(code).toMatch(/\?\.\w+\(\)/);
},
run: {
stdout: "789",
},
});
itBundled("minify/ArrowToBindNoTransformOptionalCall", {
files: {
"/entry.js": /* js */ `
const obj = { method() { return 321; } };
const fn = () => obj.method?.();
console.log(fn());
`,
},
minifySyntax: true,
target: "bun",
onAfterBundle(api) {
const code = api.readFile("/out.js");
// Should NOT transform because of optional call
expect(code).not.toContain(".bind(");
},
run: {
stdout: "321",
},
});
itBundled("minify/ArrowToBindNoTransformThisTarget", {
files: {
"/entry.js": /* js */ `
class MyClass {
value = 42;
method() { return this.value; }
getMethod() {
// Arrow captures 'this' from enclosing context
// Cannot transform because 'this' is not an identifier
return () => this.method();
}
}
const obj = new MyClass();
const fn = obj.getMethod();
console.log(fn());
`,
},
minifySyntax: true,
target: "bun",
onAfterBundle(api) {
const code = api.readFile("/out.js");
// Should NOT transform because target is 'this', not a bound identifier
expect(code).not.toContain(".bind(");
},
run: {
stdout: "42",
},
});
itBundled("minify/ArrowToBindNoTransformArgumentsAccess", {
files: {
"/entry.js": /* js */ `
const obj = { greet(name) { return "Hello, " + name; } };
function test() {
// Arrow that uses arguments from enclosing function
const fn = () => obj.greet(arguments[0]);
return fn();
}
console.log(test("World"));
`,
},
minifySyntax: true,
target: "bun",
onAfterBundle(api) {
const code = api.readFile("/out.js");
// Should NOT transform because call has arguments
expect(code).not.toContain(".bind(");
},
run: {
stdout: "Hello, World",
},
});
itBundled("minify/ArrowToBindNoTransformNewTarget", {
files: {
"/entry.js": /* js */ `
const obj = {
check() { return "method called"; }
};
function MyConstructor() {
// Arrow that references new.target
this.fn = () => {
if (new.target) {
return obj.check();
}
return "no new.target";
};
}
const instance = new MyConstructor();
console.log(instance.fn());
`,
},
minifySyntax: true,
target: "bun",
onAfterBundle(api) {
const code = api.readFile("/out.js");
// Should NOT transform - arrow body has multiple statements (if/else logic)
// and references new.target which arrows inherit from enclosing scope
expect(code).not.toContain("obj.check.bind(");
},
run: {
stdout: "method called",
},
});
itBundled("minify/ArrowToBindNoTransformPropertyReassigned", {
files: {
"/entry.js": /* js */ `
const obj = { method() { return "original"; } };
const fn = () => obj.method();
obj.method = () => "reassigned";
console.log(fn());
`,
},
minifySyntax: true,
target: "bun",
onAfterBundle(api) {
const code = api.readFile("/out.js");
// Should NOT transform because obj.method is reassigned after the arrow is created
// If we transform to obj.method.bind(obj), it would capture the original method
// But the arrow should call the reassigned method
expect(code).not.toContain(".bind(");
},
run: {
stdout: "reassigned",
},
});
itBundled("minify/ArrowToBindNoTransformGetterProperty", {
files: {
"/entry.js": /* js */ `
let callCount = 0;
const obj = {
get method() {
callCount++;
return () => "from getter " + callCount;
}
};
const fn = () => obj.method();
console.log(fn());
console.log(fn());
`,
},
minifySyntax: true,
target: "bun",
onAfterBundle(api) {
const code = api.readFile("/out.js");
// Should NOT transform because method is a getter
// bind() would only call the getter once, but arrow calls it each time
expect(code).not.toContain(".bind(");
},
run: {
stdout: "from getter 1\nfrom getter 2",
},
});
});

View File

@@ -1,266 +0,0 @@
/**
* Regression test for issue #26721
*
* HTTP/1.1 fallback is broken for `node:http2` secure server when
* `allowHTTP1: true` is passed. The server only advertises `h2` in ALPN
* negotiation, causing HTTP/1.1-only clients to fail.
*
* @see https://github.com/oven-sh/bun/issues/26721
*/
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import http2 from "node:http2";
import https from "node:https";
import { join } from "node:path";
// TLS certificates for testing
const fixturesDir = join(import.meta.dirname, "..", "fixtures");
const tlsOptions = {
cert: readFileSync(join(fixturesDir, "cert.pem")),
key: readFileSync(join(fixturesDir, "cert.key")),
};
interface TestContext {
server: http2.Http2SecureServer;
serverPort: number;
serverUrl: string;
}
describe("HTTP/2 allowHTTP1 option", () => {
let ctx: TestContext;
beforeAll(async () => {
const server = http2.createSecureServer({
...tlsOptions,
allowHTTP1: true,
});
// Handle HTTP/2 streams
server.on("stream", (stream, headers) => {
stream.respond({
":status": 200,
"content-type": "text/plain",
"x-protocol": "h2",
});
stream.end("ok h2\n");
});
// Handle HTTP/1.1 requests (via allowHTTP1 fallback)
// Note: HTTP/2 compatibility also emits 'request' events, but those requests
// will have already been handled by the 'stream' handler. We check if headers
// have been sent to avoid double-responding.
server.on("request", (req, res) => {
// Skip if this is from HTTP/2 compat (headers already sent by stream handler)
if (res.headersSent) return;
res.writeHead(200, {
"content-type": "text/plain",
"x-protocol": "http1",
});
res.end("ok http1\n");
});
const { promise: listenPromise, resolve: listenResolve, reject: listenReject } = Promise.withResolvers<number>();
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
listenReject(new Error("Failed to get server address"));
return;
}
listenResolve(address.port);
});
server.once("error", listenReject);
const serverPort = await listenPromise;
ctx = {
server,
serverPort,
serverUrl: `https://127.0.0.1:${serverPort}`,
};
});
afterAll(async () => {
if (ctx?.server) {
// Close all active connections first to ensure server.close() completes
if (typeof ctx.server.closeAllConnections === "function") {
ctx.server.closeAllConnections();
}
const { promise, resolve } = Promise.withResolvers<void>();
ctx.server.close(() => resolve());
await promise;
}
});
test("HTTP/2 client can connect and make request", async () => {
const client = http2.connect(ctx.serverUrl, { rejectUnauthorized: false });
const response = await new Promise<{ status: number; body: string; protocol: string }>((resolve, reject) => {
const req = client.request({ ":path": "/" });
let body = "";
let protocol = "";
req.on("response", headers => {
protocol = headers["x-protocol"] as string;
});
req.on("data", chunk => {
body += chunk;
});
req.on("end", () => {
resolve({ status: 200, body, protocol });
});
req.on("error", reject);
req.end();
});
expect(response.body).toBe("ok h2\n");
expect(response.protocol).toBe("h2");
const { promise: closePromise, resolve: closeResolve } = Promise.withResolvers<void>();
client.close(closeResolve);
await closePromise;
});
test("HTTP/1.1 client can connect when allowHTTP1 is true (issue #26721)", async () => {
// This test verifies that HTTP/1.1 clients can connect to an HTTP/2 server
// with allowHTTP1: true. Before the fix, this would fail with:
// "tlsv1 alert no application protocol" because the server only
// advertised "h2" in ALPN, not "http/1.1".
const response = await new Promise<{ statusCode: number; body: string; protocol: string }>((resolve, reject) => {
const req = https.request(
{
hostname: "127.0.0.1",
port: ctx.serverPort,
path: "/",
method: "GET",
rejectUnauthorized: false,
headers: {
Connection: "close", // Ensure connection is closed after request
},
// Force HTTP/1.1 by not specifying ALPNProtocols or by using https module
},
res => {
let body = "";
res.on("data", chunk => {
body += chunk;
});
res.on("end", () => {
resolve({
statusCode: res.statusCode!,
body,
protocol: res.headers["x-protocol"] as string,
});
});
},
);
req.on("error", reject);
req.end();
});
expect(response.statusCode).toBe(200);
expect(response.body).toBe("ok http1\n");
expect(response.protocol).toBe("http1");
});
test("HTTP/1.1 POST request works with allowHTTP1", async () => {
const postData = JSON.stringify({ message: "hello" });
// Use the shared server from ctx
const response = await new Promise<{ statusCode: number; body: string }>((resolve, reject) => {
const req = https.request(
{
hostname: "127.0.0.1",
port: ctx.serverPort,
path: "/post",
method: "POST",
rejectUnauthorized: false,
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(postData),
Connection: "close", // Ensure connection is closed after request
},
},
res => {
let body = "";
res.on("data", chunk => {
body += chunk;
});
res.on("end", () => {
resolve({ statusCode: res.statusCode!, body });
});
},
);
req.on("error", reject);
req.write(postData);
req.end();
});
expect(response.statusCode).toBe(200);
expect(response.body).toBe("ok http1\n");
});
});
describe("HTTP/2 without allowHTTP1", () => {
test("HTTP/1.1 client gets rejected when allowHTTP1 is false", async () => {
const server = http2.createSecureServer({
...tlsOptions,
allowHTTP1: false,
});
server.on("stream", (stream, _headers) => {
stream.respond({ ":status": 200 });
stream.end("ok");
});
const { promise: listenPromise, resolve: listenResolve, reject: listenReject } = Promise.withResolvers<number>();
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
listenReject(new Error("Failed to get server address"));
return;
}
listenResolve(address.port);
});
server.once("error", listenReject);
const port = await listenPromise;
try {
await new Promise<void>((resolve, reject) => {
const req = https.request(
{
hostname: "127.0.0.1",
port,
path: "/",
method: "GET",
rejectUnauthorized: false,
},
() => {
reject(new Error("Expected connection to fail"));
},
);
req.on("error", err => {
// We expect an ALPN negotiation error or similar
// Note: Bun's https client may report different error messages
expect(err.message).toMatch(/no application protocol|ECONNRESET|ECONNREFUSED|socket hang up/i);
resolve();
});
req.end();
});
} finally {
// Force close all connections and the server
// Use a short timeout to ensure this doesn't hang the test
await Promise.race([
new Promise<void>(resolve => server.close(() => resolve())),
new Promise<void>(resolve => setTimeout(resolve, 500)),
]);
}
});
});