Merge pull request #3578 from lightpanda-io/response-null-body

webapi: improve fetch/response correctness with null bodies
This commit is contained in:
Karl Seguin authored and GitHub committed 2026-09-21 18:56:12 +08:00
commit dba2fdf5fc
5 files changed
+96 -5

No files matched your search

+27
View File
@@ -564,3 +564,30 @@
});
}
</script>
<script id=fetch_null_body type=module>
{
const state = await testing.async();
const results = [];
for (const [url, method] of [
['http://127.0.0.1:9582/status/204', 'GET'],
['http://127.0.0.1:9582/status/205', 'POST'],
['http://127.0.0.1:9582/status/304', 'GET'],
['http://127.0.0.1:9582/xhr', 'HEAD'],
]) {
const response = await fetch(url, { method });
results.push([response.body, response.bodyUsed, await response.text()]);
}
const ok = await fetch('http://127.0.0.1:9582/xhr');
state.resolve();
await state.done(() => {
for (const [body, bodyUsed, text] of results) {
testing.expectEqual(null, body);
testing.expectEqual(false, bodyUsed);
testing.expectEqual('', text);
}
testing.expectEqual(true, ok.body !== null);
});
}
</script>
+23
View File
@@ -429,3 +429,26 @@
testing.expectTrue(threw);
}
</script>
<script id=init_validation>
for (const status of [0, 100, 199, 600, 1000]) {
testing.expectError('RangeError', () => new Response('', { status }));
}
testing.expectError('RangeError', () => Response.json({}, { status: 99 }));
for (const statusText of ['\n', 'Ā']) {
testing.expectError('TypeError', () => new Response('', { statusText }));
}
testing.expectEqual('Tab\tand ÿ', new Response('', { statusText: 'Tab\tand ÿ' }).statusText);
// a body is not allowed with a null body status, even an empty one
for (const status of [204, 205, 304]) {
testing.expectError('TypeError', () => new Response('body', { status }));
testing.expectError('TypeError', () => new Response('', { status }));
testing.expectError('TypeError', () => Response.json({}, { status }));
const r = new Response(null, { status });
testing.expectEqual(status, r.status);
testing.expectEqual(null, r.body);
}
testing.expectEqual(204, new Response(undefined, { status: 204 }).status);
</script>
+12 -5
View File
@@ -45,6 +45,7 @@ _owns_response: bool,
_signal: ?*AbortSignal,
_manual_redirect: bool,
_no_cors: bool,
_null_body: bool,
_sink: Sink,
pub const Input = Request.Input;
@@ -129,6 +130,7 @@ fn submit(request: *Request, body: ?[]const u8, sink: Sink, exec: *const Executi
._signal = request._signal,
._manual_redirect = request._redirect == .manual,
._no_cors = request._mode == .@"no-cors",
._null_body = request._method == .HEAD,
};
if (comptime lp.IS_DEBUG) {
@@ -211,8 +213,13 @@ fn httpHeaderDoneCallback(transfer: *Transfer) !Transfer.HeaderResult {
}
}
const status = transfer.responseStatus().?;
if (is_opaque or Response.isNullBodyStatus(status) or (self._manual_redirect and HttpClient.isRedirectStatus(status))) {
self._null_body = true;
}
const arena = self._response._arena;
if (!is_opaque) {
if (self._null_body == false) {
try self._buf.ensureTotalCapacityPrecise(arena.allocator(), transfer.bodyLen());
}
@@ -226,8 +233,8 @@ fn httpHeaderDoneCallback(transfer: *Transfer) !Transfer.HeaderResult {
});
}
res._status = transfer.responseStatus().?;
res._status_text = std.http.Status.phrase(@enumFromInt(transfer.responseStatus().?)) orelse "";
res._status = status;
res._status_text = std.http.Status.phrase(@enumFromInt(status)) orelse "";
res._url = try arena.dupeZ(u8, transfer.req.url);
res._is_redirected = transfer.redirectCount().? > 0;
@@ -290,7 +297,7 @@ fn httpDataCallback(transfer: *Transfer, data: []const u8) !void {
}
}
if (self._no_cors and transfer.client.obey_cors and transfer._cors_cross_origin) {
if (self._null_body) {
return;
}
@@ -301,7 +308,7 @@ fn httpDoneCallback(ctx: *anyopaque) !void {
const self: *Fetch = @ptrCast(@alignCast(ctx));
var response = self._response;
response._http_transfer = null;
response._body = .{ .bytes = self._buf.items };
response._body = if (self._null_body) .empty else .{ .bytes = self._buf.items };
log.info(.http, "request complete", .{
.source = "fetch",
+29
View File
@@ -73,6 +73,7 @@ const InitOpts = struct {
pub const BodyInit = body_init.BodyInit;
pub fn init(body_: ?BodyInit, opts_: ?InitOpts, exec: *const Execution) !*Response {
try validateInit(opts_ orelse .{}, body_ != null);
const session = exec.session;
const bucket: lp.ArenaPool.BucketSize = blk: {
@@ -90,6 +91,33 @@ pub fn init(body_: ?BodyInit, opts_: ?InitOpts, exec: *const Execution) !*Respon
return initWithArena(arena, body_, opts_, exec);
}
fn validateInit(opts: InitOpts, has_body: bool) !void {
if (opts.status < 200 or opts.status > 599) {
return error.RangeError;
}
if (opts.statusText) |status_text| {
// reason-phrase: HTAB, SP, VCHAR and obs-text, all within a ByteString
var it = (std.unicode.Utf8View.init(status_text) catch return error.TypeError).iterator();
while (it.nextCodepoint()) |cp| switch (cp) {
'\t', ' '...'~', 0x80...0xFF => {},
else => return error.TypeError,
};
}
if (has_body and isNullBodyStatus(opts.status)) {
return error.TypeError;
}
}
// https://fetch.spec.whatwg.org/#null-body-status
pub fn isNullBodyStatus(status: u16) bool {
return switch (status) {
101, 103, 204, 205, 304 => true,
else => false,
};
}
// fetch()'s response shell.
pub fn initPending(exec: *const Execution) !*Response {
const arena = try exec.session.getPinnedArena(.large, "Response.pending");
@@ -190,6 +218,7 @@ fn createRedirect(url_: []const u8, status_: ?u16, exec: *const Execution) !*Res
}
fn createJson(data: js.Value, opts_: ?InitOpts, exec: *const Execution) !*Response {
try validateInit(opts_ orelse .{}, true);
const session = exec.session;
const arena = try session.getPinnedArena(.medium, "Response.json");
errdefer arena.release();
+5
View File
@@ -866,6 +866,11 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void {
});
}
if (std.mem.startsWith(u8, path, "/status/")) {
const code = try std.fmt.parseInt(u16, path["/status/".len..], 10);
return req.respond("", .{ .status = @enumFromInt(code) });
}
if (std.mem.eql(u8, path, "/xhr/500")) {
return req.respond("Internal Server Error", .{
.status = .internal_server_error,