Skip to content
Draft
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
16 changes: 13 additions & 3 deletions docs/virtual-modules/net/fetch.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -28,8 +28,18 @@ export function handler(req) {

```ts
fetch(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:

| Field | Type | Required | Notes |
Expand Down
144 changes: 144 additions & 0 deletions packages/modules/src/net/fetch.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 = 3,
.effect = .write,
.returns = .object,
.param_types = &.{ .string, .object, .number },
.return_labels = .{ .external = true },
.contract_extractions = &.{
.{ .arg_position = 0, .category = .fetch_host, .transform = .extract_host },
},
},
.{
.name = "post",
.module_func = postImpl,
.arg_count = 3,
.effect = .write,
.returns = .object,
.param_types = &.{ .string, .object, .number },
.return_labels = .{ .external = true },
.contract_extractions = &.{
.{ .arg_position = 0, .category = .fetch_host, .transform = .extract_host },
},
},
.{
.name = "put",
.module_func = putImpl,
.arg_count = 3,
.effect = .write,
.returns = .object,
.param_types = &.{ .string, .object, .number },
.return_labels = .{ .external = true },
.contract_extractions = &.{
.{ .arg_position = 0, .category = .fetch_host, .transform = .extract_host },
},
},
.{
.name = "patch",
.module_func = patchImpl,
.arg_count = 3,
.effect = .write,
.returns = .object,
.param_types = &.{ .string, .object, .number },
.return_labels = .{ .external = true },
.contract_extractions = &.{
.{ .arg_position = 0, .category = .fetch_host, .transform = .extract_host },
},
},
.{
.name = "delete",
.module_func = deleteImpl,
.arg_count = 3,
.effect = .write,
.returns = .object,
.param_types = &.{ .string, .object, .number },
.return_labels = .{ .external = true },
.contract_extractions = &.{
.{ .arg_position = 0, .category = .fetch_host, .transform = .extract_host },
},
},
},
};

Expand All @@ -49,3 +109,87 @@ 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 retries = try parseRetries(handle, args);
const forwarded = [_]sdk.JSValue{ url, final_init };
try sdk.requireCapability(handle, .runtime_callback);
return callWithRetries(state, handle, &forwarded, retries);
}

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;
}

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);
}