Skip to content

Commit ba7ab83

Browse files
committed
feat(webhooks): auto-decompress proxy response body and simplify body handling in scripts
1 parent 9208a71 commit ba7ab83

20 files changed

Lines changed: 883 additions & 122 deletions

Cargo.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ actix-web-httpauth = "0.8.2"
2121
addr = { version = "0.15.6", default-features = false }
2222
anyhow = "1.0.101"
2323
async-stream = "0.3.6"
24+
brotli = "8.0.2"
2425
byte-unit = "5.2.0"
2526
bytes = "1.11.1"
2627
chrono = { version = "0.4.43", default-features = false }
@@ -33,6 +34,7 @@ deno_error = "0.7.3"
3334
directories = "6.0.0"
3435
dotenvy = "0.15.7"
3536
figment = "0.10.19"
37+
flate2 = "1.1.9"
3638
futures = "0.3.31"
3739
handlebars = "6.4.0"
3840
hex = "0.4.3"
@@ -73,6 +75,7 @@ url = "2.5.8"
7375
urlencoding = "2.1.3"
7476
uuid = "1.20.0"
7577
zip = { version = "7.4.0", default-features = false }
78+
zstd = "0.13.3"
7679

7780
[dev-dependencies]
7881
ctor = "0.6.3"

components/secutils-docs/docs/guides/platform/deno_runtime.mdx

Lines changed: 90 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -40,18 +40,19 @@ const bytes = Deno.core.encode('Hello, world!');
4040
// Uint8Array(13) [72, 101, 108, 108, 111, 44, 32, 119, 111, 114, 108, 100, 33]
4141
```
4242

43-
Use it whenever a script needs to return a binary body:
43+
Use it whenever a script needs to explicitly encode a string to binary:
4444

4545
```javascript
4646
(() => {
47-
return {
48-
body: Deno.core.encode(
49-
JSON.stringify({ status: 'ok' })
50-
)
51-
};
47+
const bytes = Deno.core.encode('raw binary payload');
48+
return { body: bytes };
5249
})();
5350
```
5451

52+
:::tip
53+
Scripts can also return `body` as a plain string, object, or array - the runtime auto-converts them. See [Body auto-conversion](#body-auto-conversion) below.
54+
:::
55+
5556
### `Deno.core.decode(buffer)`
5657

5758
Decodes a `Uint8Array` back into a JavaScript string using **UTF-8**:
@@ -134,9 +135,7 @@ function fromBase64(base64) {
134135
(() => {
135136
const payload = JSON.stringify({ user: 'alice', role: 'admin' });
136137
const encoded = toBase64(payload);
137-
return {
138-
body: Deno.core.encode(encoded)
139-
};
138+
return { body: encoded };
140139
})();
141140
```
142141

@@ -154,7 +153,7 @@ Returns the **UTF-8 byte length** of a string without allocating a `Uint8Array`.
154153
'Content-Type': 'application/json',
155154
'Content-Length': String(Deno.core.byteLength(body)),
156155
},
157-
body: Deno.core.encode(body),
156+
body,
158157
};
159158
})();
160159
```
@@ -260,6 +259,9 @@ interface ProxyRequest {
260259
// Clamped to the server-configured maximum (default: 30 000 ms).
261260
// If omitted, the server default timeout applies.
262261
timeout?: number;
262+
// Automatically decompress the response body based on the Content-Encoding
263+
// header (gzip, deflate, br, zstd). Defaults to true.
264+
decompress?: boolean;
263265
}
264266
```
265267

@@ -271,7 +273,7 @@ interface ProxyResponse {
271273
statusCode: number;
272274
// HTTP response headers from the upstream server.
273275
headers: Record<string, string>;
274-
// Response body as an array of bytes.
276+
// Response body as an array of bytes (decompressed by default).
275277
body: number[];
276278
}
277279
```
@@ -284,13 +286,35 @@ Basic example:
284286
url: 'https://api.example.com/data',
285287
method: 'POST',
286288
headers: { 'Content-Type': 'application/json' },
287-
body: Array.from(Deno.core.encode(JSON.stringify({ key: 'value' }))),
289+
body: Deno.core.encode(JSON.stringify({ key: 'value' })),
288290
});
289291
// resp.statusCode, resp.headers, resp.body are available
290292
return resp;
291293
})()
292294
```
293295

296+
#### Transparent decompression
297+
298+
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).
299+
300+
After decompression, the original transport headers are preserved under renamed keys so you can still see what the upstream sent:
301+
302+
| Original header | Renamed to |
303+
|--------------------|-------------------------------|
304+
| `content-encoding` | `x-original-content-encoding` |
305+
| `content-length` | `x-original-content-length` |
306+
307+
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.
308+
309+
To **opt out** of automatic decompression (e.g. to forward compressed bytes as-is), pass `decompress: false`:
310+
311+
```javascript
312+
const resp = await Deno.core.ops.op_proxy_request({
313+
url: 'https://upstream/api',
314+
decompress: false, // body stays compressed, content-encoding header is preserved
315+
});
316+
```
317+
294318
**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:
295319

296320
```javascript
@@ -301,25 +325,26 @@ Basic example:
301325
return {
302326
statusCode: 502,
303327
headers: { 'Content-Type': 'text/plain' },
304-
body: Array.from(Deno.core.encode(`Proxy error: ${e.message}`)),
328+
body: `Proxy error: ${e.message}`,
305329
};
306330
}
307331
})()
308332
```
309333

310334
Possible error messages include:
311335

312-
| Error | Meaning |
313-
|-------------------------------------------|---------------------------------------------------------------------------------------|
314-
| `Invalid URL '…': …` | The `url` field could not be parsed. |
315-
| `URL not allowed (non-public address): …` | SSRF protection blocked the URL because it resolves to a private/internal IP address. |
316-
| `Invalid HTTP method: '…'` | The `method` value is not a valid HTTP method token. |
317-
| `Invalid header name: '…'` | A key in `headers` is not a valid HTTP header name. |
318-
| `Invalid header value for '…'` | A value in `headers` contains invalid characters. |
319-
| `Upstream request timed out: …` | The upstream server did not respond within the configured timeout. |
320-
| `Failed to connect to upstream: …` | Could not establish a TCP connection to the upstream server. |
321-
| `Upstream request failed: …` | A general upstream request failure (DNS, TLS, etc.). |
322-
| `Upstream response body too large: …` | The response body exceeded the configured size limit. |
336+
| Error | Meaning |
337+
|---------------------------------------------------|---------------------------------------------------------------------------------------|
338+
| `Invalid URL '…': …` | The `url` field could not be parsed. |
339+
| `URL not allowed (non-public address): …` | SSRF protection blocked the URL because it resolves to a private/internal IP address. |
340+
| `Invalid HTTP method: '…'` | The `method` value is not a valid HTTP method token. |
341+
| `Invalid header name: '…'` | A key in `headers` is not a valid HTTP header name. |
342+
| `Invalid header value for '…'` | A value in `headers` contains invalid characters. |
343+
| `Upstream request timed out: …` | The upstream server did not respond within the configured timeout. |
344+
| `Failed to connect to upstream: …` | Could not establish a TCP connection to the upstream server. |
345+
| `Upstream request failed: …` | A general upstream request failure (DNS, TLS, etc.). |
346+
| `Upstream response body too large: …` | The response body exceeded the configured size limit. |
347+
| `Failed to decompress gzip/deflate/brotli/zstd …` | The response body claimed a content-encoding but the data was invalid. |
323348

324349
:::caution Security
325350
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)](https://owasp.org/www-community/attacks/Server-Side_Request_Forgery). This restriction can be relaxed per subscription tier for local development scenarios.
@@ -332,3 +357,44 @@ Each responder has a maximum number of concurrent proxy requests it can handle s
332357
:::tip Response tracking
333358
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](/docs/guides/webhooks#track-responses) for details.
334359
:::
360+
361+
## Body auto-conversion {#body-auto-conversion}
362+
363+
Responder scripts can return `body` as several types - not just `Uint8Array`. The runtime automatically converts the value before sending the response:
364+
365+
| Script returns | Conversion | Result |
366+
|------------------------------------------|---------------------------------|-----------------------------------|
367+
| `Uint8Array` or `ArrayBuffer` | None (pass-through) | Raw bytes |
368+
| `string` | UTF-8 encode | UTF-8 bytes of the string |
369+
| Plain object (e.g. `{ key: "value" }`) | `JSON.stringify` + UTF-8 encode | UTF-8 bytes of the JSON |
370+
| Array of non-numbers (e.g. `[{ a: 1 }]`) | `JSON.stringify` + UTF-8 encode | UTF-8 bytes of the JSON array |
371+
| Array of numbers (e.g. `[72, 101]`) | `new Uint8Array(arr)` | Raw bytes (backward compatible) |
372+
| `number` or `boolean` | `JSON.stringify` + UTF-8 encode | UTF-8 bytes of `"42"` or `"true"` |
373+
| `null` or `undefined` | Skipped | Uses the responder's default body |
374+
375+
Examples:
376+
377+
```javascript
378+
// Return a JSON object directly - no Deno.core.encode needed.
379+
(() => ({
380+
headers: { 'Content-Type': 'application/json' },
381+
body: { status: 'ok', count: 42 },
382+
}))();
383+
```
384+
385+
```javascript
386+
// Return a plain text string.
387+
(() => ({ body: 'Hello, world!' }))();
388+
```
389+
390+
```javascript
391+
// Modify a proxied JSON response and return the object.
392+
(async () => {
393+
const resp = await Deno.core.ops.op_proxy_request({
394+
url: 'https://api.example.com/data',
395+
});
396+
const data = JSON.parse(Deno.core.decode(new Uint8Array(resp.body)));
397+
data._proxied = true;
398+
return { ...resp, body: data };
399+
})()
400+
```

components/secutils-docs/docs/guides/platform/secrets.mdx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,14 @@ In responder scripts, secrets are available through the `context.secrets` object
8383
```javascript
8484
(async () => {
8585
const apiKey = context.secrets.MY_API_KEY;
86-
const resp = await fetch('https://api.example.com/data', {
87-
headers: { 'Authorization': `Bearer ${apiKey}` }
86+
const resp = await Deno.core.ops.op_proxy_request({
87+
url: 'https://api.example.com/data',
88+
headers: { 'Authorization': `Bearer ${apiKey}` },
8889
});
90+
const data = JSON.parse(Deno.core.decode(new Uint8Array(resp.body)));
8991
return {
9092
headers: { 'Content-Type': 'application/json' },
91-
body: Deno.core.encode(JSON.stringify(await resp.json()))
93+
body: data
9294
};
9395
})();
9496
```

components/secutils-docs/docs/guides/webhooks.mdx

Lines changed: 16 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -174,10 +174,7 @@ The script should be provided in the form of an [Immediately Invoked Function Ex
174174
<tr><td><b>Headers</b></td><td><CodeBlock language="http">Content-Type: text/html; charset=utf-8</CodeBlock></td></tr>
175175
<tr><td><b>Script</b></td><td><CodeBlock language="javascript">{`(async () => {
176176
return {
177-
// Encode body as binary data.
178-
body: Deno.core.encode(
179-
context.query.arg ?? 'Query string does not include \`arg\` parameter'
180-
)
177+
body: context.query.arg ?? 'Query string does not include \`arg\` parameter'
181178
};
182179
})();`}</CodeBlock></td></tr>
183180
</tbody>
@@ -240,28 +237,28 @@ interface ScriptResult {
240237
// Optional HTTP headers of the response. If not specified, the default headers of responder are used.
241238
headers?: Record<string, string>;
242239
// Optional HTTP body of the response. If not specified, the default body of responder is used.
243-
body?: Uint8Array;
240+
// Accepts Uint8Array, string, object, or array - see Body auto-conversion.
241+
body?: Uint8Array | string | object;
244242
// When true, the request is not recorded in the responder's tracked request history.
245243
skipRequest?: boolean;
246244
// When true, the response sent to the client is also stored alongside the tracked request.
247245
trackResponse?: boolean;
248246
}
249247
```
250248

249+
:::tip Body auto-conversion
250+
The `body` field accepts multiple types: `Uint8Array` for raw bytes, a **string** (auto-encoded to UTF-8), a **plain object or array** (auto-serialized to JSON), or a **number/boolean** (auto-stringified). See [Body auto-conversion](/docs/guides/platform/deno_runtime#body-auto-conversion) for the full conversion table.
251+
:::
252+
251253
### Override response properties
252254
The script overrides the responder's response with a custom status code, headers, and body:
253255

254256
```javascript
255257
(async () => {
256258
return {
257259
statusCode: 201,
258-
headers: {
259-
"Content-Type": "application/json"
260-
},
261-
// Encode body as binary data.
262-
body: Deno.core.encode(
263-
JSON.stringify({ a: 1, b: 2 })
264-
)
260+
headers: { "Content-Type": "application/json" },
261+
body: { a: 1, b: 2 },
265262
};
266263
})();
267264
```
@@ -278,7 +275,7 @@ This script inspects the incoming request properties and returns them as a JSON
278275

279276
// Override response with a custom HTML body.
280277
return {
281-
body: Deno.core.encode(`
278+
body: `
282279
<h2>Request headers</h2>
283280
<table>
284281
<tr><th>Header</th><th>Value</th></tr>
@@ -303,7 +300,7 @@ This script inspects the incoming request properties and returns them as a JSON
303300
304301
<h2>Request body</h2>
305302
<pre>${JSON.stringify(parsedJsonBody, null, 2)}</pre>
306-
`)
303+
`
307304
};
308305
})();
309306
```
@@ -316,7 +313,7 @@ This script reads a user secret and includes it in the response. Secrets are man
316313
const apiKey = context.secrets.THIRD_PARTY_API_KEY ?? 'not-set';
317314
return {
318315
headers: { 'Content-Type': 'application/json' },
319-
body: Deno.core.encode(JSON.stringify({ apiKey }))
316+
body: { apiKey }
320317
};
321318
})();
322319
```
@@ -340,7 +337,7 @@ Responder scripts can forward incoming requests to a real backend using `Deno.co
340337
})()
341338
```
342339
343-
**Transform the response** - e.g. inject a field into JSON responses:
340+
**Transform the response** - e.g., inject a field into JSON responses. Compressed responses (gzip, deflate, Brotli) are automatically decompressed, so `JSON.parse` works regardless of the upstream's `Content-Encoding`:
344341
345342
```javascript
346343
(async () => {
@@ -354,11 +351,7 @@ Responder scripts can forward incoming requests to a real backend using `Deno.co
354351
if (resp.headers['content-type']?.includes('application/json')) {
355352
const body = JSON.parse(Deno.core.decode(new Uint8Array(resp.body)));
356353
body._proxied = true;
357-
return {
358-
statusCode: resp.statusCode,
359-
headers: resp.headers,
360-
body: Array.from(Deno.core.encode(JSON.stringify(body))),
361-
};
354+
return { statusCode: resp.statusCode, headers: resp.headers, body };
362355
}
363356
364357
return resp;
@@ -377,10 +370,7 @@ Responder scripts can forward incoming requests to a real backend using `Deno.co
377370
body: context.body,
378371
});
379372
}
380-
return {
381-
statusCode: 200,
382-
body: Array.from(Deno.core.encode(JSON.stringify({ mock: true }))),
383-
};
373+
return { statusCode: 200, body: { mock: true } };
384374
})()
385375
```
386376
@@ -394,10 +384,7 @@ By default every incoming request is recorded in the responder's tracked history
394384
(async () => {
395385
// Only track non-health-check requests.
396386
const skip = context.path === '/healthz' || context.path === '/readyz';
397-
return {
398-
body: Deno.core.encode('ok'),
399-
skipRequest: skip,
400-
};
387+
return { body: 'ok', skipRequest: skip };
401388
})();
402389
```
403390
36 Bytes
Loading
12 Bytes
Loading
-4 Bytes
Loading
-2 Bytes
Loading
-13 Bytes
Loading

0 commit comments

Comments
 (0)