Archived
M6(2/n): 治理台九功能 + SSE 订阅层 + 读/治分权
治理台按 §9.1 拆成九个 gov-* feature,共用件落 shared/gov:
· 外壳(左轨/顶栏/面包屑/页签/整页下钻)自原型 Governance.tsx 平移,
DOM 与 g-* 类名原样;路由状态即应用状态,区/页签/下钻栈全编码进 hash 可深链
· 编排画布 diagram-js 画布件 1:1 平移(flow-layout/flow-node-paint/FlowDiagram),
新增 pkg-to-graph 把 ProcessPackage 转成画布图模型;统一报价 v1 全图 12 节点 15 边渲染通过
· 后端补四路由:审批单 / 工作流下钻 / 通道实例 / 度量仪表
SSE 订阅层(§9.2):app 层单一连接,按信封目标失效缓存;
组件不再自行轮询(去掉 refetchInterval)。广播只说「哪个目标动了」,不带业务载荷,
且在投递已落库已送达之后才发 —— 不是第二条投递链路。
两处实质修正:
· 治理路由一律要 govern → 读路由改要 read。客体三类 × 动作三种的分法就是为此:
BD 有 task:read 能看在途、动不了控制事件;一律 govern 等于把三种动作压成一种
· 控制事件/恢复/改派原记为 engine-transition(归机器产出)→ 新增 control 条目种类,
归人裁决侧。归侧是按动作类型的纯推导,所以「谁干的」必须体现在 kind 上;
否则人的叫停会被算进自主率的机器分子
判据 ⑩ 治理台一半跑通:在途监控 / 叫停 / 恢复,治理动作留痕可查。
无权查看不再画成空清单,按岗位说明缺哪一项。
gates: tsc / eslint / depcruise(286 模块)/ api-report / 360 测试 全绿
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* 治理台九功能的运行面路由(编码规范 §3)。
|
||||
*
|
||||
* 「治理台九功能**不设**后端 governance 模块:每个功能是对应模块的管理用例 + 路由组装。」
|
||||
* 「治理动作本身留痕入事件流 —— 治理也被治理。」
|
||||
*/
|
||||
import type { Hono } from 'hono';
|
||||
import type { ApiError, GovernanceFunction, GrantAction } from '@contracts';
|
||||
import {
|
||||
ATTRIBUTION_SIDE_LABEL,
|
||||
browseLedgerRoute,
|
||||
controlTaskRoute,
|
||||
govMetricsRoute,
|
||||
govTaskLogRoute,
|
||||
govWorkflowRoute,
|
||||
listActivitiesRoute,
|
||||
listApprovalsRoute,
|
||||
listChannelsRoute,
|
||||
listActivityMemoryRoute,
|
||||
listGovTasksRoute,
|
||||
listLedgerObjectsRoute,
|
||||
listProcessPackagesRoute,
|
||||
listToolsRoute,
|
||||
listTriggersRoute,
|
||||
publishProcessRoute,
|
||||
reassignTaskRoute,
|
||||
resumeTaskRoute,
|
||||
staffIdSchema,
|
||||
taskIdSchema,
|
||||
toggleTriggerRoute,
|
||||
validateProcessRoute,
|
||||
} from '@contracts';
|
||||
import { NotPublishableError } from '@modules/process';
|
||||
import type { Container } from '../container.ts';
|
||||
import type { AppEnv } from '../http-env.ts';
|
||||
|
||||
export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Container): void {
|
||||
/**
|
||||
* 岗位授权:客体 = 治理功能,动作按路由分级 —— **看是 `read`,动才是 `govern`**。
|
||||
*
|
||||
* 客体三类 × 动作三种的分法就是为此:BD 有 `task:read` 能看在途,却动不了控制事件;
|
||||
* PM 有 `task:govern`,读写皆通(动作蕴含 govern ⊃ write ⊃ read)。
|
||||
* 一律要 govern 会把只读监控也锁死,等于把三种动作压成一种。
|
||||
*/
|
||||
function guard(fn: GovernanceFunction, action: GrantAction) {
|
||||
return async (account: string): Promise<ApiError | undefined> => {
|
||||
const staff = await container.org.findByAccount(account);
|
||||
if (staff === undefined) return { code: 'unauthenticated', message: '会话无效' };
|
||||
const ok = await container.org.authorize(staff.id, { kind: 'governance', function: fn }, action);
|
||||
if (ok) return undefined;
|
||||
const need = action === 'govern' ? '治理权' : '查看权';
|
||||
return { code: 'forbidden', message: `岗位 ${staff.position.code} 无「${fn}」${need}` };
|
||||
};
|
||||
}
|
||||
|
||||
const readTask = guard('task', 'read');
|
||||
const governTask = guard('task', 'govern');
|
||||
const readProcess = guard('process', 'read');
|
||||
const governProcess = guard('process', 'govern');
|
||||
const readTrigger = guard('trigger', 'read');
|
||||
const governTrigger = guard('trigger', 'govern');
|
||||
const readActivity = guard('activity', 'read');
|
||||
const readTool = guard('tool', 'read');
|
||||
const readData = guard('data', 'read');
|
||||
const readMemory = guard('memory', 'read');
|
||||
|
||||
/** 拒绝的 HTTP 语义:无权 403,无会话 401 */
|
||||
const denyStatus = (body: ApiError): 401 | 403 => (body.code === 'forbidden' ? 403 : 401);
|
||||
|
||||
/* ── 任务管理:在途监控 · 控制 · 改派 · 任务日志 ──────────────────── */
|
||||
|
||||
app.get(listGovTasksRoute.path, async (c) => {
|
||||
const denied = await readTask(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
|
||||
const { staff } = await container.org.listStaff();
|
||||
const nameOf = new Map(staff.map((s) => [s.id, s.name]));
|
||||
const rows = await Promise.all(
|
||||
staff.map(async (s) => (await container.task.listByOwner(s.id)).map((t) => ({ t, name: s.name }))),
|
||||
);
|
||||
const workflows = await container.process.listWorkflows(50);
|
||||
|
||||
return c.json({
|
||||
tasks: rows.flat().map(({ t, name }) => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
activityId: t.activityId,
|
||||
paradigm: t.paradigm,
|
||||
ownerName: nameOf.get(t.ownerId) ?? name,
|
||||
runtime: t.runtime,
|
||||
...(t.terminal === undefined ? {} : { terminal: t.terminal }),
|
||||
...(t.workflowId === undefined ? {} : { workflowId: t.workflowId }),
|
||||
usedSteps: t.used.steps,
|
||||
capSteps: t.caps.steps,
|
||||
startedAt: t.startedAt,
|
||||
})),
|
||||
workflows: workflows.map((w) => ({
|
||||
id: w.id,
|
||||
subject: w.subject,
|
||||
state: w.state,
|
||||
packageId: w.packageId,
|
||||
startedAt: w.startedAt,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
/** 控制三语义 —— 叫停留痕为最重档纠正信号 */
|
||||
app.post(controlTaskRoute.path, async (c) => {
|
||||
const denied = await governTask(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const staff = await container.org.findByAccount(c.get('account'));
|
||||
const parsed = controlTaskRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success || staff === undefined) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
const outcome = await container.task.control(
|
||||
taskIdSchema.parse(c.req.param('taskId')),
|
||||
parsed.data.semantic,
|
||||
staff.id,
|
||||
parsed.data.reason,
|
||||
);
|
||||
return c.json({ ok: true, resumable: outcome.resumable });
|
||||
});
|
||||
|
||||
app.post(resumeTaskRoute.path, async (c) => {
|
||||
const denied = await governTask(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const staff = await container.org.findByAccount(c.get('account'));
|
||||
if (staff === undefined) return c.json({ code: 'unauthenticated', message: '会话无效' } satisfies ApiError, 401);
|
||||
const task = await container.task.resume(taskIdSchema.parse(c.req.param('taskId')), staff.id);
|
||||
return c.json({ ok: true, runtime: task.runtime });
|
||||
});
|
||||
|
||||
app.post(reassignTaskRoute.path, async (c) => {
|
||||
const denied = await governTask(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const staff = await container.org.findByAccount(c.get('account'));
|
||||
const parsed = reassignTaskRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success || staff === undefined) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
const task = await container.task.reassign(
|
||||
taskIdSchema.parse(c.req.param('taskId')),
|
||||
staffIdSchema.parse(parsed.data.to),
|
||||
staff.id,
|
||||
);
|
||||
return c.json({ ok: true, ownerId: task.ownerId });
|
||||
});
|
||||
|
||||
app.get(govTaskLogRoute.path, async (c) => {
|
||||
const denied = await readTask(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const lines = await container.memory.taskLog(taskIdSchema.parse(c.req.param('taskId')));
|
||||
return c.json({
|
||||
log: lines.map((l) => ({ id: l.id, at: l.at, sideLabel: ATTRIBUTION_SIDE_LABEL[l.side], title: l.title })),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 审批单 —— 审批点的产物,**不是任务**:没有预算、没有任务日志,只有批不批。
|
||||
* 它在实现上是卡到审批人工位的验收工单,`taskId` 位坐的是 workflowId(见 wiring/engines.ts)。
|
||||
*/
|
||||
app.get(listApprovalsRoute.path, async (c) => {
|
||||
const denied = await readTask(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
|
||||
const workflows = await container.process.listWorkflows(50);
|
||||
const perWorkflow = await Promise.all(
|
||||
workflows.map(async (w) => {
|
||||
const pkg = await container.process.getPackage(w.packageId, w.packageVersion);
|
||||
if (pkg === undefined) return [];
|
||||
const instances = await container.process.instancesOf(w.id);
|
||||
const rows = await Promise.all(
|
||||
instances
|
||||
.filter((i) => i.activated && pkg.nodes.find((n) => n.id === i.nodeId)?.kind === 'approval')
|
||||
.map(async (i) => {
|
||||
const node = pkg.nodes.find((n) => n.id === i.nodeId);
|
||||
const position = node?.kind === 'approval' ? node.approver.position : '';
|
||||
const holders = position === '' ? [] : await container.org.staffOfPosition(position);
|
||||
return {
|
||||
id: `${w.id}:${i.nodeId}#${i.instance}`,
|
||||
workflowId: w.id,
|
||||
workflowSubject: w.subject,
|
||||
title: `审批:${node?.label ?? i.nodeId}`,
|
||||
approverName: holders[0]?.name ?? '—',
|
||||
approverPosition: position,
|
||||
// 终局唯放行:released = 通过;其余终态 = 退回;未终态 = 待审批
|
||||
state:
|
||||
i.terminal === undefined
|
||||
? ('pending' as const)
|
||||
: i.terminal === 'released'
|
||||
? ('passed' as const)
|
||||
: ('rejected' as const),
|
||||
issuedAt: w.startedAt,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return rows;
|
||||
}),
|
||||
);
|
||||
return c.json({ approvals: perWorkflow.flat() });
|
||||
});
|
||||
|
||||
/** 工作流下钻:流程包 × 节点实例 × 在途任务 —— 编排画布的数据面 */
|
||||
app.get(govWorkflowRoute.path, async (c) => {
|
||||
const denied = await readTask(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
|
||||
const workflow = await container.process.getWorkflow(c.req.param('id'));
|
||||
const pkg = await container.process.getPackage(workflow.packageId, workflow.packageVersion);
|
||||
if (pkg === undefined) {
|
||||
const body: ApiError = { code: 'not-found', message: `流程包缺失:${workflow.packageId}` };
|
||||
return c.json(body, 404);
|
||||
}
|
||||
const instances = await container.process.instancesOf(workflow.id);
|
||||
const { staff } = await container.org.listStaff();
|
||||
const nested = await Promise.all(
|
||||
staff.map(async (s) => (await container.task.listByOwner(s.id)).map((t) => ({ t, owner: s.name }))),
|
||||
);
|
||||
|
||||
return c.json({
|
||||
workflow: {
|
||||
id: workflow.id,
|
||||
subject: workflow.subject,
|
||||
state: workflow.state,
|
||||
packageId: workflow.packageId,
|
||||
packageVersion: workflow.packageVersion,
|
||||
source: workflow.source,
|
||||
startedAt: workflow.startedAt,
|
||||
...(workflow.finishedAt === undefined ? {} : { finishedAt: workflow.finishedAt }),
|
||||
},
|
||||
pkg,
|
||||
instances: instances.map((i) => ({
|
||||
nodeId: i.nodeId,
|
||||
instance: i.instance,
|
||||
running: i.running,
|
||||
waiting: i.waiting,
|
||||
activated: i.activated,
|
||||
...(i.terminal === undefined ? {} : { terminal: i.terminal }),
|
||||
})),
|
||||
tasks: nested
|
||||
.flat()
|
||||
.filter(({ t }) => t.workflowId === workflow.id)
|
||||
.map(({ t, owner }) => ({ id: t.id, nodeId: t.nodeId ?? '', title: t.title, ownerName: owner })),
|
||||
});
|
||||
});
|
||||
|
||||
/* ── 工作流管理 ──────────────────────────────────────────────────── */
|
||||
|
||||
app.get(listProcessPackagesRoute.path, async (c) => {
|
||||
const denied = await readProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
return c.json({ packages: await container.process.listPackages() });
|
||||
});
|
||||
|
||||
/** 画图即校验 —— 与前端画布同源 */
|
||||
app.post(validateProcessRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const parsed = validateProcessRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '流程包不合 schema', detail: parsed.error.issues };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
return c.json(await container.process.validate(parsed.data.pkg));
|
||||
});
|
||||
|
||||
/** 不闭合不得发布 */
|
||||
app.post(publishProcessRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
try {
|
||||
return c.json(await container.process.publish(c.req.param('id'), Number(c.req.param('version'))));
|
||||
} catch (error) {
|
||||
if (error instanceof NotPublishableError) {
|
||||
const body: ApiError = { code: 'precondition-failed', message: error.message, detail: error.report };
|
||||
return c.json(body, 412);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
/* ── 触发器配置 ──────────────────────────────────────────────────── */
|
||||
|
||||
app.get(listTriggersRoute.path, async (c) => {
|
||||
const denied = await readTrigger(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
return c.json({ triggers: await container.trigger.list() });
|
||||
});
|
||||
|
||||
app.post(toggleTriggerRoute.path, async (c) => {
|
||||
const denied = await governTrigger(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const parsed = toggleTriggerRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
// 触发器的增删启停不改流程包的任何版本
|
||||
await container.trigger.setEnabled(c.req.param('id'), parsed.data.enabled);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/* ── 活动库管理:两维分类 × 两条版本序列 ─────────────────────────── */
|
||||
|
||||
app.get(listActivitiesRoute.path, async (c) => {
|
||||
const denied = await readActivity(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const cards = await container.registry.list();
|
||||
return c.json({
|
||||
activities: cards.map((card) => ({
|
||||
activityId: card.activityId,
|
||||
name: card.name,
|
||||
category: card.category,
|
||||
paradigm: card.paradigm,
|
||||
enabled: card.enabled,
|
||||
packageVersions: card.packageVersions.map((p) => ({
|
||||
version: p.version,
|
||||
state: p.state,
|
||||
installedAt: p.installedAt,
|
||||
})),
|
||||
assetVersions: card.assetVersions.map((a) => ({
|
||||
kind: a.kind,
|
||||
version: a.version,
|
||||
effective: a.effective,
|
||||
})),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
/* ── 工具管理 ────────────────────────────────────────────────────── */
|
||||
|
||||
app.get(listToolsRoute.path, async (c) => {
|
||||
const denied = await readTool(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const tools = await container.toolbox.list();
|
||||
return c.json({
|
||||
tools: tools.map((t) => ({
|
||||
name: t.name,
|
||||
version: t.version,
|
||||
description: t.description,
|
||||
effect: t.effect,
|
||||
pure: t.pure === true,
|
||||
// 凭证隔离:只回键,永不回值(ADR-009)
|
||||
connectionKeys: [...t.connectionKeys],
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
/* ── 数据管理:域 / 对象目录 + 记录浏览 ─────────────────────────── */
|
||||
|
||||
app.get(listLedgerObjectsRoute.path, async (c) => {
|
||||
const denied = await readData(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
return c.json({ objects: await container.ledger.listObjects() });
|
||||
});
|
||||
|
||||
app.get(browseLedgerRoute.path, async (c) => {
|
||||
const denied = await readData(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const address = { domain: c.req.param('domain'), object: c.req.param('object') };
|
||||
const described = await container.ledger.describe(address);
|
||||
if (described === undefined) {
|
||||
const body: ApiError = { code: 'not-found', message: `台账对象未物化:${address.domain}.${address.object}` };
|
||||
return c.json(body, 404);
|
||||
}
|
||||
// 记录浏览只读正式版 —— 跨任务只见过门的事实
|
||||
const page = await container.ledger.query({ address, where: {}, orderBy: [], limit: 50, offset: 0 });
|
||||
return c.json({
|
||||
columns: described.columns.map((col) => ({ name: col.name, type: col.type })),
|
||||
primaryKey: [...described.primaryKey],
|
||||
rows: page.rows,
|
||||
total: page.total,
|
||||
});
|
||||
});
|
||||
|
||||
/* ── 记忆管理:活动记忆四层 ─────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* 度量仪表 —— 自主率与纠正率**成对读**,复合为有效自主率。
|
||||
* 只读近期任务的任务记忆聚合;度量本身不载权力。
|
||||
*/
|
||||
app.get(govMetricsRoute.path, async (c) => {
|
||||
const denied = await readMemory(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const { staff } = await container.org.listStaff();
|
||||
const nested = await Promise.all(staff.map((s) => container.task.listByOwner(s.id)));
|
||||
const taskIds = nested.flat().map((t) => t.id);
|
||||
const metrics = await container.memory.metrics({
|
||||
taskIds,
|
||||
submissions: 0,
|
||||
corrections: 0,
|
||||
taskCount: taskIds.length,
|
||||
deterministicJudgements: 0,
|
||||
totalJudgements: 0,
|
||||
});
|
||||
return c.json({ ...metrics, taskCount: taskIds.length });
|
||||
});
|
||||
|
||||
/* ── 系统管理 · 通道实例 ────────────────────────────────────────── */
|
||||
|
||||
app.get(listChannelsRoute.path, async (c) => {
|
||||
const denied = await readTrigger(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const channels = await container.channel.list();
|
||||
return c.json({
|
||||
channels: channels.map((ch) => ({
|
||||
id: ch.id,
|
||||
name: ch.name,
|
||||
kind: ch.kind,
|
||||
address: ch.address,
|
||||
enabled: ch.enabled,
|
||||
extractCount: ch.extract.length,
|
||||
mappingCount: ch.mapping.length,
|
||||
...(ch.fallbackPosition === undefined ? {} : { fallbackPosition: ch.fallbackPosition }),
|
||||
addressWindowMs: ch.addressWindowMs,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
app.get(listActivityMemoryRoute.path, async (c) => {
|
||||
const denied = await readMemory(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const entries = await container.memory.loadActivityMemory(c.req.param('activityId'), { limit: 100 });
|
||||
return c.json({
|
||||
entries: entries.map((e) => ({
|
||||
id: e.id,
|
||||
layer: e.layer,
|
||||
statement: e.statement,
|
||||
evidenceCount: e.evidence.length,
|
||||
...(e.increment === undefined ? {} : { incrementState: e.increment.state }),
|
||||
createdAt: e.createdAt,
|
||||
})),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* SSE:前端唯一一条订阅连接(编码规范 §9.2)。
|
||||
*
|
||||
* 「SSE 事件到达 → 按信封目标失效对应 TanStack Query 缓存,由订阅层统一做;
|
||||
* 组件禁止自行轮询或手写缓存。」
|
||||
* 推送里**不带业务载荷**——只说「哪个目标动了」,画面仍由服务端投影重建。
|
||||
*/
|
||||
import type { Hono } from 'hono';
|
||||
import { streamSSE } from 'hono/streaming';
|
||||
import type { Broadcaster } from '../broadcast.ts';
|
||||
import type { AppEnv } from '../http-env.ts';
|
||||
|
||||
export function registerStreamRoute(app: Hono<AppEnv>, broadcaster: Broadcaster): void {
|
||||
app.get('/api/stream', (c) =>
|
||||
streamSSE(c, async (stream) => {
|
||||
const queue: string[] = [];
|
||||
let wake: (() => void) | undefined;
|
||||
|
||||
const unsubscribe = broadcaster.subscribe((touch) => {
|
||||
queue.push(JSON.stringify(touch));
|
||||
wake?.();
|
||||
});
|
||||
stream.onAbort(unsubscribe);
|
||||
|
||||
await stream.writeSSE({ event: 'ready', data: '1' });
|
||||
|
||||
// 心跳兼作断线探测:客户端 EventSource 自带重连,重连后前端整体失效一次
|
||||
const heartbeat = setInterval(() => {
|
||||
queue.push('');
|
||||
wake?.();
|
||||
}, 20_000);
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
if (queue.length === 0) {
|
||||
await new Promise<void>((resolve) => {
|
||||
wake = resolve;
|
||||
});
|
||||
wake = undefined;
|
||||
continue;
|
||||
}
|
||||
const next = queue.shift()!;
|
||||
if (next === '') await stream.writeSSE({ event: 'ping', data: '1' });
|
||||
else await stream.writeSSE({ event: 'touch', data: next });
|
||||
}
|
||||
} finally {
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user