nimeet Docs
中文

Bypass Streaming (Legacy FLS)

RTMP push and HLS playback, entry points, idle timeout, heartbeats, callbacks and migration to Media Push.

Version v1.1.0 · Updated 2026-10-26 · revision 1

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

ArtifactFormDescription
RTMP push addressrtmp(s)://<RTMP_PUSH_BASE>/<entryPoint>/<streamName>Pushed to third-party live platforms (OBS, cloud live, and so on)
HLS playback addresshttps://<HLS_PLAY_BASE>/<entryPoint>/<streamName>.m3u8Playable by any HLS player; not produced when hls: false

Defaults come from environment variables:

VariableDefault
RTMP_PUSH_BASErtmp://rtmp.example.com/live
HLS_PLAY_BASEhttps://hls.example.com/live
RTMP_REGIONcn-shanghai

Platform API

MethodPathDescription
POST/v1/streamsCreate 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}/stopStop
POST/v1/streams/{id}/heartbeatViewer heartbeat: refreshes lastActiveAt so idle reclamation does not fire

Create request body:

FieldDefaultDescription
channelNamerequiredThe channel must exist and must not be destroyed (otherwise 404 channel_not_found)
entryPointlive1–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
rtmpUrlgenerated automaticallyCustom push address; it must start with rtmp:// or rtmps://, otherwise 400 invalid_parameter
hlstrueWhether to produce HLS
idleTimeoutSeconds300How long after the last viewer activity the task is reclaimed automatically; 0 turns it off
maxDurationSecondsnoneMaximum push duration protection (10–86400)
idempotencyKeynoneIdempotency 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

FieldDescription
id / channelName / entryPoint / streamNameIdentity and naming
rtmpUrl / hlsUrlPush and playback addresses
statestarting → running → stopping → stopped / failed / expired
idleTimeoutSecondsIdle reclamation threshold
lastActiveAtTime of the latest heartbeat or creation
startedAt / endedAt / errorLifecycle 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

MethodPathDescription
POST/v1/projects/{appid}/fls/entry_points?id={name}Create an entry point; returns the full list
GET/v1/projects/{appid}/fls/entry_pointsList 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 region segment is only echoed back in the response (the current deployment is single-region) and does not change behaviour;
  • id (streamName) and cname (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():

  1. Fetch every task in starting / running;
  2. If idleTimeoutSeconds > 0 and now - lastActiveAt ≥ idleTimeout → stop the task;
  3. After stopping, set the state to expired and 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:

json
{
  "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:

javascript
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:

bash
#!/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

  • entryPoint names are 1–64 characters and streamName 1–128; both allow only [A-Za-z0-9_-]. The ${entryPoint}/${streamName} in the address are not URL-escaped, so avoid special characters.
  • The live entry point cannot be deleted; other entry points cannot be restored once deleted.
  • HLS uses 4-second segments (segmentDurationSeconds: 4 on 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

Generated by the documentation build pipeline · 2026-10-31 01:24:02 UTC · Documentation center v1.1.0.

Content follows the public API definitions.

ni-meet is a self-hosted RTC PaaS with a LiveKit-based media plane (Apache-2.0) and an Agora-compatible API.