From 8d3be3d891f842259f1bf8f0e9e4727d97cee783 Mon Sep 17 00:00:00 2001 From: srdjan <61190+srdjan@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:52:23 +0000 Subject: [PATCH 1/2] add high-level helpers to zigttp fetch module --- docs/virtual-modules/net/fetch.md | 14 +++- packages/modules/src/net/fetch.zig | 119 +++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 3 deletions(-) diff --git a/docs/virtual-modules/net/fetch.md b/docs/virtual-modules/net/fetch.md index 9a2539b..b54858d 100644 --- a/docs/virtual-modules/net/fetch.md +++ b/docs/virtual-modules/net/fetch.md @@ -5,14 +5,14 @@ Web-standard outbound HTTP client with optional durable replay. ## Summary ```ts -import { fetch } from "zigttp:fetch"; +import { get, post, fetch } from "zigttp:fetch"; export function handler(req) { // Non-durable: ordinary outbound call. - const pong = fetch("https://example.com/ping"); + const pong = get("https://example.com/ping"); // Durable: replayable across crashes, keyed by idempotency header. - const receipt = fetch("https://billing.example/charge", { + const receipt = post("https://billing.example/charge", { method: "POST", headers: { "Idempotency-Key": req.headers.get("idempotency-key") }, body: req.text(), @@ -28,8 +28,16 @@ export function handler(req) { ```ts fetch(url: string, init?: RequestInit): Response +get(url: string, init?: RequestInit): Response +post(url: string, init?: RequestInit): Response +put(url: string, init?: RequestInit): Response +patch(url: string, init?: RequestInit): Response +delete(url: string, init?: RequestInit): Response ``` +The method helpers are high-level aliases: they clone `init` and set +`method` automatically, so handler code can stay focused on intent. + `RequestInit` extends the WHATWG shape: | Field | Type | Required | Notes | diff --git a/packages/modules/src/net/fetch.zig b/packages/modules/src/net/fetch.zig index 4741216..c9dd83a 100644 --- a/packages/modules/src/net/fetch.zig +++ b/packages/modules/src/net/fetch.zig @@ -38,6 +38,66 @@ pub const binding = sdk.ModuleBinding{ .{ .arg_position = 0, .category = .fetch_host, .transform = .extract_host }, }, }, + .{ + .name = "get", + .module_func = getImpl, + .arg_count = 2, + .effect = .write, + .returns = .object, + .param_types = &.{ .string, .object }, + .return_labels = .{ .external = true }, + .contract_extractions = &.{ + .{ .arg_position = 0, .category = .fetch_host, .transform = .extract_host }, + }, + }, + .{ + .name = "post", + .module_func = postImpl, + .arg_count = 2, + .effect = .write, + .returns = .object, + .param_types = &.{ .string, .object }, + .return_labels = .{ .external = true }, + .contract_extractions = &.{ + .{ .arg_position = 0, .category = .fetch_host, .transform = .extract_host }, + }, + }, + .{ + .name = "put", + .module_func = putImpl, + .arg_count = 2, + .effect = .write, + .returns = .object, + .param_types = &.{ .string, .object }, + .return_labels = .{ .external = true }, + .contract_extractions = &.{ + .{ .arg_position = 0, .category = .fetch_host, .transform = .extract_host }, + }, + }, + .{ + .name = "patch", + .module_func = patchImpl, + .arg_count = 2, + .effect = .write, + .returns = .object, + .param_types = &.{ .string, .object }, + .return_labels = .{ .external = true }, + .contract_extractions = &.{ + .{ .arg_position = 0, .category = .fetch_host, .transform = .extract_host }, + }, + }, + .{ + .name = "delete", + .module_func = deleteImpl, + .arg_count = 2, + .effect = .write, + .returns = .object, + .param_types = &.{ .string, .object }, + .return_labels = .{ .external = true }, + .contract_extractions = &.{ + .{ .arg_position = 0, .category = .fetch_host, .transform = .extract_host }, + }, + }, }, }; @@ -49,3 +109,62 @@ fn fetchImpl(handle: *sdk.ModuleHandle, _: sdk.JSValue, args: []const sdk.JSValu try sdk.requireCapability(handle, .runtime_callback); return state.call_fn(state.runtime_ptr, handle, args); } + +fn getImpl(handle: *sdk.ModuleHandle, _: sdk.JSValue, args: []const sdk.JSValue) anyerror!sdk.JSValue { + return fetchWithMethod(handle, args, "GET"); +} + +fn postImpl(handle: *sdk.ModuleHandle, _: sdk.JSValue, args: []const sdk.JSValue) anyerror!sdk.JSValue { + return fetchWithMethod(handle, args, "POST"); +} + +fn putImpl(handle: *sdk.ModuleHandle, _: sdk.JSValue, args: []const sdk.JSValue) anyerror!sdk.JSValue { + return fetchWithMethod(handle, args, "PUT"); +} + +fn patchImpl(handle: *sdk.ModuleHandle, _: sdk.JSValue, args: []const sdk.JSValue) anyerror!sdk.JSValue { + return fetchWithMethod(handle, args, "PATCH"); +} + +fn deleteImpl(handle: *sdk.ModuleHandle, _: sdk.JSValue, args: []const sdk.JSValue) anyerror!sdk.JSValue { + return fetchWithMethod(handle, args, "DELETE"); +} + +fn fetchWithMethod(handle: *sdk.ModuleHandle, args: []const sdk.JSValue, method: []const u8) anyerror!sdk.JSValue { + const state = sdk.getModuleState(handle, FetchState, MODULE_STATE_SLOT) orelse { + return sdk.throwError(handle, "Error", "fetch helpers require runtime installation (no runtime callback wired)"); + }; + if (args.len == 0) return util.throwTypeError(handle, "fetch helper requires a URL string"); + const url = args[0]; + if (sdk.extractString(url) == null) return util.throwTypeError(handle, "fetch helper url must be a string"); + + const init = if (args.len > 1) args[1] else sdk.JSValue.undefined_val; + if (!init.isUndefined() and !init.isNull() and !sdk.isObject(init)) { + return util.throwTypeError(handle, "fetch helper init must be an object"); + } + + const final_init = if (sdk.isObject(init)) try cloneWithMethod(handle, init, method) else blk: { + const obj = try sdk.createObject(handle); + try sdk.objectSet(handle, obj, "method", try sdk.createString(handle, method)); + break :blk obj; + }; + + const forwarded = [_]sdk.JSValue{ url, final_init }; + try sdk.requireCapability(handle, .runtime_callback); + return state.call_fn(state.runtime_ptr, handle, &forwarded); +} + +fn cloneWithMethod(handle: *sdk.ModuleHandle, init: sdk.JSValue, method: []const u8) !sdk.JSValue { + const obj = try sdk.createObject(handle); + const keys = try sdk.objectKeys(handle, init); + const key_count = sdk.arrayLength(keys) orelse 0; + var i: u32 = 0; + while (i < key_count) : (i += 1) { + const key_val = sdk.arrayGet(handle, keys, i) orelse continue; + const key = sdk.extractString(key_val) orelse continue; + const val = sdk.objectGet(handle, init, key) orelse continue; + try sdk.objectSet(handle, obj, key, val); + } + try sdk.objectSet(handle, obj, "method", try sdk.createString(handle, method)); + return obj; +} From 44cb97fe05349c8ae28040cc8f44faface6cabb4 Mon Sep 17 00:00:00 2001 From: srdjan <61190+srdjan@users.noreply.github.com> Date: Wed, 29 Apr 2026 17:13:25 +0000 Subject: [PATCH 2/2] add optional retries to fetch method helpers --- docs/virtual-modules/net/fetch.md | 12 ++++---- packages/modules/src/net/fetch.zig | 47 +++++++++++++++++++++++------- 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/docs/virtual-modules/net/fetch.md b/docs/virtual-modules/net/fetch.md index b54858d..35d2fa5 100644 --- a/docs/virtual-modules/net/fetch.md +++ b/docs/virtual-modules/net/fetch.md @@ -28,15 +28,17 @@ export function handler(req) { ```ts fetch(url: string, init?: RequestInit): Response -get(url: string, init?: RequestInit): Response -post(url: string, init?: RequestInit): Response -put(url: string, init?: RequestInit): Response -patch(url: string, init?: RequestInit): Response -delete(url: string, init?: RequestInit): Response +get(url: string, init?: RequestInit, retries?: number): Response +post(url: string, init?: RequestInit, retries?: number): Response +put(url: string, init?: RequestInit, retries?: number): Response +patch(url: string, init?: RequestInit, retries?: number): Response +delete(url: string, init?: RequestInit, retries?: number): Response ``` The method helpers are high-level aliases: they clone `init` and set `method` automatically, so handler code can stay focused on intent. +They also accept `retries` (default `0`, max `8`) and retry on `5xx` +responses. `RequestInit` extends the WHATWG shape: diff --git a/packages/modules/src/net/fetch.zig b/packages/modules/src/net/fetch.zig index c9dd83a..f21f656 100644 --- a/packages/modules/src/net/fetch.zig +++ b/packages/modules/src/net/fetch.zig @@ -41,10 +41,10 @@ pub const binding = sdk.ModuleBinding{ .{ .name = "get", .module_func = getImpl, - .arg_count = 2, + .arg_count = 3, .effect = .write, .returns = .object, - .param_types = &.{ .string, .object }, + .param_types = &.{ .string, .object, .number }, .return_labels = .{ .external = true }, .contract_extractions = &.{ .{ .arg_position = 0, .category = .fetch_host, .transform = .extract_host }, @@ -53,10 +53,10 @@ pub const binding = sdk.ModuleBinding{ .{ .name = "post", .module_func = postImpl, - .arg_count = 2, + .arg_count = 3, .effect = .write, .returns = .object, - .param_types = &.{ .string, .object }, + .param_types = &.{ .string, .object, .number }, .return_labels = .{ .external = true }, .contract_extractions = &.{ .{ .arg_position = 0, .category = .fetch_host, .transform = .extract_host }, @@ -65,10 +65,10 @@ pub const binding = sdk.ModuleBinding{ .{ .name = "put", .module_func = putImpl, - .arg_count = 2, + .arg_count = 3, .effect = .write, .returns = .object, - .param_types = &.{ .string, .object }, + .param_types = &.{ .string, .object, .number }, .return_labels = .{ .external = true }, .contract_extractions = &.{ .{ .arg_position = 0, .category = .fetch_host, .transform = .extract_host }, @@ -77,10 +77,10 @@ pub const binding = sdk.ModuleBinding{ .{ .name = "patch", .module_func = patchImpl, - .arg_count = 2, + .arg_count = 3, .effect = .write, .returns = .object, - .param_types = &.{ .string, .object }, + .param_types = &.{ .string, .object, .number }, .return_labels = .{ .external = true }, .contract_extractions = &.{ .{ .arg_position = 0, .category = .fetch_host, .transform = .extract_host }, @@ -89,10 +89,10 @@ pub const binding = sdk.ModuleBinding{ .{ .name = "delete", .module_func = deleteImpl, - .arg_count = 2, + .arg_count = 3, .effect = .write, .returns = .object, - .param_types = &.{ .string, .object }, + .param_types = &.{ .string, .object, .number }, .return_labels = .{ .external = true }, .contract_extractions = &.{ .{ .arg_position = 0, .category = .fetch_host, .transform = .extract_host }, @@ -149,9 +149,10 @@ fn fetchWithMethod(handle: *sdk.ModuleHandle, args: []const sdk.JSValue, method: break :blk obj; }; + const retries = try parseRetries(handle, args); const forwarded = [_]sdk.JSValue{ url, final_init }; try sdk.requireCapability(handle, .runtime_callback); - return state.call_fn(state.runtime_ptr, handle, &forwarded); + return callWithRetries(state, handle, &forwarded, retries); } fn cloneWithMethod(handle: *sdk.ModuleHandle, init: sdk.JSValue, method: []const u8) !sdk.JSValue { @@ -168,3 +169,27 @@ fn cloneWithMethod(handle: *sdk.ModuleHandle, init: sdk.JSValue, method: []const try sdk.objectSet(handle, obj, "method", try sdk.createString(handle, method)); return obj; } + +fn parseRetries(handle: *sdk.ModuleHandle, args: []const sdk.JSValue) anyerror!i32 { + if (args.len < 3 or args[2].isUndefined() or args[2].isNull()) return 0; + const retries = sdk.extractInt(args[2]) orelse return util.throwTypeError(handle, "fetch helper retries must be an integer"); + if (retries < 0) { + return util.throwTypeError(handle, "fetch helper retries must be >= 0"); + } + return @min(retries, 8); +} + +fn callWithRetries(state: *const FetchState, handle: *sdk.ModuleHandle, forwarded: []const sdk.JSValue, retries: i32) anyerror!sdk.JSValue { + var attempts_left = retries; + while (true) { + const response = try state.call_fn(state.runtime_ptr, handle, forwarded); + const status = getResponseStatus(handle, response) orelse return response; + if (status < 500 or attempts_left <= 0) return response; + attempts_left -= 1; + } +} + +fn getResponseStatus(handle: *sdk.ModuleHandle, response: sdk.JSValue) ?i32 { + const status_val = sdk.objectGet(handle, response, "status") orelse return null; + return sdk.extractInt(status_val); +}