Deno Sandbox Runtime
Webhook responder scripts and API tracker extractor / configurator scripts run inside a restricted Deno runtime. The sandbox intentionally limits what code can do:
- Limited network access -
fetch,XMLHttpRequest, and standard network APIs are unavailable. Responder scripts can make outbound HTTP requests throughDeno.core.ops.op_proxy_request()with built-in SSRF protection. - No file-system access - reading or writing files is not possible.
- No
btoa/atob- the standard Base64 globals are not exposed (see Base64 encoding below for a workaround).
What is available:
- All standard JavaScript built-ins (
JSON,Math,Date,Map,Set,Array,TextEncoder,TextDecoder,Promise, etc.). - The
Deno.coreutility namespace documented on this page. - The global
contextvariable injected by Secutils.dev with request/response data (see the individual guide pages for its shape).
The complete list of Deno.core members exposed in the sandbox is published at
demo.webhooks.dev.secutils.dev/deno-apis.
This page covers only the subset that is most useful when writing scripts.
Encoding and decoding
These are the most commonly used APIs. Every script that builds or reads a binary body will use at least encode and decode.
Deno.core.encode(text)
Encodes a JavaScript string into a Uint8Array using UTF-8:
const bytes = Deno.core.encode('Hello, world!');
// Uint8Array(13) [72, 101, 108, 108, 111, 44, 32, 119, 111, 114, 108, 100, 33]
Use it whenever a script needs to explicitly encode a string to binary:
(() => {
const bytes = Deno.core.encode('raw binary payload');
return { body: bytes };
})();
Scripts can also return body as a plain string, object, or array - the runtime auto-converts them. See Body auto-conversion below.
Deno.core.decode(buffer)
Decodes a Uint8Array back into a JavaScript string using UTF-8:
const text = Deno.core.decode(new Uint8Array([72, 101, 108, 108, 111]));
// 'Hello'
Typical use - parsing an incoming JSON body in a responder or extractor:
const body = context.body.length > 0
? JSON.parse(Deno.core.decode(new Uint8Array(context.body)))
: {};
Deno.core.encodeBinaryString(buffer)
Converts a Uint8Array into a binary string where each byte maps to a single Latin-1 character. This is the same encoding that String.fromCharCode would produce byte-by-byte, but much faster for large buffers:
const bin = Deno.core.encodeBinaryString(
new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f])
);
// 'Hello'
This function is particularly useful as a building block for Base64 encoding.
Base64 encoding
The sandbox does not expose the standard btoa and atob globals. You can implement Base64 encoding and decoding with pure JavaScript and the APIs above.
Encode (btoa replacement)
function toBase64(input) {
const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
const bytes = typeof input === 'string'
? Deno.core.encode(input)
: input;
let result = '';
for (let i = 0; i < bytes.length; i += 3) {
const a = bytes[i];
const b = i + 1 < bytes.length ? bytes[i + 1] : 0;
const c = i + 2 < bytes.length ? bytes[i + 2] : 0;
result += CHARS[a >> 2]
+ CHARS[((a & 3) << 4) | (b >> 4)]
+ (i + 1 < bytes.length ? CHARS[((b & 15) << 2) | (c >> 6)] : '=')
+ (i + 2 < bytes.length ? CHARS[c & 63] : '=');
}
return result;
}
Decode (atob replacement)
function fromBase64(base64) {
const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
const stripped = base64.replace(/=+$/, '');
const bytes = [];
for (let i = 0; i < stripped.length; i += 4) {
const a = CHARS.indexOf(stripped[i]);
const b = CHARS.indexOf(stripped[i + 1]);
const c = CHARS.indexOf(stripped[i + 2]);
const d = CHARS.indexOf(stripped[i + 3]);
bytes.push((a << 2) | (b >> 4));
if (c >= 0) bytes.push(((b & 15) << 4) | (c >> 2));
if (d >= 0) bytes.push(((c & 3) << 6) | d);
}
return new Uint8Array(bytes);
}
Example: return a Base64-encoded value
(() => {
const payload = JSON.stringify({ user: 'alice', role: 'admin' });
const encoded = toBase64(payload);
return { body: encoded };
})();
String utilities
Deno.core.byteLength(str)
Returns the UTF-8 byte length of a string without allocating a Uint8Array. This is handy when you need to set a Content-Length header:
(() => {
const body = JSON.stringify({ status: 'ok' });
return {
headers: {
'Content-Type': 'application/json',
'Content-Length': String(Deno.core.byteLength(body)),
},
body,
};
})();
Debugging
Deno.core.print(msg, isErr)
Prints a string to the runtime's output stream. Pass true as the second argument to write to stderr instead of stdout:
Deno.core.print('Debug: processing request\n', false);
Deno.core.print('Warning: missing field\n', true);
Output from Deno.core.print is written to the server log, not returned to the HTTP client. Use it only for temporary debugging.
Deep copy
Deno.core.structuredClone(value)
Creates a deep copy of a value using the structured clone algorithm, the same mechanism behind the global structuredClone in modern runtimes:
const original = { nested: { count: 1 }, items: [1, 2, 3] };
const copy = Deno.core.structuredClone(original);
copy.nested.count = 99;
// original.nested.count is still 1
Type checking
Deno.core exposes a set of is* predicates that mirror Node.js's util.types. They are useful when your script handles input whose shape is not known in advance.
| Predicate | Returns true for |
|---|---|
Deno.core.isDate(v) | Date instances |
Deno.core.isRegExp(v) | RegExp instances |
Deno.core.isMap(v) | Map instances |
Deno.core.isSet(v) | Set instances |
Deno.core.isTypedArray(v) | Any typed array (Uint8Array, Float64Array, etc.) |
Deno.core.isArrayBuffer(v) | ArrayBuffer instances |
Deno.core.isArrayBufferView(v) | Any ArrayBuffer view (typed arrays and DataView) |
Deno.core.isPromise(v) | Promise instances |
Deno.core.isNativeError(v) | Native Error instances (including subtypes) |
if (Deno.core.isTypedArray(context.body)) {
Deno.core.print('Body is already a typed array\n', false);
}
Serialization (advanced)
For advanced use cases that need compact binary encoding beyond JSON, Deno.core exposes V8's built-in serialization format:
Deno.core.serialize(value)
Serializes a JavaScript value into a Uint8Array using V8's internal format. This preserves types that JSON.stringify cannot, such as Map, Set, Date, RegExp, ArrayBuffer, and typed arrays:
const data = { created: new Date(), tags: new Set(['a', 'b']) };
const packed = Deno.core.serialize(data);
Deno.core.deserialize(buffer)
Restores the value from a buffer previously produced by Deno.core.serialize:
const restored = Deno.core.deserialize(packed);
// restored.created is a Date, restored.tags is a Set
The serialization format is specific to V8 and is not portable across engines or language runtimes. Prefer JSON for data that will be consumed outside of the Deno sandbox.
Proxy requests
Deno.core.ops.op_proxy_request(request)
Sends an HTTP request to an upstream server and returns the response. This enables responder scripts to act as a MITM proxy - forwarding requests to a real backend, inspecting or transforming responses, and returning them to the client. See Proxy requests to an upstream service for usage examples.
The request argument has the following interface:
interface ProxyRequest {
// Target URL (required). Must be http:// or https://.
url: string;
// HTTP method. Defaults to "GET".
method?: string;
// Optional HTTP headers to send with the request.
headers?: Record<string, string>;
// Optional binary request body as an array of bytes.
body?: number[];
// Skip TLS certificate validation. Defaults to false.
// Useful for proxying to upstream servers with self-signed certificates.
insecure?: boolean;
// Per-request timeout in milliseconds.
// Clamped to the server-configured maximum (default: 30 000 ms).
// If omitted, the server default timeout applies.
timeout?: number;
// Automatically decompress the response body based on the Content-Encoding
// header (gzip, deflate, br, zstd). Defaults to true.
decompress?: boolean;
}
The returned value has the following interface:
interface ProxyResponse {
// HTTP status code from the upstream server.
statusCode: number;
// HTTP response headers from the upstream server.
headers: Record<string, string>;
// Response body as an array of bytes (decompressed by default).
body: number[];
}
Basic example:
(async () => {
const resp = await Deno.core.ops.op_proxy_request({
url: 'https://api.example.com/data',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: Deno.core.encode(JSON.stringify({ key: 'value' })),
});
// resp.statusCode, resp.headers, resp.body are available
return resp;
})()
Transparent decompression
When the upstream server returns a compressed response (e.g. Content-Encoding: gzip), op_proxy_request automatically decompresses the body before returning it to the script. Supported encodings: gzip, x-gzip, deflate, br (Brotli), and zstd (Zstandard).
After decompression, the original transport headers are preserved under renamed keys so you can still see what the upstream sent:
| Original header | Renamed to |
|---|---|
content-encoding | x-original-content-encoding |
content-length | x-original-content-length |
This means scripts can always work with the decompressed body directly (e.g. JSON.parse(Deno.core.decode(new Uint8Array(resp.body)))) regardless of whether the upstream compresses its responses.
To opt out of automatic decompression (e.g. to forward compressed bytes as-is), pass decompress: false:
const resp = await Deno.core.ops.op_proxy_request({
url: 'https://upstream/api',
decompress: false, // body stays compressed, content-encoding header is preserved
});
Error handling. When the upstream request fails, the op throws a JavaScript error with a descriptive message. Scripts can catch these errors and return a custom response:
(async () => {
try {
return await Deno.core.ops.op_proxy_request({ url: 'https://upstream/api' });
} catch (e) {
return {
statusCode: 502,
headers: { 'Content-Type': 'text/plain' },
body: `Proxy error: ${e.message}`,
};
}
})()
Possible error messages include:
| Error | Meaning |
|---|---|
Invalid URL '…': … | The url field could not be parsed. |
URL not allowed (non-public address): … | SSRF protection blocked the URL because it resolves to a private/internal IP address. |
Invalid HTTP method: '…' | The method value is not a valid HTTP method token. |
Invalid header name: '…' | A key in headers is not a valid HTTP header name. |
Invalid header value for '…' | A value in headers contains invalid characters. |
Upstream request timed out: … | The upstream server did not respond within the configured timeout. |
Failed to connect to upstream: … | Could not establish a TCP connection to the upstream server. |
Upstream request failed: … | A general upstream request failure (DNS, TLS, etc.). |
Upstream response body too large: … | The response body exceeded the configured size limit. |
Failed to decompress gzip/deflate/brotli/zstd … | The response body claimed a content-encoding but the data was invalid. |
By default, op_proxy_request only allows requests to publicly routable IP addresses. URLs that resolve to private or loopback addresses (e.g. 127.0.0.1, 10.x.x.x, 192.168.x.x) are rejected to prevent Server-Side Request Forgery (SSRF). This restriction can be relaxed per subscription tier for local development scenarios.
Each responder has a maximum number of concurrent proxy requests it can handle simultaneously (configurable per subscription tier). When the limit is reached, additional requests receive a 429 Too Many Requests response with a Retry-After header. The upstream response body size is also limited (default: 10 MB) to prevent memory exhaustion. Every proxy request has a timeout (default: 30 s) that can be lowered per-request via the timeout field but cannot exceed the server-configured maximum. All proxy requests use HTTP/1.1 - HTTP/2 is not supported.
When using op_proxy_request, you can store the upstream response alongside the tracked request by returning trackResponse: true in your script result. This is especially useful for debugging proxy issues. See Track responses for details.
Body auto-conversion
Responder scripts can return body as several types - not just Uint8Array. The runtime automatically converts the value before sending the response:
| Script returns | Conversion | Result |
|---|---|---|
Uint8Array or ArrayBuffer | None (pass-through) | Raw bytes |
string | UTF-8 encode | UTF-8 bytes of the string |
Plain object (e.g. { key: "value" }) | JSON.stringify + UTF-8 encode | UTF-8 bytes of the JSON |
Array of non-numbers (e.g. [{ a: 1 }]) | JSON.stringify + UTF-8 encode | UTF-8 bytes of the JSON array |
Array of numbers (e.g. [72, 101]) | new Uint8Array(arr) | Raw bytes (backward compatible) |
number or boolean | JSON.stringify + UTF-8 encode | UTF-8 bytes of "42" or "true" |
null or undefined | Skipped | Uses the responder's default body |
Examples:
// Return a JSON object directly - no Deno.core.encode needed.
(() => ({
headers: { 'Content-Type': 'application/json' },
body: { status: 'ok', count: 42 },
}))();
// Return a plain text string.
(() => ({ body: 'Hello, world!' }))();
// Modify a proxied JSON response and return the object.
(async () => {
const resp = await Deno.core.ops.op_proxy_request({
url: 'https://api.example.com/data',
});
const data = JSON.parse(Deno.core.decode(new Uint8Array(resp.body)));
data._proxied = true;
return { ...resp, body: data };
})()