Bypass Streaming (Legacy FLS)
RTMP push and HLS playback, entry points, idle timeout, heartbeats, callbacks and migration to Media Push.
Bypass streaming (bypass / media push) pushes channel content out as a standard RTMP stream and also produces an HLS playlist, so viewers without an RTC SDK can watch in an ordinary player. Like Cloud Recording, it is a task-shaped resource: create → queue → run → stop/reclaim, with state and callbacks throughout.
Artifacts and addresses
| Artifact | Form | Description |
|---|---|---|
| RTMP push address | rtmp(s)://<RTMP_PUSH_BASE>/<entryPoint>/<streamName> | Pushed to third-party live platforms (OBS, cloud live, and so on) |
| HLS playback address | https://<HLS_PLAY_BASE>/<entryPoint>/<streamName>.m3u8 | Playable by any HLS player; not produced when hls: false |
Defaults come from environment variables:
| Variable | Default |
|---|---|
RTMP_PUSH_BASE | rtmp://rtmp.example.com/live |
HLS_PLAY_BASE | https://hls.example.com/live |
RTMP_REGION | cn-shanghai |
Platform API
| Method | Path | Description |
|---|---|---|
POST | /v1/streams | Create a push task, 202 |
GET | /v1/streams?channelName=&state=&limit= | List tasks (the response key is streams) |
GET | /v1/streams/{id} | Query a single task |
POST | /v1/streams/{id}/stop | Stop |
POST | /v1/streams/{id}/heartbeat | Viewer heartbeat: refreshes lastActiveAt so idle reclamation does not fire |
Create request body:
| Field | Default | Description |
|---|---|---|
channelName | required | The channel must exist and must not be destroyed (otherwise 404 channel_not_found) |
entryPoint | live | 1–64 characters, [A-Za-z0-9_-]; the default entry point live always exists |
streamName | <channelName>_<random> | 1–128 characters, [A-Za-z0-9_-]; it becomes the last segment of the address |
rtmpUrl | generated automatically | Custom push address; it must start with rtmp:// or rtmps://, otherwise 400 invalid_parameter |
hls | true | Whether to produce HLS |
idleTimeoutSeconds | 300 | How long after the last viewer activity the task is reclaimed automatically; 0 turns it off |
maxDurationSeconds | none | Maximum push duration protection (10–86400) |
idempotencyKey | none | Idempotency key |
Prerequisites: the project must enable streaming (otherwise 403 service_not_enabled); if the concurrency quota quotas.streamingConcurrent (default 5) is exhausted, the API returns 429 streaming_quota_exceeded.
Task fields
| Field | Description |
|---|---|
id / channelName / entryPoint / streamName | Identity and naming |
rtmpUrl / hlsUrl | Push and playback addresses |
state | starting → running → stopping → stopped / failed / expired |
idleTimeoutSeconds | Idle reclamation threshold |
lastActiveAt | Time of the latest heartbeat or creation |
startedAt / endedAt / error | Lifecycle timestamps and error |
expired is a terminal state specific to push: it is triggered by the background tick's idle sweep, and error spells out idle timeout after <N>s without viewer activity.
Agora FLS-compatible entry points
| Method | Path | Description |
|---|---|---|
POST | /v1/projects/{appid}/fls/entry_points?id={name} | Create an entry point; returns the full list |
GET | /v1/projects/{appid}/fls/entry_points | List them (live always exists) |
DELETE | /v1/projects/{appid}/fls/entry_points/{entryPoint} | Delete one; deleting live is rejected (400) |
PUT | /v1/projects/{appid}/fls/entry_points/{entryPoint}/admin/record/standard/regions/{region}/tasks?id={streamName}&cname={channel} | Start a push task, 202 |
DELETE | /v1/projects/{appid}/fls/entry_points/{entryPoint}/admin/record/standard/regions/{region}/tasks/{streamName} | Stop the task for that streamName |
Notes:
- the
regionsegment is only echoed back in the response (the current deployment is single-region) and does not change behaviour; id(streamName) andcname(channel name) may be supplied in the query string or in the JSON body;- Agora's "standard recording" and our "push task" are the same thing: pushing channel content out as a standard stream;
- authentication: Agora FLS uses a signature derived from the customer key/secret; here everything uses the project's Basic credentials.
Idle reclamation and heartbeat
The background engine ticks every EGRESS_POLL_INTERVAL_MS (default 1000ms), and the tick includes sweepIdle():
- Fetch every task in
starting/running; - If
idleTimeoutSeconds > 0andnow - lastActiveAt ≥ idleTimeout→ stop the task; - After stopping, set the state to
expiredand record the reason.
"Activity" is refreshed by three things: creation, an explicit heartbeat, and state synchronisation. Viewers should call the heartbeat periodically (for example every 30–60 seconds), otherwise a long silent live stream will be reclaimed. The heartbeat response returns the task's current snapshot (including the updated lastActiveAt).
State and callbacks
Every state change emits event code 202 streaming_state_changed:
{
"eventType": 202,
"productId": 1,
"payload": {
"taskId": "stm_1a2b…",
"channelName": "weekly-sync",
"entryPoint": "live",
"streamName": "weekly-sync_9f2c",
"rtmpUrl": "rtmp://rtmp.example.com/live/weekly-sync_9f2c",
"hlsUrl": "https://hls.example.com/live/weekly-sync_9f2c.m3u8",
"state": "running",
"ts": 1793000000
}
}States and callbacks both follow the same consistency path: "persist first → Outbox → delivery", and delivery failures are handled by the retry schedule of Callback events.
End-to-end example
Create → tick → query → heartbeat → stop, plus creating and deleting entry points and the protection rules:
import { seedChannel, tick, uniqueChannel } from './_lib.mjs';
export const meta = {
id: '14-streaming-lifecycle',
title: '旁路推流:RTMP/HLS 任务与 entry point',
safe: false, // 会创建/停止推流任务
needsApi: true,
};
/**
* 平台接口:POST /v1/streams → tick → GET → heartbeat → stop
* 声网 FLS 兼容:GET/POST/DELETE /v1/projects/{appid}/fls/entry_points
*
* 推流任务默认 idleTimeoutSeconds=300;心跳刷新 lastActiveAt,超时后由后台 sweep 置为 expired。
* 默认 rtpush 域名来自 RTMP_PUSH_BASE / HLS_PLAY_BASE(见 the control plane)。
*/
export async function run(ctx) {
const api = ctx.api;
const appId = ctx.credentials.appId;
const channelName = uniqueChannel('docs-push');
await seedChannel(api, appId, channelName, 93001);
const created = await api('POST', '/v1/streams', {
body: { channelName, entryPoint: 'live', hls: true, idleTimeoutSeconds: 120 },
});
ctx.assert(created.status === 202, `创建推流任务应返回 202,实际 ${created.status}`);
const task = created.json;
ctx.assert(/^rtmp:\/\/.+\/live\/.+/.test(task.rtmpUrl), `rtmpUrl 形状不符: ${task.rtmpUrl}`);
ctx.assert(/^https:\/\/.+\/live\/.+\.m3u8$/.test(task.hlsUrl), `hlsUrl 形状不符: ${task.hlsUrl}`);
ctx.assert(task.rtmpUrl.endsWith(task.streamName), 'rtmpUrl 应以 streamName 结尾');
ctx.assert(task.idleTimeoutSeconds === 120, 'idleTimeoutSeconds 应被回显');
await tick(api);
const afterTick = await api('GET', `/v1/streams/${task.id}`);
ctx.assert(['starting', 'running'].includes(afterTick.json.state), `tick 后状态应为 starting/running,实际 ${afterTick.json.state}`);
const heartbeat = await api('POST', `/v1/streams/${task.id}/heartbeat`);
ctx.assert(heartbeat.json.lastActiveAt, '心跳应刷新 lastActiveAt');
const list = await api('GET', '/v1/streams', { query: { channelName } });
ctx.assert((list.json.streams ?? []).some((entry) => entry.id === task.id), '任务应出现在列表中');
const stopped = await api('POST', `/v1/streams/${task.id}/stop`);
ctx.assert(stopped.json.state === 'stopped', `stop 后应为 stopped,实际 ${stopped.json.state}`);
// 非法 rtmpUrl 必须在创建前被拒绝
const invalid = await ctx.client('POST', '/v1/streams', { body: { channelName, rtmpUrl: 'http://not-rtmp.example.com/x' } });
ctx.assert(invalid.status === 400 && invalid.json.code === 'invalid_parameter', '非 rtmp(s):// 地址应 400');
// ------------------------------------------------------------ entry points
const before = await api('GET', `/v1/projects/${appId}/fls/entry_points`);
ctx.assert((before.json.entryPoints ?? []).includes('live'), '默认 entry point "live" 必须存在');
const entryPoint = `docs${Date.now().toString(36).slice(-4)}`;
const createdPoint = await api('POST', `/v1/projects/${appId}/fls/entry_points`, { query: { id: entryPoint } });
ctx.assert((createdPoint.json.entryPoints ?? []).includes(entryPoint), '新建 entry point 应出现在列表中');
const deletedPoint = await api('DELETE', `/v1/projects/${appId}/fls/entry_points/${entryPoint}`);
ctx.assert(deletedPoint.json.deleted === true, 'entry point 应被删除');
const deleteDefault = await ctx.client('DELETE', `/v1/projects/${appId}/fls/entry_points/live`);
ctx.assert(deleteDefault.status === 400, '默认 entry point "live" 不允许删除');
return {
summary:
`${channelName}: streamName=${task.streamName}(${task.idleTimeoutSeconds}s idle);tick 后 ${afterTick.json.state} → ${stopped.json.state};` +
`entry point 新建/删除成功,live 受保护`,
details: {
rtmpUrl: task.rtmpUrl,
hlsUrl: task.hlsUrl,
heartbeatAt: heartbeat.json.lastActiveAt,
entryPointsBefore: before.json.entryPoints,
},
};
}curl version:
#!/usr/bin/env bash
# 旁路推流:创建 RTMP/HLS 任务、心跳续命、停止;以及 entry point 管理
set -euo pipefail
: "${NIMEET_API_BASE:=http://127.0.0.1:8787}"
: "${CNAME:=weekly-sync}"
CRED_FILE=$([ -f the control plane class="tok-flag">-credentials.json ] && echo the control plane class="tok-flag">-credentials.json || echo data/demo-credentials.json)
CRED=$(node -e "const c=JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'));process.stdout.write(Buffer.from(c.customerId+':'+c.customerSecret).toString('base64'))" "$CRED_FILE")
APP_ID=$(node -e "process.stdout.write(JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).appId)" "$CRED_FILE")
# 1) 创建推流任务(hls=true 同时产出播放用 m3u8)
CREATE=$(curl -s -X POST "$NIMEET_API_BASE/v1/streams" \
-H "Authorization: Basic $CRED" -H 'Content-Type: application/json' \
-d "{\"channelName\":\"$CNAME\",\"entryPoint\":\"live\",\"hls\":true,\"idleTimeoutSeconds\":300}")
echo "$CREATE"
TASK_ID=$(node -e "process.stdout.write(JSON.parse(process.argv[1]).id)" "$CREATE")
# 2) 观众心跳:刷新 lastActiveAt,避免 idleTimeout 回收
curl -s -X POST "$NIMEET_API_BASE/v1/streams/$TASK_ID/heartbeat" \
-H "Authorization: Basic $CRED"
# 3) 查询与停止
curl -s "$NIMEET_API_BASE/v1/streams/$TASK_ID" -H "Authorization: Basic $CRED"
curl -s -X POST "$NIMEET_API_BASE/v1/streams/$TASK_ID/stop" -H "Authorization: Basic $CRED"
# 4) entry point(默认 live 恒存在且不可删除)
curl -s "$NIMEET_API_BASE/v1/projects/$APP_ID/fls/entry_points" -H "Authorization: Basic $CRED"
curl -s -X POST "$NIMEET_API_BASE/v1/projects/$APP_ID/fls/entry_points?id=docs-demo" -H "Authorization: Basic $CRED"
curl -s -X DELETE "$NIMEET_API_BASE/v1/projects/$APP_ID/fls/entry_points/docs-demo" -H "Authorization: Basic $CRED"Limitations and caveats
entryPointnames are 1–64 characters andstreamName1–128; both allow only[A-Za-z0-9_-]. The${entryPoint}/${streamName}in the address are not URL-escaped, so avoid special characters.- The
liveentry point cannot be deleted; other entry points cannot be restored once deleted. - HLS uses 4-second segments (
segmentDurationSeconds: 4on the Egress side), so players see several seconds of latency; for low-latency interaction use an RTC subscription rather than HLS. - Push is one-way: viewers have no RTC capabilities (they cannot speak or report quality), and quality data comes only from RTC participants.
- The current implementation looks up FLS tasks by streamName within the project when stopping them, so keep streamNames unique (the generated
<channelName>_<random>default already guarantees that).
Next steps
- REST API reference: complete endpoints, parameters and curl.
- Error codes: locate failures by
code/ HTTP status. - FAQ: quotas, limits and troubleshooting.