Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
b0489fe
feat(examples): add web-capture gateway (clip/cast.streamkit.dev)
streamer45 Jun 27, 2026
0dedcd6
fix(web-capture): harden SSRF, session lifetime, and clip concurrency
streamer45 Jun 27, 2026
5ca0f1e
refactor(web-capture): embed pipeline YAML from template files
streamer45 Jun 27, 2026
170061f
fix(web-capture): close shutdown/create race, proxy + log correctness
streamer45 Jun 27, 2026
ce0e590
feat(web-capture): H.264/fMP4 cast for Safari/iOS; user-role token
streamer45 Jun 28, 2026
c8e1f75
refactor(web-capture): single host, output as the first path segment
streamer45 Jun 28, 2026
c8638e0
regression test
streamer45 Jun 28, 2026
008d3fb
fix(servo,web-capture): stop cross-session pixel leak; land clip firs…
streamkit-devin Jun 28, 2026
b2d9233
fix(servo): re-arm first-paint gate on viewport resize
streamkit-devin Jun 28, 2026
1672946
feat(examples/web-capture): serve autoplay clip player page for brows…
streamkit-devin Jun 28, 2026
6fdae5b
fix(plugins/servo): clear cached frame on URL change
streamkit-devin Jun 28, 2026
7a8a517
fix(examples/web-capture): clamp max-concurrency to >=1, fix option e…
streamkit-devin Jun 28, 2026
7d1f949
feat(transport::http::mse): add fMP4 support for H.264 casting
streamkit-devin Jun 28, 2026
0837352
Merge origin/main into feat/web-capture-gateway
streamkit-devin Jun 28, 2026
1cc9da4
fix(web-capture): close pipe reader if clip request build fails
streamkit-devin Jun 28, 2026
af7cef1
chore(plugins/servo): bump version to 0.1.1
streamkit-devin Jun 28, 2026
6821c16
fix(web-capture): fail fast in proxyMSE when session reaped mid-retry
streamkit-devin Jun 29, 2026
d0288fc
fix(web-capture): block IPv4-compatible IPv6 and local-use NAT64 in S…
streamkit-devin Jun 29, 2026
debfd9a
perf(web-capture): defer target DNS lookup off the player-page path
streamkit-devin Jun 29, 2026
8b748fb
refactor(servo): dedupe transparent-frame paths and surface make_curr…
streamkit-devin Jun 29, 2026
ff957fe
refactor(web-capture): dedupe gateway auth/maxViewers state and strip…
streamkit-devin Jun 29, 2026
d77d0a7
chore(deps): bump anyhow + wasmtime-wasi and ignore ttf-parser unmain…
streamkit-devin Jun 30, 2026
d4348e0
fix(web-capture): reject sessions whose create finishes after shutdown
streamkit-devin Jul 4, 2026
4c782ff
fix(web-capture): derive MSE Content-Type fallback from the cast enco…
streamkit-devin Jul 4, 2026
42fdbcc
fix(web-capture): clamp max-sessions/max-viewers and drop unused auth…
streamkit-devin Jul 4, 2026
8b5ea1d
chore(servo): drop redundant uuid dev-dependency
streamkit-devin Jul 4, 2026
cf95e4a
chore(deps): ignore quick-xml RUSTSEC-2026-0194/0195 advisories via o…
streamkit-devin Jul 4, 2026
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
21 changes: 21 additions & 0 deletions apps/skit/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1646,6 +1646,27 @@ allowed_plugins = []
});
}

#[test]
fn config_partial_role_override_deep_merges_builtin() {
figment::Jail::expect_with(|jail| {
jail.create_file(
"skit.toml",
r#"[permissions.roles.user]
allowed_nodes = ["video::*", "containers::*", "transport::http::mse", "plugin::*"]
"#,
)?;
let result = load("skit.toml").expect("load with user override");
let user = result.config.permissions.roles.get("user").expect("user role");
// Fields absent from the TOML must survive from the built-in default
// (deep-merge), not reset to the struct's bool default of false.
assert!(user.create_sessions, "create_sessions preserved by deep-merge");
assert!(user.destroy_sessions, "destroy_sessions preserved by deep-merge");
// The overridden array adds the MSE transport node.
assert!(user.is_node_allowed("transport::http::mse"));
Ok(())
});
}
Comment on lines +1680 to +1699

@staging-devin-ai-integration staging-devin-ai-integration Bot Jun 28, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Deep-merge config test relies on figment dict-level merge of map values

The new test config_partial_role_override_deep_merges_builtin asserts that overriding only allowed_nodes for the built-in user role preserves create_sessions/destroy_sessions from defaults. This holds because load merges Serialized::defaults(Config::default()) then the TOML (config.rs:1187-1200), and figment merges nested dicts (including HashMap<String,Permissions> values) at the Value level before struct extraction, so absent fields keep the default's true rather than serde's bool default of false (defaults defined at permissions.rs:137-138). The test reflects real existing behavior, not a regression.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground


fn attr_policy(allowed: &[&str], fallback: &str) -> MetricsAttributePolicy {
MetricsAttributePolicy {
allowed: allowed.iter().map(|s| (*s).to_string()).collect(),
Expand Down
1 change: 1 addition & 0 deletions examples/web-capture/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/gateway
26 changes: 26 additions & 0 deletions examples/web-capture/.golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
version: "2"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: SPDX/REUSE compliance for headerless config files is satisfied via REUSE.toml

The new .gitignore and .golangci.yml lack inline SPDX headers, which would appear to violate the CONTRIBUTING/AGENTS rule that all new files carry SPDX headers. I confirmed REUSE.toml at the repo root aggregates **/*.yml, .gitignore, and **/.gitignore with the MPL-2.0 license (and this matches the sibling examples/speech-gateway/.golangci.yml, which is also headerless). So these files are compliant and I did not report them as rule violations. go.sum is present on disk (just omitted from the diff), so the Dockerfile go build is not broken.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

linters:
default: none
enable:
- bidichk
- errcheck
- govet
- ineffassign
- makezero
- misspell
- revive
- staticcheck
- unconvert
- unqueryvet
- unused
- whitespace
settings:
govet:
disable:
- fieldalignment
enable-all: true
unqueryvet:
check-sql-builders: true
issues:
max-issues-per-linter: 0 # no maximum
max-same-issues: 0 # no maximum
13 changes: 13 additions & 0 deletions examples/web-capture/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# SPDX-FileCopyrightText: © 2025 StreamKit Contributors
#
# SPDX-License-Identifier: MPL-2.0

FROM golang:1.24-bookworm AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /gateway ./cmd/gateway

FROM gcr.io/distroless/static-debian12
COPY --from=build /gateway /gateway
EXPOSE 8080
ENTRYPOINT ["/gateway"]
20 changes: 20 additions & 0 deletions examples/web-capture/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# SPDX-FileCopyrightText: © 2025 StreamKit Contributors
#
# SPDX-License-Identifier: MPL-2.0

.PHONY: build lint test run clean

build:
go build -o gateway ./cmd/gateway

lint:
golangci-lint run

test:
go test ./...

run:
go run ./cmd/gateway

clean:
rm -f gateway
117 changes: 117 additions & 0 deletions examples/web-capture/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<!--
SPDX-FileCopyrightText: © 2025 StreamKit Contributors

SPDX-License-Identifier: MPL-2.0
-->

# Web Capture Gateway

Render any web page to video through a StreamKit backend. The first path segment picks the output; paste the target URL after it — no flags, no JSON:

- **clip** — a finite MP4 file (oneshot pipeline): `web.streamkit.dev/clip/example.com`
- **cast** — a live stream, WebM or fMP4 (dynamic session): `web.streamkit.dev/cast/example.com`

The page is rendered by the [Servo](../../plugins/native/servo) browser engine on the backend. The gateway turns a pasteable URL into the multipart oneshot / dynamic-session calls the backend expects and, for `cast`, owns the session lifetime so abandoned streams are torn down instead of leaking a renderer.

## URL shape

```
{host}/{clip|cast}/[options/]{target-url}
```

The first segment is the output (`clip` or `cast`); the target URL follows **verbatim** (scheme optional, `https` assumed), so its own query string just rides along — no percent-encoding:

```
web.streamkit.dev/clip/grafana.example/d/abc?panel=3&from=now-6h
```

Options are an optional comma-separated `key=value` segment between the output and the URL:

```
web.streamkit.dev/clip/dur=30s/example.com # clip length (default 10s, capped at 60s)
web.streamkit.dev/cast/res=2560x1440/example.com # capture resolution (default 1920x1080)
web.streamkit.dev/clip/res=1280x720,dur=15s/example.com # combine, comma-separated
```

`dur` is `clip`-only; `res` (the capture resolution) applies to both modes. The scheme is identical locally, e.g. `http://127.0.0.1:8080/clip/dur=5s/example.com`.

The same URL serves a browser and a player/script: an address-bar visit (`Accept: text/html`) to a `cast` URL returns a tiny autoplay page; anything else (a `<video src>`, `curl`, `ffplay`) gets the raw stream/file.

## Formats & browser support

| Mode | Format | Plays in |
|------|--------|----------|
| `clip` | MP4 / H.264, fragmented (plays inline as it renders) | Everywhere, including Safari/iOS |
| `cast` | VP9 / WebM by default; H.264 / fMP4 via `cast-encoder=h264-sw` | VP9: Chromium & Firefox. H.264/fMP4: everywhere, including Safari/iOS |

The cast default (VP9/WebM) is crisper for screen text but plays only in Chromium/Firefox. For universal live playback (incl. Safari/iOS), set `cast-encoder=h264-sw` (or `h264-hw`): `transport::http::mse` serves fragmented MP4, which a plain `<video>` plays everywhere.

## Prereqs

- A StreamKit server with the **Servo plugin** built and loaded:
`just build-plugin-native-servo && just copy-plugins-native`.
- A non-admin **`user`** token suffices (it may create/destroy sessions and use `transport::http::mse`, `plugin::native::servo`, and the video/container nodes) — no admin needed. Pass it via `--token` / `SKIT_TOKEN`.
- Go 1.24+.

## Run

```sh
cd examples/web-capture
go run ./cmd/gateway --listen :8080 --skit-url http://127.0.0.1:4545

# clip → MP4 (plays inline in a browser; -o saves it)
curl -L 'http://127.0.0.1:8080/clip/dur=5s/example.com' -o clip.mp4

# cast → open in a browser to watch live
xdg-open 'http://127.0.0.1:8080/cast/example.com'
```

### Configuration

| Env | Flag | Default | Purpose |
|-----|------|---------|---------|
| `GATEWAY_LISTEN` | `--listen` | `:8080` | Listen address |
| `SKIT_URL` | `--skit-url` | `http://127.0.0.1:4545` | Backend URL |
| `SKIT_TOKEN` | `--token` | — | Bearer token sent to the backend |
| `GATEWAY_MAX_CONCURRENCY` | `--max-concurrency` | 4 | Max concurrent clip renders |
| `GATEWAY_CLIP_DEFAULT_SECS` | `--clip-default-secs` | 10 | Default clip duration |
| `GATEWAY_CLIP_MAX_SECS` | `--clip-max-secs` | 60 | Maximum clip duration |
| `GATEWAY_MAX_SESSIONS` | `--max-sessions` | 8 | Max concurrent live cast sessions |
| `GATEWAY_MAX_VIEWERS` | `--max-viewers` | 10 | Max viewers per cast stream |
| `GATEWAY_SESSION_IDLE_SECS` | `--session-idle-secs` | 30 | Idle grace before a viewerless session is reaped |
| `GATEWAY_SESSION_MAX_SECS` | `--session-max-secs` | 1800 | Hard cap on a session's lifetime |
| `GATEWAY_RESOLUTION` | `--resolution` | `1920x1080` | Capture resolution WxH (render = encode, 1:1) |
| `GATEWAY_CLIP_BITRATE_KBPS` | `--clip-bitrate-kbps` | `10000` | Clip video bitrate (kbps) |
| `GATEWAY_CAST_BITRATE_KBPS` | `--cast-bitrate-kbps` | `6000` | Cast video bitrate (kbps) |
| `GATEWAY_CLIP_ENCODER` | `--clip-encoder` | `h264-sw` | `h264-sw`, or `h264-hw` (Vulkan Video) |
| `GATEWAY_CAST_ENCODER` | `--cast-encoder` | `vp9-sw` | `vp9-sw`, `av1-sw`, `av1-hw`, `h264-sw`, `h264-hw` |

Capture runs at **1080p30** by default: the page renders **and** encodes at the same resolution (1:1, no downscale), so text stays crisp and the page gets a real desktop layout. Drop to `res=1280x720` for less bandwidth or raise to `res=2560x1440` for more detail (capped at 4K); frame rate is fixed at 30.

### Encoders

Software encoders are the default, so the gateway runs anywhere with no GPU — ideal for local dev. Hardware is opt-in per mode:

| Profile | Codec / container | Modes | Notes |
|---|---|---|---|
| `h264-sw` | H.264 — MP4 (clip) / fMP4 (cast) | clip, cast | universal incl. Safari/iOS; OpenH264 (software) |
| `h264-hw` | H.264 — MP4 / fMP4 | clip, cast | Vulkan Video (NVIDIA/AMD/Intel; NVENC has no H.264) |
| `vp9-sw` | VP9 — WebM | cast | cast default; crisp screen text; Chromium/Firefox |
| `av1-sw` | AV1 — WebM | cast | SVT-AV1 (software) — exercise AV1 without a GPU |
| `av1-hw` | AV1 — WebM | cast | NVENC AV1 (NVIDIA); efficient + crisp; Chromium/Firefox |

Clips are always H.264/MP4 (universal download). Cast defaults to VP9/WebM (crisp, Chromium/Firefox); switch to `h264-sw`/`h264-hw` for H.264/fMP4 that also plays in Safari/iOS, or `av1-*` for AV1/WebM. Hardware profiles require the matching runtime in the **backend** (Vulkan for `h264-hw`, CUDA + NVENC for `av1-hw`) — the gateway only selects the node, so a missing runtime surfaces as a backend pipeline error, not a gateway one.

## Lifetime & teardown (cast)

The engine does **not** stop a pipeline when its MSE viewers disconnect, and has no idle reaper — so lifetime is the gateway's job. It keeps **one session per normalized URL** (viewers of the same page share one renderer), refcounts viewers across the streaming proxy, and a background reaper tears down sessions that have been **idle** past the grace window or have exceeded their **max lifetime** (via `DELETE /api/v1/sessions/{id}`, which fully drops the pipeline). On `SIGTERM` it drains and destroys every owned session, so a redeploy never leaks renderers.

## Metrics

Prometheus metrics at `GET /metrics` (not gated by the concurrency limit). Beyond the per-endpoint request/latency/rejection series (`endpoint` is `clip` or `cast`), `cast` adds `webcapture_active_sessions`, `webcapture_active_viewers`, `webcapture_session_lifetime_seconds`, and `webcapture_sessions_reaped_total{reason}`. A public instance may keep `/metrics` inside its trust boundary rather than exposing it.

## Limitations

- **Public / URL-only.** Targets that resolve to loopback/private/link-local/CGNAT/cloud-metadata addresses are rejected (SSRF guard). Auth-gated pages (cookies/headers) are intentionally out of scope — a future self-hosted feature, never the public path. DNS rebinding between the gateway's check and Servo's own fetch is a known gap.
- **Servo is view-only and software-rendered**: static/CSS pages render at full frame rate; heavy WebGL is ~15–20 fps. No clicking, scrolling, or login.
- **`cast` in Safari/iOS**: the default VP9/WebM is Chromium/Firefox-only; use `cast-encoder=h264-sw` for fMP4/H.264 that plays in Safari/iOS.
Loading
Loading