Station 이벤트 지켜보기
1회용 입장권으로 SSE 에 붙어 기기 상태·명령·접속 변화를 실시간으로 찍어요. 끊기면 마지막 seq 부터 이어 받아요.
무엇을 하나
브라우저의 EventSource·WebSocket 은 Authorization 헤더를 실을 수 없어서 1회용 입장권(ostk_)으로 붙어요. 서버에서는 사용자 인증으로 바로 붙어도 돼요.
SSE 의 id: 가 seq 예요. 끊긴 뒤 last_event_id(또는 Last-Event-ID 헤더)로 이어 받아요. 같은 이벤트가 웹훅으로도 가고, 두 길의 seq 가 같아요.
다른 터미널에서 클라우드 리눅스 샘플을 KEEP=1 로 돌리고, 그 Station 으로 이 샘플을 붙이면 가상 기기의 compute_status 가 starting → running 이 되는 순간(device.status_changed)과 명령의 흐름이 보여요. 로컬에서는 같은 사용자(ENDUE_OS_SUB)여야 그 Station 이 보여요.
부르는 API
프롬프트
Claude Code 같은 AI 코딩 에이전트에 그대로 붙여 넣으세요. 아래 코드가 이 프롬프트로 만든 결과예요.
endue OS Station 의 이벤트를 실시간으로 보여 주는 Node.js CLI 를 만들어 줘. 의존성 없이 Node 22 의 fetch 와 스트림만,
파일 하나(station-events.mjs). 사용법: node station-events.mjs <station_id> [types 쉼표 목록]
API 규칙:
- 기본 주소 ENDUE_OS_URL (없으면 http://127.0.0.1:5173), 경로 /api/v1/...
- Authorization: Bearer <토큰>. 토큰은 ENDUE_OS_TOKEN, 없으면 POST {ENDUE_OS_URL}/idp/token?sub=${ENDUE_OS_SUB ?? "alice"}&ttl=3600.
할 일:
1. POST /stations/{id}/event-tickets → data.ticket (1회용, 60초).
2. GET /stations/{id}/events?ticket=…[&types=…][&last_event_id=…] 를 SSE 로 읽는다.
블록은 빈 줄로 나뉘고, data: 줄이 JSON 이벤트 { seq, type, occurred_at, resource_id, data } 다 (resource_id 가 곧 device_id).
3. 이벤트마다 "#seq 시각 type 요약" 한 줄을 찍는다. command.* 이면 data.command·status·duration_ms 를,
device.status_changed 면 resource_id 와 data.compute_status·status_reason·online 을 요약한다.
4. event: resync 를 받으면 last_event_id 를 지우고 "상태를 다시 읽어야 한다" 고 알린다.
5. 연결이 끊기면 2초 뒤 새 입장권으로, 마지막 seq 를 last_event_id 로 넘겨 이어 받는다.결과 코드 station-events.mjs
#!/usr/bin/env node
// Station 이벤트 지켜보기 — endue OS 샘플.
// 입장권을 받아 SSE 로 붙고, 이벤트를 한 줄씩 찍는다. 끊기면 마지막 seq 부터 이어 받는다.
//
// node station-events.mjs stn_… (토큰은 ENDUE_OS_TOKEN, 없으면 로컬 가짜 IdP 의 sub=ENDUE_OS_SUB)
// node station-events.mjs stn_… device.status_changed,command.completed
const [stationId, types] = process.argv.slice(2);
if (!stationId) throw new Error('사용법: node station-events.mjs <station_id> [types]');
const BASE = (process.env.ENDUE_OS_URL ?? 'http://127.0.0.1:5173').replace(/\/+$/, '');
const API = `${BASE}/api/v1`;
// 로컬에서는 Station 을 만든 사용자와 같은 sub 로 토큰을 받아야 그 Station 이 보인다 (남의 것은 404).
const token =
process.env.ENDUE_OS_TOKEN ??
(await (await fetch(`${BASE}/idp/token?sub=${process.env.ENDUE_OS_SUB ?? 'alice'}&ttl=3600`, { method: 'POST' })).json()).access_token;
let lastSeq = null;
async function connect() {
// 1회용 입장권 — 60초 안에 한 번. 브라우저의 EventSource 도 같은 방법으로 붙는다.
const t = await fetch(`${API}/stations/${stationId}/event-tickets`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } });
if (!t.ok) throw new Error(`event-tickets → ${t.status}`);
const { ticket } = (await t.json()).data;
const qs = new URLSearchParams({ ticket });
if (types) qs.set('types', types);
if (lastSeq != null) qs.set('last_event_id', String(lastSeq));
const res = await fetch(`${API}/stations/${stationId}/events?${qs}`);
if (res.status !== 200) throw new Error(`events → ${res.status}`);
console.log(`붙었다${lastSeq != null ? ` — seq ${lastSeq} 다음부터` : ''}`);
const decoder = new TextDecoder();
let buf = '';
for await (const chunk of res.body) {
buf += decoder.decode(chunk, { stream: true });
let i;
while ((i = buf.indexOf('\n\n')) >= 0) {
const block = buf.slice(0, i);
buf = buf.slice(i + 2);
const field = (name) => block.split('\n').find((l) => l.startsWith(`${name}:`))?.slice(name.length + 1).trim();
if (field('event') === 'resync') {
// 링버퍼(1000개·15분) 밖 — REST 로 상태를 다시 읽고 처음부터 붙는다.
console.log('resync — 상태를 다시 읽어야 한다');
lastSeq = null;
continue;
}
const data = field('data');
if (!data) continue;
const ev = JSON.parse(data);
lastSeq = ev.seq;
// resource.* 의 resource_id 가 곧 device_id 다.
const brief = ev.type.startsWith('command.')
? `${ev.data.command} ${ev.data.status}${ev.data.duration_ms != null ? ` ${ev.data.duration_ms}ms` : ''}`
: ev.type === 'device.status_changed'
? `${ev.resource_id ?? ''} ${ev.data.compute_status}${ev.data.status_reason ? ` (${ev.data.status_reason})` : ''} online=${ev.data.online}`
: JSON.stringify(ev.data);
console.log(`#${ev.seq} ${ev.occurred_at.slice(11, 19)} ${ev.type.padEnd(22)} ${brief}`);
}
}
}
for (;;) {
try {
await connect();
} catch (e) {
console.warn(String(e.message ?? e));
}
await new Promise((r) => setTimeout(r, 2000)); // 끊기면 잠시 뒤 이어 받는다
}돌려 보기
# endue-os 제품 레포에서 로컬 스택을 띄운 뒤 local/up.sh node site/samples/station-events.mjs # 운영 (예정) — endue 계정의 토큰으로 ENDUE_OS_URL=https://api.endueos.com ENDUE_OS_TOKEN=… node station-events.mjs