From 258b33bc8dc7938799e6ea654dd5e20be3f61648 Mon Sep 17 00:00:00 2001 From: Gabe Kangas Date: Fri, 18 Sep 2026 10:08:30 -0700 Subject: [PATCH 1/3] feat(http): support binary response bodies --- docs/PLUGIN_AUTHOR_GUIDE.md | 15 +++++-- docs/WIRE_PROTOCOL.md | 11 ++++- .../__tests__/binary.test.json | 14 +++++++ .../js/all-permissions-test/src/plugin.js | 9 +++- examples/js/safeguard-stress/src/plugin.js | 6 +-- .../__tests__/binary.test.json | 4 +- .../python/all-permissions-test/src/plugin.py | 2 +- .../python/safeguard-stress/src/plugin.py | 4 +- host-runtime/plugin/testing/runner.go | 10 +++++ host-runtime/plugin/testing/scenario.go | 1 + sdks/js/index.d.ts | 3 +- sdks/js/index.js | 41 ++++++++++++++++++- sdks/python/README.md | 2 +- sdks/python/owncast_plugin/__init__.py | 12 +++++- 14 files changed, 115 insertions(+), 19 deletions(-) create mode 100644 examples/js/all-permissions-test/__tests__/binary.test.json diff --git a/docs/PLUGIN_AUTHOR_GUIDE.md b/docs/PLUGIN_AUTHOR_GUIDE.md index 4119272..7cbe01c 100644 --- a/docs/PLUGIN_AUTHOR_GUIDE.md +++ b/docs/PLUGIN_AUTHOR_GUIDE.md @@ -336,12 +336,19 @@ interface IncomingHttpRequest { interface OutgoingHttpResponse { status?: number; // default 200 headers?: Record; - body?: string; + body?: string | Uint8Array; } ``` Endpoints are public by default. Gate admin features with `req.authenticated`. +Response strings are sent as UTF-8. In JavaScript, set the response `body` to a +`Uint8Array`. In Python, return `bytes` or `bytearray` directly or use either +as a dictionary body. Set the appropriate `Content-Type` header for the client. + +Byte response bodies require Owncast v0.3.1 or later. Older hosts return an +empty body. + `req.user` is the chat user the request came from, if Owncast could identify one. The host resolves it from the visitor's chat identity cookie, which is set when they register or connect to chat, so it is available on normal viewer @@ -632,7 +639,7 @@ The host enforces these caps per plugin. They're generous for normal use. Size p | `on_filter` output | 1 MiB | the (modified) message a filter returns | | HTTP request body delivered to your handler | 1 MB | inbound `onHttpRequest` body | | HTTP response body | 10 MB | what `onHttpRequest` returns | -| `on_http_request` envelope output | 12 MiB | the full encoded response envelope | +| `on_http_request` envelope output | 16 MiB | the full encoded response envelope | | Pending timers | 64 | `owncast.timer.setTimeout`/`setInterval` outstanding at once | | Timer delay | 100 ms to 24 h | clamped into this range | | SSE connections | 64 | concurrent browser clients on your event stream | @@ -662,7 +669,7 @@ my-plugin/ A request to `/plugins/my-plugin/` serves `public/index.html` automatically. -For dynamic endpoints (JSON APIs, webhooks, etc.) write an `onHttpRequest`. Path traversal is blocked, response headers are filtered through an allowlist (allowed: `Content-Type`, `Cache-Control`, `Set-Cookie`, `Location`, `ETag`, `Last-Modified`, `Vary`, `Link`, and CORS headers, with host-owned things like `Server`, CSP, and HSTS blocked), and body sizes are capped at 1 MB request / 10 MB response. Cookies you set default to a `Path` scoped to your plugin's namespace. +For dynamic endpoints (JSON APIs, webhooks, generated files, etc.) write an `onHttpRequest`. Return a string body for UTF-8 text or a byte body for arbitrary content. Path traversal is blocked, response headers are filtered through an allowlist (allowed: `Content-Type`, `Cache-Control`, `Set-Cookie`, `Location`, `ETag`, `Last-Modified`, `Vary`, `Link`, and CORS headers, with host-owned things like `Server`, CSP, and HSTS blocked), and body sizes are capped at 1 MB request / 10 MB response. Cookies you set default to a `Path` scoped to your plugin's namespace. ## Realtime updates (Server-Sent Events) @@ -1177,7 +1184,7 @@ Point your `test` script at that one entry (`node __tests__/index.test.js`) inst - `event: ""`, fire-and-forget notification dispatch - `filter: ""`, filter chain. Inline `expect: {action, payload?, reason?}` checks the FilterResult -- `http: { method, path, headers?, body?, user?, authenticated?, expect: {status, headers?, body?, bodyContains?} }`, sends an HTTP request through your plugin server +- `http: { method, path, headers?, body?, user?, authenticated?, expect: {status, headers?, body?, bodyContains?, bodyBase64?} }`, sends an HTTP request through your plugin server. Use `bodyBase64` to compare arbitrary response bytes. - `tabContent: { slug, user?, expect: {body?, bodyContains?} }`, calls `onTabContent` directly and asserts on the returned HTML - `pageContent: { slug, user?, expect: {body?, bodyContains?} }`, calls `onPageContent` directly and asserts on the returned HTML - `pageStyles: { expect: {body?, bodyContains?} }`, calls `onPageStyles` directly and asserts on the returned CSS diff --git a/docs/WIRE_PROTOCOL.md b/docs/WIRE_PROTOCOL.md index 606ebaf..9c88960 100644 --- a/docs/WIRE_PROTOCOL.md +++ b/docs/WIRE_PROTOCOL.md @@ -807,6 +807,7 @@ type OutgoingHttpResponse = { status?: number; headers?: { [key: string]: string }; body?: string; + bodyBase64?: string; }; type ContentRequest = { @@ -826,7 +827,15 @@ type AuthCheckResult = The host always supplies every non-optional `IncomingHttpRequest` key. It omits `user` for anonymous viewers and admin-only authentication. A missing or -zero response status defaults to 200. +zero response status defaults to 200. `body` contains UTF-8 text. +`bodyBase64` contains arbitrary response bytes encoded as standard base64. +Responses must not contain both fields. The JavaScript SDK uses `bodyBase64` +internally when a response's `body` is a `Uint8Array`. The Python SDK uses it +when a handler returns `bytes` or `bytearray` directly or sets its dictionary +body to either type. + +Byte response bodies require Owncast v0.3.1 or later. Older hosts ignore +`bodyBase64` and return an empty body. ### Stream and server data diff --git a/examples/js/all-permissions-test/__tests__/binary.test.json b/examples/js/all-permissions-test/__tests__/binary.test.json new file mode 100644 index 0000000..5e07b9e --- /dev/null +++ b/examples/js/all-permissions-test/__tests__/binary.test.json @@ -0,0 +1,14 @@ +[ + { + "name": "invalid UTF-8 is returned as raw HTTP bytes", + "events": [ + { + "http": { + "method": "GET", + "path": "/binary-response", + "expect": { "status": 200, "bodyBase64": "/wCA" } + } + } + ] + } +] diff --git a/examples/js/all-permissions-test/src/plugin.js b/examples/js/all-permissions-test/src/plugin.js index c40c184..94772c7 100644 --- a/examples/js/all-permissions-test/src/plugin.js +++ b/examples/js/all-permissions-test/src/plugin.js @@ -48,7 +48,14 @@ module.exports = definePlugin({ onFediverseMention() {}, onFediverseReply() {}, // Requires http.serve. - onHttpRequest: () => ({ status: 204 }), + onHttpRequest: (req) => + req.path === "/binary-response" + ? { + status: 200, + headers: { "Content-Type": "application/octet-stream" }, + body: new Uint8Array([0xff, 0x00, 0x80]), + } + : { status: 204 }, // Requires auth.gate. onAuthCheck: () => authCheck.ok(), // Require ui.modify. diff --git a/examples/js/safeguard-stress/src/plugin.js b/examples/js/safeguard-stress/src/plugin.js index 7163407..dbe60d8 100644 --- a/examples/js/safeguard-stress/src/plugin.js +++ b/examples/js/safeguard-stress/src/plugin.js @@ -14,10 +14,10 @@ function hugeString(bytes) { // Sized just over each cap so the handler can serialize and return the // payload within the per-call timeout, the tests want the *size* check to // fire, not the timeout. (MaxFilterOutputBytes = 1 MiB, and we send 1.1 MiB. -// MaxHTTPHandlerOutputBytes = 12 MiB, and the HTTP test has a 5s call cap so -// 13 MiB is fine there.) +// MaxHTTPHandlerOutputBytes = 16 MiB, and the HTTP test has a 5s call cap so +// 17 MiB is fine there.) const HUGE_FILTER_BODY = hugeString(1126400); // ~1.075 MiB, > 1 MiB cap -const HUGE_HTTP_BODY = hugeString(13 * 1024 * 1024); +const HUGE_HTTP_BODY = hugeString(17 * 1024 * 1024); module.exports = definePlugin({ filterChatMessage(msg) { diff --git a/examples/python/all-permissions-test/__tests__/binary.test.json b/examples/python/all-permissions-test/__tests__/binary.test.json index e6b6348..c82b3dc 100644 --- a/examples/python/all-permissions-test/__tests__/binary.test.json +++ b/examples/python/all-permissions-test/__tests__/binary.test.json @@ -1,12 +1,12 @@ [ { - "name": "invalid UTF-8 survives assets, filesystem, and upload as raw bytes", + "name": "invalid UTF-8 survives assets, filesystem, upload, and HTTP as raw bytes", "events": [ { "http": { "method": "GET", "path": "/binary-round-trip", - "expect": { "status": 200, "body": "/wCA" } + "expect": { "status": 200, "bodyBase64": "/wCA" } } } ], diff --git a/examples/python/all-permissions-test/src/plugin.py b/examples/python/all-permissions-test/src/plugin.py index 5470b64..c010291 100644 --- a/examples/python/all-permissions-test/src/plugin.py +++ b/examples/python/all-permissions-test/src/plugin.py @@ -134,7 +134,7 @@ def on_http_request(req): owncast.fs.write("invalid-utf8.bin", data) stored = owncast.fs.read("invalid-utf8.bin") owncast.storage.upload("invalid-utf8.bin", stored) - return {"status": 200, "body": base64.b64encode(stored).decode("ascii")} + return {"status": 200, "body": stored} # Requires auth.gate. diff --git a/examples/python/safeguard-stress/src/plugin.py b/examples/python/safeguard-stress/src/plugin.py index fea65b7..c8f7659 100644 --- a/examples/python/safeguard-stress/src/plugin.py +++ b/examples/python/safeguard-stress/src/plugin.py @@ -15,10 +15,10 @@ def huge_string(num_bytes): # Sized just over each cap so the handler can serialize and return the # payload within the per-call timeout. The tests want the *size* check to # fire, not the timeout. (MaxFilterOutputBytes = 1 MiB, we send 1.1 MiB. -# MaxHTTPHandlerOutputBytes = 12 MiB, HTTP test has a 5s call cap so 13 MiB +# MaxHTTPHandlerOutputBytes = 16 MiB, HTTP test has a 5s call cap so 17 MiB # is fine there.) HUGE_FILTER_BODY = huge_string(1126400) # ~1.075 MiB, > 1 MiB cap -HUGE_HTTP_BODY = huge_string(13 * 1024 * 1024) +HUGE_HTTP_BODY = huge_string(17 * 1024 * 1024) @plugin.filter_chat_message diff --git a/host-runtime/plugin/testing/runner.go b/host-runtime/plugin/testing/runner.go index 20c09d1..74654fd 100644 --- a/host-runtime/plugin/testing/runner.go +++ b/host-runtime/plugin/testing/runner.go @@ -1,6 +1,7 @@ package testing import ( + "bytes" "context" "encoding/base64" "encoding/json" @@ -346,6 +347,15 @@ func runHTTPStep(server *plugin.Server, pluginName string, h *HTTPStep) error { if h.Expect.BodyContains != "" && !strings.Contains(rec.Body.String(), h.Expect.BodyContains) { return fmt.Errorf("http body does not contain %q\n body: %q", h.Expect.BodyContains, rec.Body.String()) } + if h.Expect.BodyBase64 != nil { + want, err := decodeScenarioBase64(*h.Expect.BodyBase64) + if err != nil { + return fmt.Errorf("http bodyBase64 is invalid: %w", err) + } + if !bytes.Equal(want, rec.Body.Bytes()) { + return fmt.Errorf("http bodyBase64: want %q got %q", *h.Expect.BodyBase64, base64.StdEncoding.EncodeToString(rec.Body.Bytes())) + } + } return nil } diff --git a/host-runtime/plugin/testing/scenario.go b/host-runtime/plugin/testing/scenario.go index c518c99..12a04d3 100644 --- a/host-runtime/plugin/testing/scenario.go +++ b/host-runtime/plugin/testing/scenario.go @@ -124,6 +124,7 @@ type HTTPExpect struct { Headers map[string]string `json:"headers,omitempty"` Body string `json:"body,omitempty"` BodyContains string `json:"bodyContains,omitempty"` + BodyBase64 *string `json:"bodyBase64,omitempty"` } // ContentStep invokes the plugin's on_tab_content or on_page_content export. diff --git a/sdks/js/index.d.ts b/sdks/js/index.d.ts index 714b077..caa254d 100644 --- a/sdks/js/index.d.ts +++ b/sdks/js/index.d.ts @@ -398,7 +398,8 @@ export interface IncomingHttpRequest { export interface OutgoingHttpResponse { status?: number; headers?: Record; - body?: string; + /** Text is sent as UTF-8. Uint8Array preserves arbitrary response bytes. */ + body?: string | Uint8Array; } /** Request context passed to `onTabContent` and `onPageContent` handlers. */ diff --git a/sdks/js/index.js b/sdks/js/index.js index 08b5344..ff1025c 100644 --- a/sdks/js/index.js +++ b/sdks/js/index.js @@ -338,6 +338,38 @@ function dispatchFilter(envelope) { return filter.pass(); } +function encodeBase64(bytes) { + const alphabet = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + const chunks = []; + let chunk = ""; + let i = 0; + for (; i + 2 < bytes.length; i += 3) { + const value = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2]; + chunk += + alphabet[(value >> 18) & 63] + + alphabet[(value >> 12) & 63] + + alphabet[(value >> 6) & 63] + + alphabet[value & 63]; + if (chunk.length >= 16384) { + chunks.push(chunk); + chunk = ""; + } + } + if (i < bytes.length) { + const value = + (bytes[i] << 16) | + (i + 1 < bytes.length ? bytes[i + 1] << 8 : 0); + chunk += + alphabet[(value >> 18) & 63] + + alphabet[(value >> 12) & 63] + + (i + 1 < bytes.length ? alphabet[(value >> 6) & 63] : "=") + + "="; + } + chunks.push(chunk); + return chunks.join(""); +} + // dispatchHttp routes incoming HTTP requests to the user's onHttpRequest // handler. Returns a default 404 if the plugin doesn't define one. function dispatchHttp(request) { @@ -346,11 +378,16 @@ function dispatchHttp(request) { } const out = registered.onHttpRequest(request); if (!out) return { status: 200, headers: {}, body: "" }; - return { + const response = { status: out.status || 200, headers: out.headers || {}, - body: out.body == null ? "" : String(out.body), }; + if (out.body instanceof Uint8Array) { + response.bodyBase64 = encodeBase64(out.body); + } else { + response.body = out.body == null ? "" : String(out.body); + } + return response; } diff --git a/sdks/python/README.md b/sdks/python/README.md index ecea950..25d97c8 100644 --- a/sdks/python/README.md +++ b/sdks/python/README.md @@ -110,7 +110,7 @@ def fallback(req): - `@plugin.get/post/put/delete/patch(path)` and `@plugin.route(path, methods=[...])` for method-specific routes, and `@plugin.on_http_request(path)` for any method. - Paths are exact and **plugin-relative** (e.g. `/api/messages`), excluding the query string. Read query params from `req.query`. - A request whose path matches a route but not its method gets an automatic **405**. An unmatched path falls through to the bare catch-all, else **404**. -- A handler returns a `dict` (`{status, body, headers}`), a `str` (→ 200), or `None` (→ 204). +- A handler returns a `dict` (`{status, body, headers}`), a `str`, `bytes`, or `bytearray` (→ 200), or `None` (→ 204). A dictionary body may also be `bytes` or `bytearray`. ### The `owncast` host API diff --git a/sdks/python/owncast_plugin/__init__.py b/sdks/python/owncast_plugin/__init__.py index 5159005..8304408 100644 --- a/sdks/python/owncast_plugin/__init__.py +++ b/sdks/python/owncast_plugin/__init__.py @@ -25,6 +25,7 @@ def greet(msg): except ImportError: # pragma: no cover - dev machine, not wasm extism = None +import base64 import json __all__ = ["plugin", "owncast", "filter", "auth_check", "CommandContext"] @@ -884,9 +885,18 @@ def _dispatch_filter(envelope): def _http_response(resp): if isinstance(resp, dict): - return resp + out = dict(resp) + body = out.get("body") + if isinstance(body, (bytes, bytearray)): + del out["body"] + out["bodyBase64"] = base64.b64encode(bytes(body)).decode("ascii") + elif "body" in out: + out.pop("bodyBase64", None) + return out if resp is None: return {"status": 204} + if isinstance(resp, (bytes, bytearray)): + return {"status": 200, "bodyBase64": base64.b64encode(bytes(resp)).decode("ascii")} return {"status": 200, "body": str(resp)} From f23b4161b4bcd55923f24c6f0afbc0506568fc82 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:16:36 +0000 Subject: [PATCH 2/3] docs: clarify JS HTTP response return shape Co-authored-by: gabek <414923+gabek@users.noreply.github.com> --- docs/PLUGIN_AUTHOR_GUIDE.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/PLUGIN_AUTHOR_GUIDE.md b/docs/PLUGIN_AUTHOR_GUIDE.md index 7cbe01c..e7afec8 100644 --- a/docs/PLUGIN_AUTHOR_GUIDE.md +++ b/docs/PLUGIN_AUTHOR_GUIDE.md @@ -342,9 +342,10 @@ interface OutgoingHttpResponse { Endpoints are public by default. Gate admin features with `req.authenticated`. -Response strings are sent as UTF-8. In JavaScript, set the response `body` to a -`Uint8Array`. In Python, return `bytes` or `bytearray` directly or use either -as a dictionary body. Set the appropriate `Content-Type` header for the client. +Response strings are sent as UTF-8. In JavaScript, return an object and set its +`body` to a string for text or a `Uint8Array` for arbitrary bytes. In Python, +you may return `bytes` or `bytearray` directly or use either as a dictionary +body. Set the appropriate `Content-Type` header for the client. Byte response bodies require Owncast v0.3.1 or later. Older hosts return an empty body. @@ -669,7 +670,7 @@ my-plugin/ A request to `/plugins/my-plugin/` serves `public/index.html` automatically. -For dynamic endpoints (JSON APIs, webhooks, generated files, etc.) write an `onHttpRequest`. Return a string body for UTF-8 text or a byte body for arbitrary content. Path traversal is blocked, response headers are filtered through an allowlist (allowed: `Content-Type`, `Cache-Control`, `Set-Cookie`, `Location`, `ETag`, `Last-Modified`, `Vary`, `Link`, and CORS headers, with host-owned things like `Server`, CSP, and HSTS blocked), and body sizes are capped at 1 MB request / 10 MB response. Cookies you set default to a `Path` scoped to your plugin's namespace. +For dynamic endpoints (JSON APIs, webhooks, generated files, etc.) write an `onHttpRequest`. In JavaScript, return `{ status, headers, body }` and put either a string or `Uint8Array` in `body`. In Python, you may return a string/bytes value directly or return a response dictionary. Path traversal is blocked, response headers are filtered through an allowlist (allowed: `Content-Type`, `Cache-Control`, `Set-Cookie`, `Location`, `ETag`, `Last-Modified`, `Vary`, `Link`, and CORS headers, with host-owned things like `Server`, CSP, and HSTS blocked), and body sizes are capped at 1 MB request / 10 MB response. Cookies you set default to a `Path` scoped to your plugin's namespace. ## Realtime updates (Server-Sent Events) From 0829a429c9cb6b8ce97086281accc0dc8319aedb Mon Sep 17 00:00:00 2001 From: Gabe Kangas Date: Sat, 19 Sep 2026 19:17:59 -0700 Subject: [PATCH 3/3] docs: clarify binary response compatibility --- docs/PLUGIN_AUTHOR_GUIDE.md | 4 ++-- docs/WIRE_PROTOCOL.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/PLUGIN_AUTHOR_GUIDE.md b/docs/PLUGIN_AUTHOR_GUIDE.md index e7afec8..7df304c 100644 --- a/docs/PLUGIN_AUTHOR_GUIDE.md +++ b/docs/PLUGIN_AUTHOR_GUIDE.md @@ -347,8 +347,8 @@ Response strings are sent as UTF-8. In JavaScript, return an object and set its you may return `bytes` or `bytearray` directly or use either as a dictionary body. Set the appropriate `Content-Type` header for the client. -Byte response bodies require Owncast v0.3.1 or later. Older hosts return an -empty body. +Byte response bodies require Owncast v0.3.1 or later. Older hosts do not +support them. `req.user` is the chat user the request came from, if Owncast could identify one. The host resolves it from the visitor's chat identity cookie, which is set diff --git a/docs/WIRE_PROTOCOL.md b/docs/WIRE_PROTOCOL.md index 9c88960..9d98915 100644 --- a/docs/WIRE_PROTOCOL.md +++ b/docs/WIRE_PROTOCOL.md @@ -834,8 +834,8 @@ internally when a response's `body` is a `Uint8Array`. The Python SDK uses it when a handler returns `bytes` or `bytearray` directly or sets its dictionary body to either type. -Byte response bodies require Owncast v0.3.1 or later. Older hosts ignore -`bodyBase64` and return an empty body. +Byte response bodies require Owncast v0.3.1 or later. Older hosts do not +support `bodyBase64`. ### Stream and server data