@graphorin/server 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/README.md +9 -9
- package/dist/app.d.ts +5 -5
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +11 -10
- package/dist/app.js.map +1 -1
- package/dist/commentary/audit-bridge.d.ts +2 -2
- package/dist/commentary/audit-bridge.js +3 -3
- package/dist/commentary/audit-bridge.js.map +1 -1
- package/dist/commentary/built-in-patterns.js +1 -1
- package/dist/commentary/built-in-patterns.js.map +1 -1
- package/dist/commentary/sanitizer.js +2 -2
- package/dist/commentary/sanitizer.js.map +1 -1
- package/dist/commentary/types.d.ts +5 -5
- package/dist/consolidator/daemon.d.ts +1 -1
- package/dist/consolidator/daemon.js.map +1 -1
- package/dist/health/checks.js.map +1 -1
- package/dist/health/routes.d.ts +2 -2
- package/dist/health/routes.js.map +1 -1
- package/dist/index.d.ts +7 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -8
- package/dist/index.js.map +1 -1
- package/dist/internal/ids.js +1 -1
- package/dist/internal/ids.js.map +1 -1
- package/dist/lifecycle/pre-bind.js.map +1 -1
- package/dist/metrics/catalog.js +1 -1
- package/dist/metrics/catalog.js.map +1 -1
- package/dist/metrics/registry.d.ts +2 -2
- package/dist/metrics/registry.js +1 -1
- package/dist/metrics/registry.js.map +1 -1
- package/dist/middleware/auth.d.ts +1 -1
- package/dist/middleware/auth.js +1 -1
- package/dist/middleware/auth.js.map +1 -1
- package/dist/middleware/cors.js.map +1 -1
- package/dist/middleware/csrf.js.map +1 -1
- package/dist/middleware/idempotency.d.ts +1 -1
- package/dist/middleware/idempotency.js +36 -23
- package/dist/middleware/idempotency.js.map +1 -1
- package/dist/middleware/rate-limit.js.map +1 -1
- package/dist/middleware/request-state.js.map +1 -1
- package/dist/middleware/scope.js.map +1 -1
- package/dist/package.js +6 -0
- package/dist/package.js.map +1 -0
- package/dist/registry/index.d.ts +2 -2
- package/dist/registry/index.js +1 -1
- package/dist/registry/index.js.map +1 -1
- package/dist/replay/routes.js +1 -1
- package/dist/replay/routes.js.map +1 -1
- package/dist/routes/agents.js +1 -1
- package/dist/routes/agents.js.map +1 -1
- package/dist/routes/audit.d.ts +1 -1
- package/dist/routes/audit.js.map +1 -1
- package/dist/routes/auth.js.map +1 -1
- package/dist/routes/health.js +1 -1
- package/dist/routes/health.js.map +1 -1
- package/dist/routes/sessions.js.map +1 -1
- package/dist/routes/tokens.js.map +1 -1
- package/dist/routes/workflows.js +42 -8
- package/dist/routes/workflows.js.map +1 -1
- package/dist/runtime/run-state.d.ts +3 -3
- package/dist/runtime/run-state.js +3 -3
- package/dist/runtime/run-state.js.map +1 -1
- package/dist/sse/events.js +1 -1
- package/dist/sse/events.js.map +1 -1
- package/dist/triggers/daemon.js.map +1 -1
- package/dist/triggers/routes.js.map +1 -1
- package/dist/ws/dispatcher.js.map +1 -1
- package/dist/ws/subjects.d.ts +1 -1
- package/dist/ws/subjects.d.ts.map +1 -1
- package/dist/ws/subjects.js +3 -3
- package/dist/ws/subjects.js.map +1 -1
- package/dist/ws/ticket.d.ts +1 -1
- package/dist/ws/ticket.js +1 -1
- package/dist/ws/ticket.js.map +1 -1
- package/dist/ws/upgrade.d.ts.map +1 -1
- package/dist/ws/upgrade.js +7 -5
- package/dist/ws/upgrade.js.map +1 -1
- package/package.json +16 -16
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"run-state.js","names":["#records","#now","#onTerminal","record: RunRecord","#emitTerminal","base: { -readonly [K in keyof RunStateSnapshot]?: RunStateSnapshot[K] }"],"sources":["../../src/runtime/run-state.ts"],"sourcesContent":["/**\n * In-memory bookkeeping for in-flight agent / workflow runs. Exposes\n * a tiny CRUD surface route handlers consume to honour the\n * `GET /runs/:runId/state` and `POST /runs/:runId/abort` endpoints\n * declared in the runtime architecture.\n *\n * The full durable resume / replay path lives in Phase 14b/c on top\n * of the WebSocket layer + the consolidator daemon. Phase 14a only\n * needs enough state to:\n * - mint a runId on every `POST /run` / `POST /stream`,\n * - track its lifecycle (`pending` → `running` → `completed` / `failed` / `aborted`),\n * - propagate `AbortController.signal` so handlers can cancel.\n *\n * @packageDocumentation\n */\n\n/**\n * Stable status discriminator for a run snapshot. Mirrors the values\n * exposed on the public REST surface.\n *\n * @stable\n */\nexport type RunStatus = 'pending' | 'running' | 'completed' | 'failed' | 'aborted';\n\n/**\n * Identifying tag for the underlying execution kind. Workflows run\n * on the durable engine in `@graphorin/workflow`; agents run on the\n * `@graphorin/agent` runtime.\n *\n * @stable\n */\nexport type RunKind = 'agent' | 'workflow';\n\n/**\n * Terminal status a run can settle into (never `pending` / `running`).\n *\n * @stable\n */\nexport type TerminalRunStatus = Extract<RunStatus, 'completed' | 'failed' | 'aborted'>;\n\n/**\n * IP-15: payload handed to the {@link RunStateTracker} `onTerminal` callback\n * the first time a run reaches a terminal state. The server turns this into\n * the `graphorin_agent_runs_total` counter + `graphorin_agent_run_duration_seconds`\n * summary. `durationMs` is omitted for a run that was aborted before it ever\n * started (no meaningful wall-clock).\n *\n * @stable\n */\nexport interface TerminalRunInfo {\n readonly status: TerminalRunStatus;\n readonly kind: RunKind;\n readonly durationMs?: number;\n}\n\nfunction isTerminalStatus(status: RunStatus): status is TerminalRunStatus {\n return status === 'completed' || status === 'failed' || status === 'aborted';\n}\n\n/**\n * Snapshot returned by {@link RunStateTracker.snapshot}.\n *\n * @stable\n */\nexport interface RunStateSnapshot {\n readonly runId: string;\n readonly kind: RunKind;\n readonly status: RunStatus;\n readonly startedAt?: number;\n readonly completedAt?: number;\n readonly error?: { readonly message: string };\n readonly agentId?: string;\n readonly workflowId?: string;\n readonly threadId?: string;\n readonly sessionId?: string;\n readonly userId?: string;\n}\n\n/**\n * Bookkeeping descriptor recorded at run start. Either an agent run\n * (with `agentId`) or a workflow run (with `workflowId` + optional\n * `threadId`).\n *\n * @stable\n */\nexport type RunDescriptor =\n | {\n readonly kind: 'agent';\n readonly agentId: string;\n readonly sessionId?: string;\n readonly userId?: string;\n }\n | {\n readonly kind: 'workflow';\n readonly workflowId: string;\n readonly threadId?: string;\n readonly sessionId?: string;\n readonly userId?: string;\n };\n\n/**\n * In-flight handle returned by {@link RunStateTracker.start}. Handlers\n * pass `signal` into the underlying `agent.run / workflow.execute`\n * invocation so cancellation propagates instantly.\n *\n * @stable\n */\nexport interface RunHandle {\n readonly runId: string;\n readonly signal: AbortSignal;\n cancel(reason?: unknown): void;\n}\n\ninterface RunRecord {\n readonly runId: string;\n readonly kind: RunKind;\n readonly descriptor: RunDescriptor;\n status: RunStatus;\n controller: AbortController;\n startedAt?: number;\n completedAt?: number;\n error?: { readonly message: string };\n}\n\n/**\n * Pluggable tracker. The default in-memory implementation is the only\n * one shipped in Phase 14a; future phases plug in a SQLite-backed\n * variant so durable resume survives process restarts.\n *\n * @stable\n */\nexport class RunStateTracker {\n readonly #records: Map<string, RunRecord> = new Map();\n readonly #now: () => number;\n readonly #onTerminal: ((info: TerminalRunInfo) => void) | undefined;\n\n constructor(\n options: {\n readonly now?: () => number;\n /**\n * IP-15: invoked exactly once per run, the first time it settles into a\n * terminal state. Used to drive the run-count + duration metrics. Never\n * throws into the tracker — wrap your handler if it might.\n */\n readonly onTerminal?: (info: TerminalRunInfo) => void;\n } = {},\n ) {\n this.#now = options.now ?? Date.now;\n this.#onTerminal = options.onTerminal;\n }\n\n #emitTerminal(record: RunRecord): void {\n if (this.#onTerminal === undefined) return;\n if (!isTerminalStatus(record.status)) return;\n const durationMs =\n record.startedAt !== undefined && record.completedAt !== undefined\n ? record.completedAt - record.startedAt\n : undefined;\n this.#onTerminal({\n status: record.status,\n kind: record.kind,\n ...(durationMs !== undefined ? { durationMs } : {}),\n });\n }\n\n /** Reserve a run id without taking ownership of an AbortSignal. */\n declare(runId: string, descriptor: RunDescriptor): void {\n if (this.#records.has(runId)) return;\n const controller = new AbortController();\n this.#records.set(runId, {\n runId,\n kind: descriptor.kind,\n descriptor,\n status: 'pending',\n controller,\n });\n }\n\n /** Promote a previously-declared run to `running` (or declare it). */\n start(runId: string, descriptor: RunDescriptor): RunHandle {\n const existing = this.#records.get(runId);\n const record: RunRecord = existing ?? {\n runId,\n kind: descriptor.kind,\n descriptor,\n status: 'pending',\n controller: new AbortController(),\n };\n record.status = 'running';\n record.startedAt = this.#now();\n this.#records.set(runId, record);\n return Object.freeze({\n runId,\n signal: record.controller.signal,\n cancel: (reason?: unknown) => {\n if (!record.controller.signal.aborted) {\n record.controller.abort(reason);\n }\n },\n });\n }\n\n /** Mark a run as terminal. */\n complete(\n runId: string,\n status: Extract<RunStatus, 'completed' | 'failed' | 'aborted'>,\n err?: unknown,\n ): void {\n const record = this.#records.get(runId);\n if (record === undefined) return;\n // IP-15: only the FIRST terminal transition counts toward the metrics —\n // a run aborted then re-completed must not double-increment.\n const wasTerminal = isTerminalStatus(record.status);\n record.status = status;\n record.completedAt = this.#now();\n if (err !== undefined) {\n record.error = { message: err instanceof Error ? err.message : String(err) };\n }\n if (!wasTerminal) this.#emitTerminal(record);\n }\n\n /** Cancel a run via its `AbortController`. */\n abort(runId: string, reason?: unknown): boolean {\n const record = this.#records.get(runId);\n if (record === undefined) return false;\n if (!record.controller.signal.aborted) {\n record.controller.abort(reason);\n }\n if (record.status === 'pending' || record.status === 'running') {\n record.status = 'aborted';\n record.completedAt = this.#now();\n this.#emitTerminal(record);\n }\n return true;\n }\n\n /** Read-only snapshot, safe to JSON.stringify. */\n snapshot(runId: string): RunStateSnapshot | undefined {\n const record = this.#records.get(runId);\n if (record === undefined) return undefined;\n const base: { -readonly [K in keyof RunStateSnapshot]?: RunStateSnapshot[K] } = {\n runId: record.runId,\n kind: record.kind,\n status: record.status,\n };\n if (record.startedAt !== undefined) base.startedAt = record.startedAt;\n if (record.completedAt !== undefined) base.completedAt = record.completedAt;\n if (record.error !== undefined) base.error = record.error;\n if (record.descriptor.kind === 'agent') {\n base.agentId = record.descriptor.agentId;\n } else {\n base.workflowId = record.descriptor.workflowId;\n if (record.descriptor.threadId !== undefined) base.threadId = record.descriptor.threadId;\n }\n if (record.descriptor.sessionId !== undefined) base.sessionId = record.descriptor.sessionId;\n if (record.descriptor.userId !== undefined) base.userId = record.descriptor.userId;\n return Object.freeze(base as RunStateSnapshot);\n }\n\n /**\n * Number of runs currently in `pending` or `running`. Useful for\n * snapshots / metrics. Note that `pending` runs hold a reservation\n * but have not yet started any work — see {@link runningCount} for\n * the drain-blocking subset.\n */\n inflightCount(): number {\n let n = 0;\n for (const record of this.#records.values()) {\n if (record.status === 'pending' || record.status === 'running') n += 1;\n }\n return n;\n }\n\n /**\n * Number of runs with active work in progress (`running`). The\n * lifecycle drain blocks on this counter only — pending runs are a\n * pure reservation (e.g. an awaited WS subscription) and can be\n * aborted immediately when SIGTERM arrives.\n */\n runningCount(): number {\n let n = 0;\n for (const record of this.#records.values()) {\n if (record.status === 'running') n += 1;\n }\n return n;\n }\n\n /**\n * Drop every reserved-but-not-yet-started run. Called by the\n * server lifecycle at the start of `stop()` so the drain only\n * waits for actual work in flight.\n */\n abortPending(reason?: unknown): number {\n let aborted = 0;\n for (const record of this.#records.values()) {\n if (record.status === 'pending') {\n if (!record.controller.signal.aborted) record.controller.abort(reason);\n record.status = 'aborted';\n record.completedAt = this.#now();\n this.#emitTerminal(record);\n aborted += 1;\n }\n }\n return aborted;\n }\n\n /** Drop terminal records older than `olderThan`. */\n prune(olderThan: number): number {\n let removed = 0;\n for (const [runId, record] of this.#records) {\n if (\n record.completedAt !== undefined &&\n record.completedAt <= olderThan &&\n record.status !== 'pending' &&\n record.status !== 'running'\n ) {\n this.#records.delete(runId);\n removed += 1;\n }\n }\n return removed;\n }\n\n /** Cancel every in-flight run. Used during graceful shutdown. */\n abortAll(reason?: unknown): number {\n let aborted = 0;\n for (const [runId] of this.#records) {\n if (this.abort(runId, reason)) aborted += 1;\n }\n return aborted;\n }\n}\n\n/** Default cadence for the terminal-record prune sweep (IP-16). */\nexport const DEFAULT_RUN_PRUNE_INTERVAL_MS = 60_000;\n/** Default retention for terminal run records before they are dropped. */\nexport const DEFAULT_RUN_RETENTION_MS = 5 * 60_000;\n\n/**\n * IP-16: schedule a periodic prune of terminal run records. Without this the\n * tracker's `prune()` was never called, so every run / stream / workflow left\n * a `RunRecord` (each holding an `AbortController`) in memory forever — an\n * unbounded leak on a long-living server. Returns a stop function that clears\n * the timer; the timer is `unref`-ed so it never keeps the process alive.\n */\nexport function scheduleRunPruning(\n runs: RunStateTracker,\n now: () => number,\n opts: { readonly intervalMs?: number; readonly retentionMs?: number } = {},\n): () => void {\n const intervalMs = opts.intervalMs ?? DEFAULT_RUN_PRUNE_INTERVAL_MS;\n const retentionMs = opts.retentionMs ?? DEFAULT_RUN_RETENTION_MS;\n const timer = setInterval(() => {\n runs.prune(now() - retentionMs);\n }, intervalMs);\n if (typeof (timer as { unref?: () => void }).unref === 'function') {\n (timer as { unref: () => void }).unref();\n }\n return () => clearInterval(timer);\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"run-state.js","names":["#records","#now","#onTerminal","record: RunRecord","#emitTerminal","base: { -readonly [K in keyof RunStateSnapshot]?: RunStateSnapshot[K] }"],"sources":["../../src/runtime/run-state.ts"],"sourcesContent":["/**\n * In-memory bookkeeping for in-flight agent / workflow runs. Exposes\n * a tiny CRUD surface route handlers consume to honour the\n * `GET /runs/:runId/state` and `POST /runs/:runId/abort` endpoints\n * declared in the runtime architecture.\n *\n * The full durable resume / replay path lives in Phase 14b/c on top\n * of the WebSocket layer + the consolidator daemon. Phase 14a only\n * needs enough state to:\n * - mint a runId on every `POST /run` / `POST /stream`,\n * - track its lifecycle (`pending` → `running` → `completed` / `failed` / `aborted`),\n * - propagate `AbortController.signal` so handlers can cancel.\n *\n * @packageDocumentation\n */\n\n/**\n * Stable status discriminator for a run snapshot. Mirrors the values\n * exposed on the public REST surface.\n *\n * @stable\n */\nexport type RunStatus = 'pending' | 'running' | 'completed' | 'failed' | 'aborted';\n\n/**\n * Identifying tag for the underlying execution kind. Workflows run\n * on the durable engine in `@graphorin/workflow`; agents run on the\n * `@graphorin/agent` runtime.\n *\n * @stable\n */\nexport type RunKind = 'agent' | 'workflow';\n\n/**\n * Terminal status a run can settle into (never `pending` / `running`).\n *\n * @stable\n */\nexport type TerminalRunStatus = Extract<RunStatus, 'completed' | 'failed' | 'aborted'>;\n\n/**\n * IP-15: payload handed to the {@link RunStateTracker} `onTerminal` callback\n * the first time a run reaches a terminal state. The server turns this into\n * the `graphorin_agent_runs_total` counter + `graphorin_agent_run_duration_seconds`\n * summary. `durationMs` is omitted for a run that was aborted before it ever\n * started (no meaningful wall-clock).\n *\n * @stable\n */\nexport interface TerminalRunInfo {\n readonly status: TerminalRunStatus;\n readonly kind: RunKind;\n readonly durationMs?: number;\n}\n\nfunction isTerminalStatus(status: RunStatus): status is TerminalRunStatus {\n return status === 'completed' || status === 'failed' || status === 'aborted';\n}\n\n/**\n * Snapshot returned by {@link RunStateTracker.snapshot}.\n *\n * @stable\n */\nexport interface RunStateSnapshot {\n readonly runId: string;\n readonly kind: RunKind;\n readonly status: RunStatus;\n readonly startedAt?: number;\n readonly completedAt?: number;\n readonly error?: { readonly message: string };\n readonly agentId?: string;\n readonly workflowId?: string;\n readonly threadId?: string;\n readonly sessionId?: string;\n readonly userId?: string;\n}\n\n/**\n * Bookkeeping descriptor recorded at run start. Either an agent run\n * (with `agentId`) or a workflow run (with `workflowId` + optional\n * `threadId`).\n *\n * @stable\n */\nexport type RunDescriptor =\n | {\n readonly kind: 'agent';\n readonly agentId: string;\n readonly sessionId?: string;\n readonly userId?: string;\n }\n | {\n readonly kind: 'workflow';\n readonly workflowId: string;\n readonly threadId?: string;\n readonly sessionId?: string;\n readonly userId?: string;\n };\n\n/**\n * In-flight handle returned by {@link RunStateTracker.start}. Handlers\n * pass `signal` into the underlying `agent.run / workflow.execute`\n * invocation so cancellation propagates instantly.\n *\n * @stable\n */\nexport interface RunHandle {\n readonly runId: string;\n readonly signal: AbortSignal;\n cancel(reason?: unknown): void;\n}\n\ninterface RunRecord {\n readonly runId: string;\n readonly kind: RunKind;\n readonly descriptor: RunDescriptor;\n status: RunStatus;\n controller: AbortController;\n startedAt?: number;\n completedAt?: number;\n error?: { readonly message: string };\n}\n\n/**\n * Pluggable tracker. The default in-memory implementation is the only\n * one shipped in Phase 14a; future phases plug in a SQLite-backed\n * variant so durable resume survives process restarts.\n *\n * @stable\n */\nexport class RunStateTracker {\n readonly #records: Map<string, RunRecord> = new Map();\n readonly #now: () => number;\n readonly #onTerminal: ((info: TerminalRunInfo) => void) | undefined;\n\n constructor(\n options: {\n readonly now?: () => number;\n /**\n * IP-15: invoked exactly once per run, the first time it settles into a\n * terminal state. Used to drive the run-count + duration metrics. Never\n * throws into the tracker - wrap your handler if it might.\n */\n readonly onTerminal?: (info: TerminalRunInfo) => void;\n } = {},\n ) {\n this.#now = options.now ?? Date.now;\n this.#onTerminal = options.onTerminal;\n }\n\n #emitTerminal(record: RunRecord): void {\n if (this.#onTerminal === undefined) return;\n if (!isTerminalStatus(record.status)) return;\n const durationMs =\n record.startedAt !== undefined && record.completedAt !== undefined\n ? record.completedAt - record.startedAt\n : undefined;\n this.#onTerminal({\n status: record.status,\n kind: record.kind,\n ...(durationMs !== undefined ? { durationMs } : {}),\n });\n }\n\n /** Reserve a run id without taking ownership of an AbortSignal. */\n declare(runId: string, descriptor: RunDescriptor): void {\n if (this.#records.has(runId)) return;\n const controller = new AbortController();\n this.#records.set(runId, {\n runId,\n kind: descriptor.kind,\n descriptor,\n status: 'pending',\n controller,\n });\n }\n\n /** Promote a previously-declared run to `running` (or declare it). */\n start(runId: string, descriptor: RunDescriptor): RunHandle {\n const existing = this.#records.get(runId);\n const record: RunRecord = existing ?? {\n runId,\n kind: descriptor.kind,\n descriptor,\n status: 'pending',\n controller: new AbortController(),\n };\n record.status = 'running';\n record.startedAt = this.#now();\n this.#records.set(runId, record);\n return Object.freeze({\n runId,\n signal: record.controller.signal,\n cancel: (reason?: unknown) => {\n if (!record.controller.signal.aborted) {\n record.controller.abort(reason);\n }\n },\n });\n }\n\n /** Mark a run as terminal. */\n complete(\n runId: string,\n status: Extract<RunStatus, 'completed' | 'failed' | 'aborted'>,\n err?: unknown,\n ): void {\n const record = this.#records.get(runId);\n if (record === undefined) return;\n // IP-15: only the FIRST terminal transition counts toward the metrics -\n // a run aborted then re-completed must not double-increment.\n const wasTerminal = isTerminalStatus(record.status);\n record.status = status;\n record.completedAt = this.#now();\n if (err !== undefined) {\n record.error = { message: err instanceof Error ? err.message : String(err) };\n }\n if (!wasTerminal) this.#emitTerminal(record);\n }\n\n /** Cancel a run via its `AbortController`. */\n abort(runId: string, reason?: unknown): boolean {\n const record = this.#records.get(runId);\n if (record === undefined) return false;\n if (!record.controller.signal.aborted) {\n record.controller.abort(reason);\n }\n if (record.status === 'pending' || record.status === 'running') {\n record.status = 'aborted';\n record.completedAt = this.#now();\n this.#emitTerminal(record);\n }\n return true;\n }\n\n /** Read-only snapshot, safe to JSON.stringify. */\n snapshot(runId: string): RunStateSnapshot | undefined {\n const record = this.#records.get(runId);\n if (record === undefined) return undefined;\n const base: { -readonly [K in keyof RunStateSnapshot]?: RunStateSnapshot[K] } = {\n runId: record.runId,\n kind: record.kind,\n status: record.status,\n };\n if (record.startedAt !== undefined) base.startedAt = record.startedAt;\n if (record.completedAt !== undefined) base.completedAt = record.completedAt;\n if (record.error !== undefined) base.error = record.error;\n if (record.descriptor.kind === 'agent') {\n base.agentId = record.descriptor.agentId;\n } else {\n base.workflowId = record.descriptor.workflowId;\n if (record.descriptor.threadId !== undefined) base.threadId = record.descriptor.threadId;\n }\n if (record.descriptor.sessionId !== undefined) base.sessionId = record.descriptor.sessionId;\n if (record.descriptor.userId !== undefined) base.userId = record.descriptor.userId;\n return Object.freeze(base as RunStateSnapshot);\n }\n\n /**\n * Number of runs currently in `pending` or `running`. Useful for\n * snapshots / metrics. Note that `pending` runs hold a reservation\n * but have not yet started any work - see {@link runningCount} for\n * the drain-blocking subset.\n */\n inflightCount(): number {\n let n = 0;\n for (const record of this.#records.values()) {\n if (record.status === 'pending' || record.status === 'running') n += 1;\n }\n return n;\n }\n\n /**\n * Number of runs with active work in progress (`running`). The\n * lifecycle drain blocks on this counter only - pending runs are a\n * pure reservation (e.g. an awaited WS subscription) and can be\n * aborted immediately when SIGTERM arrives.\n */\n runningCount(): number {\n let n = 0;\n for (const record of this.#records.values()) {\n if (record.status === 'running') n += 1;\n }\n return n;\n }\n\n /**\n * Drop every reserved-but-not-yet-started run. Called by the\n * server lifecycle at the start of `stop()` so the drain only\n * waits for actual work in flight.\n */\n abortPending(reason?: unknown): number {\n let aborted = 0;\n for (const record of this.#records.values()) {\n if (record.status === 'pending') {\n if (!record.controller.signal.aborted) record.controller.abort(reason);\n record.status = 'aborted';\n record.completedAt = this.#now();\n this.#emitTerminal(record);\n aborted += 1;\n }\n }\n return aborted;\n }\n\n /** Drop terminal records older than `olderThan`. */\n prune(olderThan: number): number {\n let removed = 0;\n for (const [runId, record] of this.#records) {\n if (\n record.completedAt !== undefined &&\n record.completedAt <= olderThan &&\n record.status !== 'pending' &&\n record.status !== 'running'\n ) {\n this.#records.delete(runId);\n removed += 1;\n }\n }\n return removed;\n }\n\n /** Cancel every in-flight run. Used during graceful shutdown. */\n abortAll(reason?: unknown): number {\n let aborted = 0;\n for (const [runId] of this.#records) {\n if (this.abort(runId, reason)) aborted += 1;\n }\n return aborted;\n }\n}\n\n/** Default cadence for the terminal-record prune sweep (IP-16). */\nexport const DEFAULT_RUN_PRUNE_INTERVAL_MS = 60_000;\n/** Default retention for terminal run records before they are dropped. */\nexport const DEFAULT_RUN_RETENTION_MS = 5 * 60_000;\n\n/**\n * IP-16: schedule a periodic prune of terminal run records. Without this the\n * tracker's `prune()` was never called, so every run / stream / workflow left\n * a `RunRecord` (each holding an `AbortController`) in memory forever - an\n * unbounded leak on a long-living server. Returns a stop function that clears\n * the timer; the timer is `unref`-ed so it never keeps the process alive.\n */\nexport function scheduleRunPruning(\n runs: RunStateTracker,\n now: () => number,\n opts: { readonly intervalMs?: number; readonly retentionMs?: number } = {},\n): () => void {\n const intervalMs = opts.intervalMs ?? DEFAULT_RUN_PRUNE_INTERVAL_MS;\n const retentionMs = opts.retentionMs ?? DEFAULT_RUN_RETENTION_MS;\n const timer = setInterval(() => {\n runs.prune(now() - retentionMs);\n }, intervalMs);\n if (typeof (timer as { unref?: () => void }).unref === 'function') {\n (timer as { unref: () => void }).unref();\n }\n return () => clearInterval(timer);\n}\n"],"mappings":"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"}
|
package/dist/sse/events.js
CHANGED
|
@@ -13,7 +13,7 @@ import { stream } from "hono/streaming";
|
|
|
13
13
|
* `ServerMessage` event frames; control-plane operations
|
|
14
14
|
* (subscribe / unsubscribe / abort / resume) go through REST.
|
|
15
15
|
*
|
|
16
|
-
* Unlike the WS surface, the SSE responder is not multiplexed
|
|
16
|
+
* Unlike the WS surface, the SSE responder is not multiplexed -
|
|
17
17
|
* one HTTP request per session subject. Reconnection is handled by
|
|
18
18
|
* the standard EventSource `Last-Event-ID` mechanism: when the
|
|
19
19
|
* client supplies the header on resume, the responder walks the
|
package/dist/sse/events.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"events.js","names":["sanitizer: DeliveryCommentarySanitizer","lastEventId: string | undefined","queue: ServerMessage[]","resumeNotify: (() => void) | undefined"],"sources":["../../src/sse/events.ts"],"sourcesContent":["/**\n * Server-Sent Events fallback for non-WebSocket-friendly clients.\n *\n * The endpoint is unidirectional: the server pushes\n * `ServerMessage` event frames; control-plane operations\n * (subscribe / unsubscribe / abort / resume) go through REST.\n *\n * Unlike the WS surface, the SSE responder is not multiplexed —\n * one HTTP request per session subject. Reconnection is handled by\n * the standard EventSource `Last-Event-ID` mechanism: when the\n * client supplies the header on resume, the responder walks the\n * dispatcher's replay buffer from that cursor.\n *\n * @packageDocumentation\n */\n\nimport {\n isEventFrame,\n isLifecycleFrame,\n type ServerEventFrame,\n type ServerMessage,\n ServerMessageSchema,\n} from '@graphorin/protocol';\nimport type { Context, MiddlewareHandler } from 'hono';\nimport { Hono } from 'hono';\nimport { stream } from 'hono/streaming';\nimport type { StreamingApi } from 'hono/utils/stream';\n\nimport {\n createDeliveryCommentarySanitizer,\n type DeliveryCommentaryConfig,\n type DeliveryCommentarySanitizer,\n} from '../commentary/index.js';\nimport type { ServerVariables } from '../internal/context.js';\nimport { createScopeMiddleware } from '../middleware/scope.js';\nimport type { WsDispatcher } from '../ws/dispatcher.js';\n\n/**\n * Stable shape consumed by {@link createSseRoutes}.\n *\n * @stable\n */\nexport interface SseRoutesDeps {\n /**\n * Cap on the per-connection SSE delivery queue (IP-9). A consumer\n * that stops reading past this many buffered frames is closed\n * instead of growing the queue without bound. Default 1000.\n */\n readonly perConnectionQueueLimit?: number;\n readonly dispatcher: WsDispatcher;\n readonly commentary?: DeliveryCommentaryConfig;\n /**\n * How long the SSE responder waits between heartbeat comments to\n * keep proxies / load balancers from closing the idle connection.\n * Default `15_000` ms.\n */\n readonly keepAliveMs?: number;\n readonly now?: () => number;\n}\n\n/**\n * Build the SSE event-stream router. Mounts\n * `GET /sessions/:id/events` (the canonical path documented in the\n * runtime spec); operators that want a different path should mount\n * the router under a custom prefix.\n *\n * @stable\n */\nexport function createSseRoutes(deps: SseRoutesDeps): Hono<{ Variables: ServerVariables }> {\n const sanitizer: DeliveryCommentarySanitizer =\n deps.dispatcher.sanitizer.applyToEvents.length > 0\n ? deps.dispatcher.sanitizer\n : createDeliveryCommentarySanitizer(deps.commentary);\n const keepAliveMs = deps.keepAliveMs ?? 15_000;\n\n const app = new Hono<{ Variables: ServerVariables }>();\n app.get(\n '/:id/events',\n createScopeMiddleware('sessions:read'),\n sseHandler(deps.dispatcher, sanitizer, keepAliveMs, deps.perConnectionQueueLimit ?? 1000),\n );\n return app;\n}\n\nfunction sseHandler(\n dispatcher: WsDispatcher,\n sanitizer: DeliveryCommentarySanitizer,\n keepAliveMs: number,\n perConnectionQueueLimit: number,\n): MiddlewareHandler<{ Variables: ServerVariables }> {\n return async (c: Context<{ Variables: ServerVariables }>) => {\n const sessionId = c.req.param('id');\n if (sessionId === undefined || sessionId.length === 0) {\n return c.json(\n { error: 'session-id-required', message: 'Path parameter :id is required.' },\n 400,\n );\n }\n const subject = `session:${sessionId}/events`;\n const sinceEventId = c.req.header('last-event-id') ?? c.req.header('Last-Event-ID');\n\n c.header('Content-Type', 'text/event-stream; charset=utf-8');\n c.header('Cache-Control', 'no-cache, no-transform');\n c.header('Connection', 'keep-alive');\n c.header('X-Accel-Buffering', 'no');\n\n return stream(c, async (writer) => {\n const replay = dispatcher.replayBuffer.replay(subject, sinceEventId);\n let lastEventId: string | undefined;\n for (const frame of replay.events) {\n const sanitized = sanitizer.sanitize({ ...frame, subscriptionId: 'sse' }, 'sse');\n await writeFrame(writer as StreamingApi, sanitized);\n lastEventId = sanitized.eventId;\n }\n if (replay.droppedCount > 0) {\n await writeReplayMarker(writer as StreamingApi, replay.droppedCount, lastEventId);\n }\n // Live tail — register a synthetic subscription so the\n // dispatcher fans new events into this connection. The\n // dispatcher already validates + sanitizes every frame; here we\n // just write the bytes onto the wire.\n const subscriberId = `sse-${Math.random().toString(36).slice(2, 10)}`;\n const subscriptionId = `${subscriberId}-sub`;\n const authState = c.get('state').auth;\n // IP-13: the anonymous (auth.kind='none') principal carries `admin:*` in\n // grantedScopes, so the dispatcher's per-subject scope check still passes.\n const grantedScopes =\n authState.kind === 'token' || authState.kind === 'anonymous' ? authState.grantedScopes : [];\n const tokenId =\n authState.kind === 'token'\n ? authState.token.tokenId\n : authState.kind === 'anonymous'\n ? 'anonymous'\n : 'sse-anon';\n let queue: ServerMessage[] = [];\n let closedByBackpressure = false;\n const queueLimit = perConnectionQueueLimit;\n let resumeNotify: (() => void) | undefined;\n const notify = (): void => {\n if (resumeNotify !== undefined) {\n const fn = resumeNotify;\n resumeNotify = undefined;\n fn();\n }\n };\n const handle = {\n id: subscriberId,\n tokenId,\n // The strict-mode subscribe path enforces scope; we trust the\n // outer middleware (`createScopeMiddleware('sessions:read')`)\n // to gate this handler so we forward an empty scope set —\n // the dispatcher's per-subject scope check still requires\n // the `agents:invoke:<sessionId>` grant.\n grantedScopes,\n send: (frame: ServerMessage) => {\n // IP-9: bound the queue — a stalled consumer is closed, not\n // buffered into the heap forever.\n if (queue.length >= queueLimit) {\n closedByBackpressure = true;\n queue = [];\n notify();\n return;\n }\n queue.push(frame);\n notify();\n },\n close: () => {\n queue = [];\n notify();\n },\n };\n const reg = dispatcher.registerSubscriber(handle);\n const result = dispatcher.subscribe({\n subscriberId,\n subject,\n subscriptionId,\n ...(lastEventId !== undefined ? { sinceEventId: lastEventId } : {}),\n });\n if (!result.ok) {\n reg.unregister();\n await writer.write(\n encodeSse({\n event: 'error',\n data: JSON.stringify({\n error: 'sse-subscribe-failed',\n reason: result.reason,\n }),\n }),\n );\n await writer.close();\n return;\n }\n const heartbeat = setInterval(() => {\n // Fire-and-forget heartbeat comment — clients ignore comment\n // lines (RFC 6455 § 9.1).\n writer.write(': keep-alive\\n\\n').catch(() => undefined);\n }, keepAliveMs);\n let aborted = false;\n const onAbort = (): void => {\n aborted = true;\n notify();\n };\n c.req.raw.signal.addEventListener('abort', onAbort, { once: true });\n try {\n while (!aborted) {\n if (closedByBackpressure) {\n // IP-9: the consumer stalled past the queue cap — close the\n // stream with a terminal lifecycle frame instead of\n // buffering unboundedly.\n await writer.write(\n encodeSse({\n event: 'lifecycle',\n data: JSON.stringify({\n kind: 'lifecycle',\n subscriptionId,\n status: 'failed',\n reason: 'flow.throttled',\n }),\n }),\n );\n aborted = true;\n continue;\n }\n if (queue.length > 0) {\n const next = queue.shift();\n if (next === undefined) continue;\n if (isEventFrame(next)) {\n await writeFrame(writer as StreamingApi, next);\n } else if (isLifecycleFrame(next)) {\n await writer.write(\n encodeSse({\n event: 'lifecycle',\n data: JSON.stringify(next),\n }),\n );\n if (\n next.status === 'completed' ||\n next.status === 'aborted' ||\n next.status === 'failed'\n ) {\n aborted = true;\n }\n }\n continue;\n }\n await new Promise<void>((resolve) => {\n resumeNotify = resolve;\n });\n }\n } finally {\n clearInterval(heartbeat);\n c.req.raw.signal.removeEventListener('abort', onAbort);\n dispatcher.unsubscribe(subscriptionId);\n reg.unregister();\n }\n });\n };\n}\n\nasync function writeFrame(writer: StreamingApi, frame: ServerEventFrame): Promise<void> {\n const validated = ServerMessageSchema.safeParse(frame);\n if (!validated.success) return;\n await writer.write(\n encodeSse({\n event: frame.type,\n id: frame.eventId,\n data: JSON.stringify(frame),\n }),\n );\n}\n\nasync function writeReplayMarker(\n writer: StreamingApi,\n droppedCount: number,\n lastEventId: string | undefined,\n): Promise<void> {\n await writer.write(\n encodeSse({\n ...(lastEventId !== undefined ? { id: lastEventId } : {}),\n event: 'replay-marker',\n data: JSON.stringify({\n v: '1',\n kind: 'replay-marker',\n subscriptionId: 'sse',\n eventId: lastEventId ?? `evt-replay-${Date.now().toString(36)}`,\n droppedCount,\n }),\n }),\n );\n}\n\nfunction encodeSse(frame: { event?: string; id?: string; data: string }): string {\n let out = '';\n if (frame.event !== undefined) out += `event: ${frame.event}\\n`;\n if (frame.id !== undefined) out += `id: ${frame.id}\\n`;\n for (const line of frame.data.split('\\n')) {\n out += `data: ${line}\\n`;\n }\n out += '\\n';\n return out;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"events.js","names":["sanitizer: DeliveryCommentarySanitizer","lastEventId: string | undefined","queue: ServerMessage[]","resumeNotify: (() => void) | undefined"],"sources":["../../src/sse/events.ts"],"sourcesContent":["/**\n * Server-Sent Events fallback for non-WebSocket-friendly clients.\n *\n * The endpoint is unidirectional: the server pushes\n * `ServerMessage` event frames; control-plane operations\n * (subscribe / unsubscribe / abort / resume) go through REST.\n *\n * Unlike the WS surface, the SSE responder is not multiplexed -\n * one HTTP request per session subject. Reconnection is handled by\n * the standard EventSource `Last-Event-ID` mechanism: when the\n * client supplies the header on resume, the responder walks the\n * dispatcher's replay buffer from that cursor.\n *\n * @packageDocumentation\n */\n\nimport {\n isEventFrame,\n isLifecycleFrame,\n type ServerEventFrame,\n type ServerMessage,\n ServerMessageSchema,\n} from '@graphorin/protocol';\nimport type { Context, MiddlewareHandler } from 'hono';\nimport { Hono } from 'hono';\nimport { stream } from 'hono/streaming';\nimport type { StreamingApi } from 'hono/utils/stream';\n\nimport {\n createDeliveryCommentarySanitizer,\n type DeliveryCommentaryConfig,\n type DeliveryCommentarySanitizer,\n} from '../commentary/index.js';\nimport type { ServerVariables } from '../internal/context.js';\nimport { createScopeMiddleware } from '../middleware/scope.js';\nimport type { WsDispatcher } from '../ws/dispatcher.js';\n\n/**\n * Stable shape consumed by {@link createSseRoutes}.\n *\n * @stable\n */\nexport interface SseRoutesDeps {\n /**\n * Cap on the per-connection SSE delivery queue (IP-9). A consumer\n * that stops reading past this many buffered frames is closed\n * instead of growing the queue without bound. Default 1000.\n */\n readonly perConnectionQueueLimit?: number;\n readonly dispatcher: WsDispatcher;\n readonly commentary?: DeliveryCommentaryConfig;\n /**\n * How long the SSE responder waits between heartbeat comments to\n * keep proxies / load balancers from closing the idle connection.\n * Default `15_000` ms.\n */\n readonly keepAliveMs?: number;\n readonly now?: () => number;\n}\n\n/**\n * Build the SSE event-stream router. Mounts\n * `GET /sessions/:id/events` (the canonical path documented in the\n * runtime spec); operators that want a different path should mount\n * the router under a custom prefix.\n *\n * @stable\n */\nexport function createSseRoutes(deps: SseRoutesDeps): Hono<{ Variables: ServerVariables }> {\n const sanitizer: DeliveryCommentarySanitizer =\n deps.dispatcher.sanitizer.applyToEvents.length > 0\n ? deps.dispatcher.sanitizer\n : createDeliveryCommentarySanitizer(deps.commentary);\n const keepAliveMs = deps.keepAliveMs ?? 15_000;\n\n const app = new Hono<{ Variables: ServerVariables }>();\n app.get(\n '/:id/events',\n createScopeMiddleware('sessions:read'),\n sseHandler(deps.dispatcher, sanitizer, keepAliveMs, deps.perConnectionQueueLimit ?? 1000),\n );\n return app;\n}\n\nfunction sseHandler(\n dispatcher: WsDispatcher,\n sanitizer: DeliveryCommentarySanitizer,\n keepAliveMs: number,\n perConnectionQueueLimit: number,\n): MiddlewareHandler<{ Variables: ServerVariables }> {\n return async (c: Context<{ Variables: ServerVariables }>) => {\n const sessionId = c.req.param('id');\n if (sessionId === undefined || sessionId.length === 0) {\n return c.json(\n { error: 'session-id-required', message: 'Path parameter :id is required.' },\n 400,\n );\n }\n const subject = `session:${sessionId}/events`;\n const sinceEventId = c.req.header('last-event-id') ?? c.req.header('Last-Event-ID');\n\n c.header('Content-Type', 'text/event-stream; charset=utf-8');\n c.header('Cache-Control', 'no-cache, no-transform');\n c.header('Connection', 'keep-alive');\n c.header('X-Accel-Buffering', 'no');\n\n return stream(c, async (writer) => {\n const replay = dispatcher.replayBuffer.replay(subject, sinceEventId);\n let lastEventId: string | undefined;\n for (const frame of replay.events) {\n const sanitized = sanitizer.sanitize({ ...frame, subscriptionId: 'sse' }, 'sse');\n await writeFrame(writer as StreamingApi, sanitized);\n lastEventId = sanitized.eventId;\n }\n if (replay.droppedCount > 0) {\n await writeReplayMarker(writer as StreamingApi, replay.droppedCount, lastEventId);\n }\n // Live tail - register a synthetic subscription so the\n // dispatcher fans new events into this connection. The\n // dispatcher already validates + sanitizes every frame; here we\n // just write the bytes onto the wire.\n const subscriberId = `sse-${Math.random().toString(36).slice(2, 10)}`;\n const subscriptionId = `${subscriberId}-sub`;\n const authState = c.get('state').auth;\n // IP-13: the anonymous (auth.kind='none') principal carries `admin:*` in\n // grantedScopes, so the dispatcher's per-subject scope check still passes.\n const grantedScopes =\n authState.kind === 'token' || authState.kind === 'anonymous' ? authState.grantedScopes : [];\n const tokenId =\n authState.kind === 'token'\n ? authState.token.tokenId\n : authState.kind === 'anonymous'\n ? 'anonymous'\n : 'sse-anon';\n let queue: ServerMessage[] = [];\n let closedByBackpressure = false;\n const queueLimit = perConnectionQueueLimit;\n let resumeNotify: (() => void) | undefined;\n const notify = (): void => {\n if (resumeNotify !== undefined) {\n const fn = resumeNotify;\n resumeNotify = undefined;\n fn();\n }\n };\n const handle = {\n id: subscriberId,\n tokenId,\n // The strict-mode subscribe path enforces scope; we trust the\n // outer middleware (`createScopeMiddleware('sessions:read')`)\n // to gate this handler so we forward an empty scope set -\n // the dispatcher's per-subject scope check still requires\n // the `agents:invoke:<sessionId>` grant.\n grantedScopes,\n send: (frame: ServerMessage) => {\n // IP-9: bound the queue - a stalled consumer is closed, not\n // buffered into the heap forever.\n if (queue.length >= queueLimit) {\n closedByBackpressure = true;\n queue = [];\n notify();\n return;\n }\n queue.push(frame);\n notify();\n },\n close: () => {\n queue = [];\n notify();\n },\n };\n const reg = dispatcher.registerSubscriber(handle);\n const result = dispatcher.subscribe({\n subscriberId,\n subject,\n subscriptionId,\n ...(lastEventId !== undefined ? { sinceEventId: lastEventId } : {}),\n });\n if (!result.ok) {\n reg.unregister();\n await writer.write(\n encodeSse({\n event: 'error',\n data: JSON.stringify({\n error: 'sse-subscribe-failed',\n reason: result.reason,\n }),\n }),\n );\n await writer.close();\n return;\n }\n const heartbeat = setInterval(() => {\n // Fire-and-forget heartbeat comment - clients ignore comment\n // lines (RFC 6455 § 9.1).\n writer.write(': keep-alive\\n\\n').catch(() => undefined);\n }, keepAliveMs);\n let aborted = false;\n const onAbort = (): void => {\n aborted = true;\n notify();\n };\n c.req.raw.signal.addEventListener('abort', onAbort, { once: true });\n try {\n while (!aborted) {\n if (closedByBackpressure) {\n // IP-9: the consumer stalled past the queue cap - close the\n // stream with a terminal lifecycle frame instead of\n // buffering unboundedly.\n await writer.write(\n encodeSse({\n event: 'lifecycle',\n data: JSON.stringify({\n kind: 'lifecycle',\n subscriptionId,\n status: 'failed',\n reason: 'flow.throttled',\n }),\n }),\n );\n aborted = true;\n continue;\n }\n if (queue.length > 0) {\n const next = queue.shift();\n if (next === undefined) continue;\n if (isEventFrame(next)) {\n await writeFrame(writer as StreamingApi, next);\n } else if (isLifecycleFrame(next)) {\n await writer.write(\n encodeSse({\n event: 'lifecycle',\n data: JSON.stringify(next),\n }),\n );\n if (\n next.status === 'completed' ||\n next.status === 'aborted' ||\n next.status === 'failed'\n ) {\n aborted = true;\n }\n }\n continue;\n }\n await new Promise<void>((resolve) => {\n resumeNotify = resolve;\n });\n }\n } finally {\n clearInterval(heartbeat);\n c.req.raw.signal.removeEventListener('abort', onAbort);\n dispatcher.unsubscribe(subscriptionId);\n reg.unregister();\n }\n });\n };\n}\n\nasync function writeFrame(writer: StreamingApi, frame: ServerEventFrame): Promise<void> {\n const validated = ServerMessageSchema.safeParse(frame);\n if (!validated.success) return;\n await writer.write(\n encodeSse({\n event: frame.type,\n id: frame.eventId,\n data: JSON.stringify(frame),\n }),\n );\n}\n\nasync function writeReplayMarker(\n writer: StreamingApi,\n droppedCount: number,\n lastEventId: string | undefined,\n): Promise<void> {\n await writer.write(\n encodeSse({\n ...(lastEventId !== undefined ? { id: lastEventId } : {}),\n event: 'replay-marker',\n data: JSON.stringify({\n v: '1',\n kind: 'replay-marker',\n subscriptionId: 'sse',\n eventId: lastEventId ?? `evt-replay-${Date.now().toString(36)}`,\n droppedCount,\n }),\n }),\n );\n}\n\nfunction encodeSse(frame: { event?: string; id?: string; data: string }): string {\n let out = '';\n if (frame.event !== undefined) out += `event: ${frame.event}\\n`;\n if (frame.id !== undefined) out += `id: ${frame.id}\\n`;\n for (const line of frame.data.split('\\n')) {\n out += `data: ${line}\\n`;\n }\n out += '\\n';\n return out;\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"daemon.js","names":["lastFireAtMs: number | undefined","pump: Promise<void> | undefined","out: {\n -readonly [K in keyof TriggersDaemonStatus]?: TriggersDaemonStatus[K];\n }"],"sources":["../../src/triggers/daemon.ts"],"sourcesContent":["/**\n * Trigger scheduler integration with the server lifecycle.\n *\n * The daemon wraps a `Scheduler` from `@graphorin/triggers` and binds\n * its `start()` / `stop()` calls to the `beforeStart` / `beforeShutdown`\n * lifecycle hooks. On `start()`, every persisted trigger is loaded\n * from `trigger_state` (durable) and the per-trigger `catchupPolicy`\n * is honoured so cron / interval / idle / event triggers survive\n * process restarts as documented in the runtime spec.\n *\n * The daemon never invokes user callbacks itself
|
|
1
|
+
{"version":3,"file":"daemon.js","names":["lastFireAtMs: number | undefined","pump: Promise<void> | undefined","out: {\n -readonly [K in keyof TriggersDaemonStatus]?: TriggersDaemonStatus[K];\n }"],"sources":["../../src/triggers/daemon.ts"],"sourcesContent":["/**\n * Trigger scheduler integration with the server lifecycle.\n *\n * The daemon wraps a `Scheduler` from `@graphorin/triggers` and binds\n * its `start()` / `stop()` calls to the `beforeStart` / `beforeShutdown`\n * lifecycle hooks. On `start()`, every persisted trigger is loaded\n * from `trigger_state` (durable) and the per-trigger `catchupPolicy`\n * is honoured so cron / interval / idle / event triggers survive\n * process restarts as documented in the runtime spec.\n *\n * The daemon never invokes user callbacks itself - those run inside\n * the `Scheduler`. The daemon only owns lifecycle + observability.\n *\n * @packageDocumentation\n */\n\nimport type { CatchupPolicy, Scheduler, SchedulerEvent } from '@graphorin/triggers';\n\n/**\n * Snapshot exposed via {@link TriggersDaemon.status} + the\n * `/v1/health` aggregator. Counts split by `disabled` so the health\n * endpoint can flag a deployment that disabled every cron trigger.\n *\n * @stable\n */\nexport interface TriggersDaemonStatus {\n readonly running: boolean;\n readonly active: number;\n readonly disabled: number;\n readonly deferred: number;\n /** Last fire timestamp across the entire pool (ISO-8601). */\n readonly lastFireAt?: string;\n}\n\n/**\n * Aggregate counters surfaced through the Prometheus\n * `/v1/metrics` exposition. Updated incrementally as the scheduler\n * publishes lifecycle events.\n *\n * @stable\n */\nexport interface TriggersDaemonMetrics {\n readonly fires: ReadonlyMap<string, { readonly success: number; readonly error: number }>;\n readonly catchupApplied: number;\n readonly libModeWarnings: number;\n}\n\n/**\n * @stable\n */\nexport interface CreateTriggersDaemonOptions {\n readonly scheduler: Scheduler;\n /**\n * Optional logger. Defaults to the standard error stream so the\n * daemon never depends on the framework logger directly.\n */\n readonly warn?: (message: string) => void;\n}\n\n/**\n * Stateful handle returned by {@link createTriggersDaemon}. The\n * `start()` / `stop()` methods are wired into\n * `LifecycleHooks.beforeStart` / `beforeShutdown` by `createServer`.\n *\n * @stable\n */\nexport interface TriggersDaemon {\n start(): Promise<void>;\n stop(): Promise<void>;\n status(): Promise<TriggersDaemonStatus>;\n metrics(): TriggersDaemonMetrics;\n readonly scheduler: Scheduler;\n}\n\n/**\n * @stable\n */\nexport function createTriggersDaemon(options: CreateTriggersDaemonOptions): TriggersDaemon {\n const fires = new Map<string, { success: number; error: number }>();\n let catchupApplied = 0;\n let libModeWarnings = 0;\n let lastFireAtMs: number | undefined;\n let running = false;\n let pump: Promise<void> | undefined;\n\n const warn = options.warn ?? ((m: string) => process.stderr.write(`${m}\\n`));\n\n function recordFire(id: string, kind: 'success' | 'error'): void {\n const entry = fires.get(id) ?? { success: 0, error: 0 };\n entry[kind] += 1;\n fires.set(id, entry);\n lastFireAtMs = Date.now();\n }\n\n async function pumpEvents(): Promise<void> {\n try {\n for await (const event of options.scheduler.events()) {\n observe(event);\n }\n } catch (err) {\n warn(`[graphorin/server] triggers daemon event pump aborted: ${describeError(err)}`);\n }\n }\n\n function observe(event: SchedulerEvent): void {\n switch (event.type) {\n case 'fire-end':\n recordFire(event.id, 'success');\n return;\n case 'fire-error':\n recordFire(event.id, 'error');\n warn(\n `[graphorin/server] triggers daemon: trigger '${event.id}' failed after ${event.durationMs}ms: ${describeError(event.error)}`,\n );\n return;\n case 'catchup-applied':\n catchupApplied += event.missed;\n return;\n case 'lib-mode-warning':\n libModeWarnings += 1;\n return;\n default:\n return;\n }\n }\n\n return {\n get scheduler() {\n return options.scheduler;\n },\n async start() {\n if (running) return;\n running = true;\n pump = pumpEvents();\n await options.scheduler.start();\n },\n async stop() {\n if (!running) return;\n running = false;\n await options.scheduler.stop();\n if (pump !== undefined) {\n try {\n await pump;\n } catch {\n // Best-effort during stop().\n }\n pump = undefined;\n }\n },\n async status() {\n const all = await options.scheduler.list();\n let active = 0;\n let disabled = 0;\n let deferred = 0;\n for (const t of all) {\n if (t.disabled) disabled += 1;\n else active += 1;\n if (t.missedFires > 0) deferred += 1;\n }\n const out: {\n -readonly [K in keyof TriggersDaemonStatus]?: TriggersDaemonStatus[K];\n } = {\n running,\n active,\n disabled,\n deferred,\n };\n if (lastFireAtMs !== undefined) {\n out.lastFireAt = new Date(lastFireAtMs).toISOString();\n }\n return Object.freeze(out as TriggersDaemonStatus);\n },\n metrics() {\n return Object.freeze({\n fires: new Map(fires),\n catchupApplied,\n libModeWarnings,\n });\n },\n };\n}\n\n/**\n * Resolve the catch-up policy default for triggers that did not\n * declare one explicitly. Returns `'none'` per DEC-150 (personal-\n * assistant friendly).\n *\n * @stable\n */\nexport function defaultCatchupPolicy(): CatchupPolicy {\n return 'none';\n}\n\nfunction describeError(err: unknown): string {\n if (err === null || err === undefined) return 'unknown';\n if (err instanceof Error) return err.message;\n return String(err);\n}\n"],"mappings":";;;;AA6EA,SAAgB,qBAAqB,SAAsD;CACzF,MAAM,wBAAQ,IAAI,KAAiD;CACnE,IAAI,iBAAiB;CACrB,IAAI,kBAAkB;CACtB,IAAIA;CACJ,IAAI,UAAU;CACd,IAAIC;CAEJ,MAAM,OAAO,QAAQ,UAAU,MAAc,QAAQ,OAAO,MAAM,GAAG,EAAE,IAAI;CAE3E,SAAS,WAAW,IAAY,MAAiC;EAC/D,MAAM,QAAQ,MAAM,IAAI,GAAG,IAAI;GAAE,SAAS;GAAG,OAAO;GAAG;AACvD,QAAM,SAAS;AACf,QAAM,IAAI,IAAI,MAAM;AACpB,iBAAe,KAAK,KAAK;;CAG3B,eAAe,aAA4B;AACzC,MAAI;AACF,cAAW,MAAM,SAAS,QAAQ,UAAU,QAAQ,CAClD,SAAQ,MAAM;WAET,KAAK;AACZ,QAAK,0DAA0D,cAAc,IAAI,GAAG;;;CAIxF,SAAS,QAAQ,OAA6B;AAC5C,UAAQ,MAAM,MAAd;GACE,KAAK;AACH,eAAW,MAAM,IAAI,UAAU;AAC/B;GACF,KAAK;AACH,eAAW,MAAM,IAAI,QAAQ;AAC7B,SACE,gDAAgD,MAAM,GAAG,iBAAiB,MAAM,WAAW,MAAM,cAAc,MAAM,MAAM,GAC5H;AACD;GACF,KAAK;AACH,sBAAkB,MAAM;AACxB;GACF,KAAK;AACH,uBAAmB;AACnB;GACF,QACE;;;AAIN,QAAO;EACL,IAAI,YAAY;AACd,UAAO,QAAQ;;EAEjB,MAAM,QAAQ;AACZ,OAAI,QAAS;AACb,aAAU;AACV,UAAO,YAAY;AACnB,SAAM,QAAQ,UAAU,OAAO;;EAEjC,MAAM,OAAO;AACX,OAAI,CAAC,QAAS;AACd,aAAU;AACV,SAAM,QAAQ,UAAU,MAAM;AAC9B,OAAI,SAAS,QAAW;AACtB,QAAI;AACF,WAAM;YACA;AAGR,WAAO;;;EAGX,MAAM,SAAS;GACb,MAAM,MAAM,MAAM,QAAQ,UAAU,MAAM;GAC1C,IAAI,SAAS;GACb,IAAI,WAAW;GACf,IAAI,WAAW;AACf,QAAK,MAAM,KAAK,KAAK;AACnB,QAAI,EAAE,SAAU,aAAY;QACvB,WAAU;AACf,QAAI,EAAE,cAAc,EAAG,aAAY;;GAErC,MAAMC,MAEF;IACF;IACA;IACA;IACA;IACD;AACD,OAAI,iBAAiB,OACnB,KAAI,aAAa,IAAI,KAAK,aAAa,CAAC,aAAa;AAEvD,UAAO,OAAO,OAAO,IAA4B;;EAEnD,UAAU;AACR,UAAO,OAAO,OAAO;IACnB,OAAO,IAAI,IAAI,MAAM;IACrB;IACA;IACD,CAAC;;EAEL;;;;;;;;;AAUH,SAAgB,uBAAsC;AACpD,QAAO;;AAGT,SAAS,cAAc,KAAsB;AAC3C,KAAI,QAAQ,QAAQ,QAAQ,OAAW,QAAO;AAC9C,KAAI,eAAe,MAAO,QAAO,IAAI;AACrC,QAAO,OAAO,IAAI"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"routes.js","names":["scheduler: Scheduler","removedIds: string[]"],"sources":["../../src/triggers/routes.ts"],"sourcesContent":["/**\n * REST routes for the `/v1/triggers` family. Mounted by the server\n * factory when a {@link TriggersDaemon} is wired in.\n *\n * GET /triggers (scope `triggers:read`)\n * GET /triggers/:id (scope `triggers:read`)\n * POST /triggers/:id/fire (scope `triggers:fire`)\n * POST /triggers/:id/disable (scope `triggers:disable`)
|
|
1
|
+
{"version":3,"file":"routes.js","names":["scheduler: Scheduler","removedIds: string[]"],"sources":["../../src/triggers/routes.ts"],"sourcesContent":["/**\n * REST routes for the `/v1/triggers` family. Mounted by the server\n * factory when a {@link TriggersDaemon} is wired in.\n *\n * GET /triggers (scope `triggers:read`)\n * GET /triggers/:id (scope `triggers:read`)\n * POST /triggers/:id/fire (scope `triggers:fire`)\n * POST /triggers/:id/disable (scope `triggers:disable`) - flag flip\n * POST /triggers/:id/enable (scope `triggers:disable`) - flag flip\n * DELETE /triggers/:id (scope `triggers:disable`) - unregister\n * POST /triggers/prune (scope `triggers:disable`)\n *\n * Response shapes mirror the persisted `TriggerState` rows so CLI +\n * dashboard consumers can render them without re-mapping.\n *\n * @packageDocumentation\n */\n\nimport type { TriggerState } from '@graphorin/core/contracts';\nimport type { Scheduler } from '@graphorin/triggers';\nimport { Hono } from 'hono';\n\nimport type { ServerVariables } from '../internal/context.js';\nimport { createScopeMiddleware } from '../middleware/scope.js';\nimport type { TriggersDaemon } from './daemon.js';\n\n/**\n * @stable\n */\nexport interface TriggersRoutesDeps {\n readonly daemon: TriggersDaemon;\n}\n\n/**\n * @stable\n */\nexport function createTriggersRoutes(\n deps: TriggersRoutesDeps,\n): Hono<{ Variables: ServerVariables }> {\n const app = new Hono<{ Variables: ServerVariables }>();\n const scheduler: Scheduler = deps.daemon.scheduler;\n\n app.get('/', createScopeMiddleware('triggers:read'), async (c) => {\n const all = await scheduler.list();\n return c.json({ triggers: all.map(serializeTrigger) });\n });\n\n app.get('/:id', createScopeMiddleware('triggers:read'), async (c) => {\n const id = c.req.param('id');\n const all = await scheduler.list();\n const found = all.find((t) => t.id === id);\n if (found === undefined) {\n return c.json({ error: 'not-found', message: `Trigger '${id}' not found.` }, 404);\n }\n return c.json({ trigger: serializeTrigger(found) });\n });\n\n app.post('/:id/fire', createScopeMiddleware('triggers:fire'), async (c) => {\n const id = c.req.param('id');\n try {\n await scheduler.fire(id);\n } catch (err) {\n return c.json(\n {\n error: 'not-found',\n message: err instanceof Error ? err.message : `Trigger '${id}' not found.`,\n },\n 404,\n );\n }\n return c.json({ ok: true, fired: id });\n });\n\n // IP-17: disable/enable are non-destructive flag flips on the\n // persistent TriggerState (in agreement with the CLI); the\n // destructive removal is DELETE /:id.\n app.post('/:id/disable', createScopeMiddleware('triggers:disable'), async (c) => {\n const id = c.req.param('id');\n try {\n const updated = await scheduler.setDisabled(id, true);\n return c.json({ ok: true, trigger: serializeTrigger(updated) });\n } catch {\n return c.json({ error: 'not-found', message: `Trigger '${id}' not found.` }, 404);\n }\n });\n\n app.post('/:id/enable', createScopeMiddleware('triggers:disable'), async (c) => {\n const id = c.req.param('id');\n try {\n const updated = await scheduler.setDisabled(id, false);\n return c.json({ ok: true, trigger: serializeTrigger(updated) });\n } catch {\n return c.json({ error: 'not-found', message: `Trigger '${id}' not found.` }, 404);\n }\n });\n\n app.delete('/:id', createScopeMiddleware('triggers:disable'), async (c) => {\n const id = c.req.param('id');\n const all = await scheduler.list();\n if (!all.some((t) => t.id === id)) {\n return c.json({ error: 'not-found', message: `Trigger '${id}' not found.` }, 404);\n }\n await scheduler.unregister(id);\n return c.json({ ok: true, removed: id });\n });\n\n app.post('/prune', createScopeMiddleware('triggers:disable'), async (c) => {\n const all = await scheduler.list();\n const removedIds: string[] = [];\n for (const trigger of all) {\n if (trigger.disabled) {\n await scheduler.unregister(trigger.id);\n removedIds.push(trigger.id);\n }\n }\n return c.json({ ok: true, removed: removedIds, count: removedIds.length });\n });\n\n return app;\n}\n\nfunction serializeTrigger(state: TriggerState): Record<string, unknown> {\n return {\n id: state.id,\n kind: state.kind,\n spec: state.spec,\n callbackRef: state.callbackRef,\n ...(state.nextFireAt !== undefined ? { nextFireAt: state.nextFireAt } : {}),\n ...(state.lastFiredAt !== undefined ? { lastFiredAt: state.lastFiredAt } : {}),\n missedFires: state.missedFires,\n disabled: state.disabled,\n catchupPolicy: state.catchupPolicy,\n maxCatchupRuns: state.maxCatchupRuns,\n catchupWindowMs: state.catchupWindowMs,\n ...(state.tags !== undefined ? { tags: [...state.tags] } : {}),\n createdAt: state.createdAt,\n ...(state.updatedAt !== undefined ? { updatedAt: state.updatedAt } : {}),\n };\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dispatcher.js","names":["record: SubscriptionRecord","snapshotEventId: string | undefined","frame: ServerEventFrame","bufferedFrame: ServerEventFrame"],"sources":["../../src/ws/dispatcher.ts"],"sourcesContent":["/**\n * Per-server WebSocket dispatcher. Owns the in-memory subscription\n * map, fans out events to matching connections, applies the\n * delivery-layer commentary-phase trace sanitization, validates\n * outbound frames against the `@graphorin/protocol` schema, and\n * exposes a tiny `emit(subject, event)` surface route handlers\n * (and the agent / workflow runtimes) consume.\n *\n * @packageDocumentation\n */\n\nimport {\n isEventFrame,\n type ServerEventFrame,\n type ServerLifecycleFrame,\n type ServerMessage,\n ServerMessageSchema,\n} from '@graphorin/protocol';\nimport type { ParsedScope } from '@graphorin/security/auth';\nimport {\n createDeliveryCommentarySanitizer,\n type DeliveryCommentaryConfig,\n type DeliveryCommentarySanitizer,\n} from '../commentary/index.js';\nimport {\n createReplayBuffer,\n type ReplayBuffer,\n type ReplayBufferOptions,\n} from './replay-buffer.js';\nimport { isSubjectAllowed, type ParsedSubject, tryParseSubject } from './subjects.js';\n\n/**\n * Public configuration accepted by {@link createWsDispatcher}.\n *\n * @stable\n */\nexport interface WsDispatcherOptions {\n readonly commentary?: DeliveryCommentaryConfig;\n readonly replayBuffer?: ReplayBufferOptions;\n /**\n * Cap on outstanding events queued for an offline subscriber.\n * Defaults to the same value as the replay buffer per-subject cap.\n */\n readonly perConnectionQueueLimit?: number;\n readonly now?: () => number;\n /**\n * Logger sink for protocol violations + dropped frames. When\n * omitted, the dispatcher is silent (production wiring uses the\n * server's structured logger).\n */\n readonly onWarn?: (event: WsDispatcherWarning) => void;\n}\n\n/**\n * Discriminator surfaced to the optional warn sink.\n *\n * @stable\n */\nexport type WsDispatcherWarning =\n | {\n readonly kind: 'invalid-frame';\n readonly subscriptionId: string;\n readonly issues: ReadonlyArray<string>;\n }\n | { readonly kind: 'queue-overflow'; readonly subscriptionId: string }\n | { readonly kind: 'subject-rejected'; readonly subject: string; readonly reason: string };\n\n/**\n * Subscriber surface used by the dispatcher. Each WebSocket\n * connection wraps its `WSContext.send` in this interface so the\n * dispatcher does not depend on `@hono/node-ws` types directly.\n *\n * @stable\n */\nexport interface WsSubscriberHandle {\n readonly id: string;\n readonly tokenId: string;\n readonly grantedScopes: ReadonlyArray<ParsedScope>;\n /** Send a server frame; the dispatcher already validated it. */\n send(frame: ServerMessage): void;\n /** Close the underlying WebSocket with a Graphorin close code. */\n close(code: number, reason: string): void;\n /**\n * Optional buffered-byte sample. The dispatcher reads this on every\n * emit to detect sustained backpressure and close the connection\n * with the Graphorin `flow.throttled` code (4006) before the OS-\n * level send buffer collapses. Consumers backed by `@hono/node-ws`\n * can return the underlying `ws.bufferedAmount`. When the field is\n * not implemented, the dispatcher falls back to the per-connection\n * outstanding-event counter.\n */\n bufferedAmount?(): number;\n}\n\n/**\n * Snapshot of an active subscription. Surfaced via\n * {@link WsDispatcher.snapshotSubscription}.\n *\n * @stable\n */\nexport interface WsSubscriptionSnapshot {\n readonly subscriptionId: string;\n readonly subject: string;\n readonly subjectKind: ParsedSubject['kind'];\n readonly subscriberId: string;\n readonly tokenId: string;\n readonly createdAt: number;\n readonly lastEventId: string | undefined;\n}\n\n/**\n * Result of {@link WsDispatcher.subscribe}.\n *\n * @stable\n */\nexport type SubscribeResult =\n | {\n readonly ok: true;\n readonly subscription: WsSubscriptionSnapshot;\n readonly replayedCount: number;\n readonly snapshotEventId: string | undefined;\n }\n | {\n readonly ok: false;\n readonly reason:\n | 'subject-malformed'\n | 'subject-unknown'\n | 'subject-wildcard'\n | 'scope-denied';\n };\n\n/**\n * Public surface of the dispatcher.\n *\n * @stable\n */\nexport interface WsDispatcher {\n readonly sanitizer: DeliveryCommentarySanitizer;\n readonly replayBuffer: ReplayBuffer;\n /**\n * Register a subscriber (one per WebSocket connection).\n * `unregister` is called on close.\n */\n registerSubscriber(handle: WsSubscriberHandle): { unregister(): void };\n /**\n * Open a new subscription for an active subscriber. Returns either\n * the subscription snapshot + replayed-event count or a typed\n * failure reason the caller maps to the appropriate close code /\n * RPC error.\n */\n subscribe(input: {\n readonly subscriberId: string;\n readonly subject: string;\n readonly subscriptionId: string;\n readonly sinceEventId?: string;\n }): SubscribeResult;\n unsubscribe(subscriptionId: string): boolean;\n /**\n * Push an event into the dispatcher. Goes through the sanitizer,\n * validates against the protocol schema, persists into the replay\n * buffer, and fans out to every matching active subscription.\n */\n emit(subject: string, frame: BareEventFrame): void;\n /** Push a lifecycle frame to a single subscription. */\n emitLifecycle(\n subscriptionId: string,\n status: ServerLifecycleFrame['status'],\n reason?: string,\n ): void;\n /** List active subscriptions for a given subscriber (diagnostics). */\n listForSubscriber(subscriberId: string): ReadonlyArray<WsSubscriptionSnapshot>;\n snapshotSubscription(subscriptionId: string): WsSubscriptionSnapshot | undefined;\n size(): { readonly subscribers: number; readonly subscriptions: number };\n /** Clear in-memory state (used during graceful shutdown). */\n shutdown(): void;\n}\n\n/**\n * Frame argument accepted by {@link WsDispatcher.emit}. The\n * dispatcher fills in `subscriptionId`, `subject`, and `eventId`.\n *\n * @stable\n */\nexport interface BareEventFrame {\n readonly type: string;\n readonly payload: unknown;\n readonly eventId?: string;\n}\n\ninterface SubscriberRecord {\n readonly handle: WsSubscriberHandle;\n readonly subscriptions: Set<string>;\n outstandingEvents: number;\n closed: boolean;\n}\n\ninterface SubscriptionRecord {\n readonly subscriberId: string;\n /** IP-18: the authenticating principal's token id (not the connection id). */\n readonly tokenId: string;\n readonly subscriptionId: string;\n readonly subject: string;\n readonly parsed: ParsedSubject;\n readonly createdAt: number;\n lastEventId: string | undefined;\n}\n\n/**\n * Build a fresh dispatcher.\n *\n * @stable\n */\nexport function createWsDispatcher(options: WsDispatcherOptions = {}): WsDispatcher {\n const sanitizer = createDeliveryCommentarySanitizer(options.commentary);\n const replayBuffer = createReplayBuffer(options.replayBuffer ?? {});\n const now = options.now ?? Date.now;\n const onWarn = options.onWarn;\n const perConnectionQueueLimit = options.perConnectionQueueLimit ?? 1_000;\n const subscribers = new Map<string, SubscriberRecord>();\n const subscriptions = new Map<string, SubscriptionRecord>();\n const subjectIndex = new Map<string, Set<string>>();\n let eventCounter = 0;\n\n function indexSubject(subject: string, subscriptionId: string): void {\n let set = subjectIndex.get(subject);\n if (set === undefined) {\n set = new Set();\n subjectIndex.set(subject, set);\n }\n set.add(subscriptionId);\n }\n\n function deindexSubject(subject: string, subscriptionId: string): void {\n const set = subjectIndex.get(subject);\n if (set === undefined) return;\n set.delete(subscriptionId);\n if (set.size === 0) subjectIndex.delete(subject);\n }\n\n function nextEventId(): string {\n eventCounter += 1;\n return `evt-${now().toString(36)}-${eventCounter.toString(36)}`;\n }\n\n function dispatchToSubscriber(record: SubscriptionRecord, frame: ServerMessage): void {\n const subscriber = subscribers.get(record.subscriberId);\n if (subscriber === undefined || subscriber.closed) return;\n // Per-connection backpressure: track the outstanding-event counter\n // and consult the optional `bufferedAmount()` sample. The\n // dispatcher closes the connection with the Graphorin\n // `flow.throttled` code (4006) when either signal exceeds the\n // configured limit so the client can reconnect against the\n // replay buffer instead of accumulating an unbounded send queue.\n const buffered =\n typeof subscriber.handle.bufferedAmount === 'function'\n ? subscriber.handle.bufferedAmount()\n : 0;\n if (\n subscriber.outstandingEvents >= perConnectionQueueLimit ||\n buffered >= perConnectionQueueLimit * 1024\n ) {\n onWarn?.({ kind: 'queue-overflow', subscriptionId: record.subscriptionId });\n shutdownSubscriber(subscriber, 4006, 'flow.throttled');\n return;\n }\n subscriber.outstandingEvents += 1;\n try {\n subscriber.handle.send(frame);\n } catch (err) {\n onWarn?.({ kind: 'queue-overflow', subscriptionId: record.subscriptionId });\n shutdownSubscriber(subscriber, 4006, 'flow.throttled');\n void err;\n } finally {\n subscriber.outstandingEvents = Math.max(0, subscriber.outstandingEvents - 1);\n }\n }\n\n function shutdownSubscriber(subscriber: SubscriberRecord, code: number, reason: string): void {\n if (subscriber.closed) return;\n subscriber.closed = true;\n try {\n subscriber.handle.close(code, reason);\n } catch {\n // ignore — we are tearing the subscriber down.\n }\n for (const id of subscriber.subscriptions) {\n const sub = subscriptions.get(id);\n if (sub !== undefined) {\n deindexSubject(sub.subject, id);\n subscriptions.delete(id);\n }\n }\n subscriber.subscriptions.clear();\n subscribers.delete(subscriber.handle.id);\n }\n\n function validateAndDispatch(record: SubscriptionRecord, frame: ServerMessage): void {\n const validated = ServerMessageSchema.safeParse(frame);\n if (!validated.success) {\n onWarn?.({\n kind: 'invalid-frame',\n subscriptionId: record.subscriptionId,\n issues: validated.error.issues.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`),\n });\n return;\n }\n dispatchToSubscriber(record, validated.data);\n }\n\n function registerSubscriber(handle: WsSubscriberHandle): { unregister(): void } {\n subscribers.set(handle.id, {\n handle,\n subscriptions: new Set(),\n outstandingEvents: 0,\n closed: false,\n });\n return {\n unregister: () => {\n const record = subscribers.get(handle.id);\n if (record === undefined) return;\n for (const id of record.subscriptions) {\n const sub = subscriptions.get(id);\n if (sub !== undefined) {\n deindexSubject(sub.subject, id);\n subscriptions.delete(id);\n }\n }\n subscribers.delete(handle.id);\n },\n };\n }\n\n function subscribe(input: {\n readonly subscriberId: string;\n readonly subject: string;\n readonly subscriptionId: string;\n readonly sinceEventId?: string;\n }): SubscribeResult {\n const subscriber = subscribers.get(input.subscriberId);\n if (subscriber === undefined) {\n return { ok: false, reason: 'subject-unknown' };\n }\n const parseResult = tryParseSubject(input.subject);\n if (!parseResult.ok) {\n const reason = parseResult.reason;\n onWarn?.({ kind: 'subject-rejected', subject: input.subject, reason });\n if (reason === 'wildcard-not-supported') return { ok: false, reason: 'subject-wildcard' };\n if (reason === 'unknown-subject') return { ok: false, reason: 'subject-unknown' };\n return { ok: false, reason: 'subject-malformed' };\n }\n if (!isSubjectAllowed(subscriber.handle.grantedScopes, parseResult.subject)) {\n return { ok: false, reason: 'scope-denied' };\n }\n const record: SubscriptionRecord = {\n subscriberId: input.subscriberId,\n tokenId: subscriber.handle.tokenId,\n subscriptionId: input.subscriptionId,\n subject: input.subject,\n parsed: parseResult.subject,\n createdAt: now(),\n lastEventId: undefined,\n };\n subscriptions.set(input.subscriptionId, record);\n subscriber.subscriptions.add(input.subscriptionId);\n indexSubject(input.subject, input.subscriptionId);\n const replay = replayBuffer.replay(input.subject, input.sinceEventId);\n let replayed = 0;\n let snapshotEventId: string | undefined = replay.nextEventIdHint;\n for (const event of replay.events) {\n const frame: ServerEventFrame = {\n ...event,\n subscriptionId: input.subscriptionId,\n };\n validateAndDispatch(record, frame);\n record.lastEventId = frame.eventId;\n replayed += 1;\n }\n if (replay.droppedCount > 0) {\n validateAndDispatch(record, {\n v: '1',\n kind: 'replay-marker',\n subscriptionId: input.subscriptionId,\n eventId: snapshotEventId ?? `evt-replay-${now().toString(36)}`,\n droppedCount: replay.droppedCount,\n note: `Replay buffer dropped ${replay.droppedCount} events before resume.`,\n });\n snapshotEventId = snapshotEventId ?? `evt-replay-${now().toString(36)}`;\n }\n return {\n ok: true,\n subscription: snapshotForRecord(record),\n replayedCount: replayed,\n snapshotEventId,\n };\n }\n\n function unsubscribe(subscriptionId: string): boolean {\n const record = subscriptions.get(subscriptionId);\n if (record === undefined) return false;\n const subscriber = subscribers.get(record.subscriberId);\n subscriber?.subscriptions.delete(subscriptionId);\n deindexSubject(record.subject, subscriptionId);\n subscriptions.delete(subscriptionId);\n return true;\n }\n\n function emit(subject: string, bare: BareEventFrame): void {\n const eventId = bare.eventId ?? nextEventId();\n // Buffer first so the replay layer captures every event, even\n // when there is no live subscriber. The buffered frame is\n // sanitizer-neutral (the per-subscription dispatch sanitizes\n // again on the wire so the second pass is idempotent — by\n // construction matching wraps are not re-wrapped).\n const bufferedFrame: ServerEventFrame = {\n v: '1',\n kind: 'event',\n eventId,\n subscriptionId: '__pending__',\n subject,\n type: bare.type,\n payload: bare.payload,\n };\n replayBuffer.push(subject, bufferedFrame);\n const subjectSubscribers = subjectIndex.get(subject);\n if (subjectSubscribers === undefined || subjectSubscribers.size === 0) return;\n for (const subscriptionId of subjectSubscribers) {\n const record = subscriptions.get(subscriptionId);\n if (record === undefined) continue;\n const sanitized = sanitizer.sanitize({ ...bufferedFrame, subscriptionId }, 'ws');\n validateAndDispatch(record, sanitized);\n record.lastEventId = sanitized.eventId;\n if (sanitized.eventId === eventId) continue;\n }\n void isEventFrame; // keep import warm\n }\n\n function emitLifecycle(\n subscriptionId: string,\n status: ServerLifecycleFrame['status'],\n reason?: string,\n ): void {\n const record = subscriptions.get(subscriptionId);\n if (record === undefined) return;\n const frame: ServerLifecycleFrame = {\n v: '1',\n kind: 'lifecycle',\n subscriptionId,\n status,\n ...(reason !== undefined ? { reason } : {}),\n };\n validateAndDispatch(record, frame);\n }\n\n function listForSubscriber(subscriberId: string): ReadonlyArray<WsSubscriptionSnapshot> {\n const subscriber = subscribers.get(subscriberId);\n if (subscriber === undefined) return Object.freeze([]);\n return Object.freeze(\n [...subscriber.subscriptions]\n .map((id) => subscriptions.get(id))\n .filter((entry): entry is SubscriptionRecord => entry !== undefined)\n .map(snapshotForRecord),\n );\n }\n\n function snapshotSubscription(subscriptionId: string): WsSubscriptionSnapshot | undefined {\n const record = subscriptions.get(subscriptionId);\n if (record === undefined) return undefined;\n return snapshotForRecord(record);\n }\n\n function size(): { readonly subscribers: number; readonly subscriptions: number } {\n return Object.freeze({\n subscribers: subscribers.size,\n subscriptions: subscriptions.size,\n });\n }\n\n function shutdown(): void {\n for (const subscription of subscriptions.values()) {\n const subscriber = subscribers.get(subscription.subscriberId);\n try {\n subscriber?.handle.send({\n v: '1',\n kind: 'lifecycle',\n subscriptionId: subscription.subscriptionId,\n status: 'aborted',\n reason: 'server-shutdown',\n });\n } catch {\n // swallow — we are tearing down.\n }\n }\n subscriptions.clear();\n subjectIndex.clear();\n subscribers.clear();\n }\n\n return Object.freeze({\n sanitizer,\n replayBuffer,\n registerSubscriber,\n subscribe,\n unsubscribe,\n emit,\n emitLifecycle,\n listForSubscriber,\n snapshotSubscription,\n size,\n shutdown,\n });\n}\n\nfunction snapshotForRecord(record: SubscriptionRecord): WsSubscriptionSnapshot {\n return Object.freeze({\n subscriptionId: record.subscriptionId,\n subject: record.subject,\n subjectKind: record.parsed.kind,\n subscriberId: record.subscriberId,\n tokenId: record.tokenId,\n createdAt: record.createdAt,\n lastEventId: record.lastEventId,\n });\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"dispatcher.js","names":["record: SubscriptionRecord","snapshotEventId: string | undefined","frame: ServerEventFrame","bufferedFrame: ServerEventFrame"],"sources":["../../src/ws/dispatcher.ts"],"sourcesContent":["/**\n * Per-server WebSocket dispatcher. Owns the in-memory subscription\n * map, fans out events to matching connections, applies the\n * delivery-layer commentary-phase trace sanitization, validates\n * outbound frames against the `@graphorin/protocol` schema, and\n * exposes a tiny `emit(subject, event)` surface route handlers\n * (and the agent / workflow runtimes) consume.\n *\n * @packageDocumentation\n */\n\nimport {\n isEventFrame,\n type ServerEventFrame,\n type ServerLifecycleFrame,\n type ServerMessage,\n ServerMessageSchema,\n} from '@graphorin/protocol';\nimport type { ParsedScope } from '@graphorin/security/auth';\nimport {\n createDeliveryCommentarySanitizer,\n type DeliveryCommentaryConfig,\n type DeliveryCommentarySanitizer,\n} from '../commentary/index.js';\nimport {\n createReplayBuffer,\n type ReplayBuffer,\n type ReplayBufferOptions,\n} from './replay-buffer.js';\nimport { isSubjectAllowed, type ParsedSubject, tryParseSubject } from './subjects.js';\n\n/**\n * Public configuration accepted by {@link createWsDispatcher}.\n *\n * @stable\n */\nexport interface WsDispatcherOptions {\n readonly commentary?: DeliveryCommentaryConfig;\n readonly replayBuffer?: ReplayBufferOptions;\n /**\n * Cap on outstanding events queued for an offline subscriber.\n * Defaults to the same value as the replay buffer per-subject cap.\n */\n readonly perConnectionQueueLimit?: number;\n readonly now?: () => number;\n /**\n * Logger sink for protocol violations + dropped frames. When\n * omitted, the dispatcher is silent (production wiring uses the\n * server's structured logger).\n */\n readonly onWarn?: (event: WsDispatcherWarning) => void;\n}\n\n/**\n * Discriminator surfaced to the optional warn sink.\n *\n * @stable\n */\nexport type WsDispatcherWarning =\n | {\n readonly kind: 'invalid-frame';\n readonly subscriptionId: string;\n readonly issues: ReadonlyArray<string>;\n }\n | { readonly kind: 'queue-overflow'; readonly subscriptionId: string }\n | { readonly kind: 'subject-rejected'; readonly subject: string; readonly reason: string };\n\n/**\n * Subscriber surface used by the dispatcher. Each WebSocket\n * connection wraps its `WSContext.send` in this interface so the\n * dispatcher does not depend on `@hono/node-ws` types directly.\n *\n * @stable\n */\nexport interface WsSubscriberHandle {\n readonly id: string;\n readonly tokenId: string;\n readonly grantedScopes: ReadonlyArray<ParsedScope>;\n /** Send a server frame; the dispatcher already validated it. */\n send(frame: ServerMessage): void;\n /** Close the underlying WebSocket with a Graphorin close code. */\n close(code: number, reason: string): void;\n /**\n * Optional buffered-byte sample. The dispatcher reads this on every\n * emit to detect sustained backpressure and close the connection\n * with the Graphorin `flow.throttled` code (4006) before the OS-\n * level send buffer collapses. Consumers backed by `@hono/node-ws`\n * can return the underlying `ws.bufferedAmount`. When the field is\n * not implemented, the dispatcher falls back to the per-connection\n * outstanding-event counter.\n */\n bufferedAmount?(): number;\n}\n\n/**\n * Snapshot of an active subscription. Surfaced via\n * {@link WsDispatcher.snapshotSubscription}.\n *\n * @stable\n */\nexport interface WsSubscriptionSnapshot {\n readonly subscriptionId: string;\n readonly subject: string;\n readonly subjectKind: ParsedSubject['kind'];\n readonly subscriberId: string;\n readonly tokenId: string;\n readonly createdAt: number;\n readonly lastEventId: string | undefined;\n}\n\n/**\n * Result of {@link WsDispatcher.subscribe}.\n *\n * @stable\n */\nexport type SubscribeResult =\n | {\n readonly ok: true;\n readonly subscription: WsSubscriptionSnapshot;\n readonly replayedCount: number;\n readonly snapshotEventId: string | undefined;\n }\n | {\n readonly ok: false;\n readonly reason:\n | 'subject-malformed'\n | 'subject-unknown'\n | 'subject-wildcard'\n | 'scope-denied';\n };\n\n/**\n * Public surface of the dispatcher.\n *\n * @stable\n */\nexport interface WsDispatcher {\n readonly sanitizer: DeliveryCommentarySanitizer;\n readonly replayBuffer: ReplayBuffer;\n /**\n * Register a subscriber (one per WebSocket connection).\n * `unregister` is called on close.\n */\n registerSubscriber(handle: WsSubscriberHandle): { unregister(): void };\n /**\n * Open a new subscription for an active subscriber. Returns either\n * the subscription snapshot + replayed-event count or a typed\n * failure reason the caller maps to the appropriate close code /\n * RPC error.\n */\n subscribe(input: {\n readonly subscriberId: string;\n readonly subject: string;\n readonly subscriptionId: string;\n readonly sinceEventId?: string;\n }): SubscribeResult;\n unsubscribe(subscriptionId: string): boolean;\n /**\n * Push an event into the dispatcher. Goes through the sanitizer,\n * validates against the protocol schema, persists into the replay\n * buffer, and fans out to every matching active subscription.\n */\n emit(subject: string, frame: BareEventFrame): void;\n /** Push a lifecycle frame to a single subscription. */\n emitLifecycle(\n subscriptionId: string,\n status: ServerLifecycleFrame['status'],\n reason?: string,\n ): void;\n /** List active subscriptions for a given subscriber (diagnostics). */\n listForSubscriber(subscriberId: string): ReadonlyArray<WsSubscriptionSnapshot>;\n snapshotSubscription(subscriptionId: string): WsSubscriptionSnapshot | undefined;\n size(): { readonly subscribers: number; readonly subscriptions: number };\n /** Clear in-memory state (used during graceful shutdown). */\n shutdown(): void;\n}\n\n/**\n * Frame argument accepted by {@link WsDispatcher.emit}. The\n * dispatcher fills in `subscriptionId`, `subject`, and `eventId`.\n *\n * @stable\n */\nexport interface BareEventFrame {\n readonly type: string;\n readonly payload: unknown;\n readonly eventId?: string;\n}\n\ninterface SubscriberRecord {\n readonly handle: WsSubscriberHandle;\n readonly subscriptions: Set<string>;\n outstandingEvents: number;\n closed: boolean;\n}\n\ninterface SubscriptionRecord {\n readonly subscriberId: string;\n /** IP-18: the authenticating principal's token id (not the connection id). */\n readonly tokenId: string;\n readonly subscriptionId: string;\n readonly subject: string;\n readonly parsed: ParsedSubject;\n readonly createdAt: number;\n lastEventId: string | undefined;\n}\n\n/**\n * Build a fresh dispatcher.\n *\n * @stable\n */\nexport function createWsDispatcher(options: WsDispatcherOptions = {}): WsDispatcher {\n const sanitizer = createDeliveryCommentarySanitizer(options.commentary);\n const replayBuffer = createReplayBuffer(options.replayBuffer ?? {});\n const now = options.now ?? Date.now;\n const onWarn = options.onWarn;\n const perConnectionQueueLimit = options.perConnectionQueueLimit ?? 1_000;\n const subscribers = new Map<string, SubscriberRecord>();\n const subscriptions = new Map<string, SubscriptionRecord>();\n const subjectIndex = new Map<string, Set<string>>();\n let eventCounter = 0;\n\n function indexSubject(subject: string, subscriptionId: string): void {\n let set = subjectIndex.get(subject);\n if (set === undefined) {\n set = new Set();\n subjectIndex.set(subject, set);\n }\n set.add(subscriptionId);\n }\n\n function deindexSubject(subject: string, subscriptionId: string): void {\n const set = subjectIndex.get(subject);\n if (set === undefined) return;\n set.delete(subscriptionId);\n if (set.size === 0) subjectIndex.delete(subject);\n }\n\n function nextEventId(): string {\n eventCounter += 1;\n return `evt-${now().toString(36)}-${eventCounter.toString(36)}`;\n }\n\n function dispatchToSubscriber(record: SubscriptionRecord, frame: ServerMessage): void {\n const subscriber = subscribers.get(record.subscriberId);\n if (subscriber === undefined || subscriber.closed) return;\n // Per-connection backpressure: track the outstanding-event counter\n // and consult the optional `bufferedAmount()` sample. The\n // dispatcher closes the connection with the Graphorin\n // `flow.throttled` code (4006) when either signal exceeds the\n // configured limit so the client can reconnect against the\n // replay buffer instead of accumulating an unbounded send queue.\n const buffered =\n typeof subscriber.handle.bufferedAmount === 'function'\n ? subscriber.handle.bufferedAmount()\n : 0;\n if (\n subscriber.outstandingEvents >= perConnectionQueueLimit ||\n buffered >= perConnectionQueueLimit * 1024\n ) {\n onWarn?.({ kind: 'queue-overflow', subscriptionId: record.subscriptionId });\n shutdownSubscriber(subscriber, 4006, 'flow.throttled');\n return;\n }\n subscriber.outstandingEvents += 1;\n try {\n subscriber.handle.send(frame);\n } catch (err) {\n onWarn?.({ kind: 'queue-overflow', subscriptionId: record.subscriptionId });\n shutdownSubscriber(subscriber, 4006, 'flow.throttled');\n void err;\n } finally {\n subscriber.outstandingEvents = Math.max(0, subscriber.outstandingEvents - 1);\n }\n }\n\n function shutdownSubscriber(subscriber: SubscriberRecord, code: number, reason: string): void {\n if (subscriber.closed) return;\n subscriber.closed = true;\n try {\n subscriber.handle.close(code, reason);\n } catch {\n // ignore - we are tearing the subscriber down.\n }\n for (const id of subscriber.subscriptions) {\n const sub = subscriptions.get(id);\n if (sub !== undefined) {\n deindexSubject(sub.subject, id);\n subscriptions.delete(id);\n }\n }\n subscriber.subscriptions.clear();\n subscribers.delete(subscriber.handle.id);\n }\n\n function validateAndDispatch(record: SubscriptionRecord, frame: ServerMessage): void {\n const validated = ServerMessageSchema.safeParse(frame);\n if (!validated.success) {\n onWarn?.({\n kind: 'invalid-frame',\n subscriptionId: record.subscriptionId,\n issues: validated.error.issues.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`),\n });\n return;\n }\n dispatchToSubscriber(record, validated.data);\n }\n\n function registerSubscriber(handle: WsSubscriberHandle): { unregister(): void } {\n subscribers.set(handle.id, {\n handle,\n subscriptions: new Set(),\n outstandingEvents: 0,\n closed: false,\n });\n return {\n unregister: () => {\n const record = subscribers.get(handle.id);\n if (record === undefined) return;\n for (const id of record.subscriptions) {\n const sub = subscriptions.get(id);\n if (sub !== undefined) {\n deindexSubject(sub.subject, id);\n subscriptions.delete(id);\n }\n }\n subscribers.delete(handle.id);\n },\n };\n }\n\n function subscribe(input: {\n readonly subscriberId: string;\n readonly subject: string;\n readonly subscriptionId: string;\n readonly sinceEventId?: string;\n }): SubscribeResult {\n const subscriber = subscribers.get(input.subscriberId);\n if (subscriber === undefined) {\n return { ok: false, reason: 'subject-unknown' };\n }\n const parseResult = tryParseSubject(input.subject);\n if (!parseResult.ok) {\n const reason = parseResult.reason;\n onWarn?.({ kind: 'subject-rejected', subject: input.subject, reason });\n if (reason === 'wildcard-not-supported') return { ok: false, reason: 'subject-wildcard' };\n if (reason === 'unknown-subject') return { ok: false, reason: 'subject-unknown' };\n return { ok: false, reason: 'subject-malformed' };\n }\n if (!isSubjectAllowed(subscriber.handle.grantedScopes, parseResult.subject)) {\n return { ok: false, reason: 'scope-denied' };\n }\n const record: SubscriptionRecord = {\n subscriberId: input.subscriberId,\n tokenId: subscriber.handle.tokenId,\n subscriptionId: input.subscriptionId,\n subject: input.subject,\n parsed: parseResult.subject,\n createdAt: now(),\n lastEventId: undefined,\n };\n subscriptions.set(input.subscriptionId, record);\n subscriber.subscriptions.add(input.subscriptionId);\n indexSubject(input.subject, input.subscriptionId);\n const replay = replayBuffer.replay(input.subject, input.sinceEventId);\n let replayed = 0;\n let snapshotEventId: string | undefined = replay.nextEventIdHint;\n for (const event of replay.events) {\n const frame: ServerEventFrame = {\n ...event,\n subscriptionId: input.subscriptionId,\n };\n validateAndDispatch(record, frame);\n record.lastEventId = frame.eventId;\n replayed += 1;\n }\n if (replay.droppedCount > 0) {\n validateAndDispatch(record, {\n v: '1',\n kind: 'replay-marker',\n subscriptionId: input.subscriptionId,\n eventId: snapshotEventId ?? `evt-replay-${now().toString(36)}`,\n droppedCount: replay.droppedCount,\n note: `Replay buffer dropped ${replay.droppedCount} events before resume.`,\n });\n snapshotEventId = snapshotEventId ?? `evt-replay-${now().toString(36)}`;\n }\n return {\n ok: true,\n subscription: snapshotForRecord(record),\n replayedCount: replayed,\n snapshotEventId,\n };\n }\n\n function unsubscribe(subscriptionId: string): boolean {\n const record = subscriptions.get(subscriptionId);\n if (record === undefined) return false;\n const subscriber = subscribers.get(record.subscriberId);\n subscriber?.subscriptions.delete(subscriptionId);\n deindexSubject(record.subject, subscriptionId);\n subscriptions.delete(subscriptionId);\n return true;\n }\n\n function emit(subject: string, bare: BareEventFrame): void {\n const eventId = bare.eventId ?? nextEventId();\n // Buffer first so the replay layer captures every event, even\n // when there is no live subscriber. The buffered frame is\n // sanitizer-neutral (the per-subscription dispatch sanitizes\n // again on the wire so the second pass is idempotent - by\n // construction matching wraps are not re-wrapped).\n const bufferedFrame: ServerEventFrame = {\n v: '1',\n kind: 'event',\n eventId,\n subscriptionId: '__pending__',\n subject,\n type: bare.type,\n payload: bare.payload,\n };\n replayBuffer.push(subject, bufferedFrame);\n const subjectSubscribers = subjectIndex.get(subject);\n if (subjectSubscribers === undefined || subjectSubscribers.size === 0) return;\n for (const subscriptionId of subjectSubscribers) {\n const record = subscriptions.get(subscriptionId);\n if (record === undefined) continue;\n const sanitized = sanitizer.sanitize({ ...bufferedFrame, subscriptionId }, 'ws');\n validateAndDispatch(record, sanitized);\n record.lastEventId = sanitized.eventId;\n if (sanitized.eventId === eventId) continue;\n }\n void isEventFrame; // keep import warm\n }\n\n function emitLifecycle(\n subscriptionId: string,\n status: ServerLifecycleFrame['status'],\n reason?: string,\n ): void {\n const record = subscriptions.get(subscriptionId);\n if (record === undefined) return;\n const frame: ServerLifecycleFrame = {\n v: '1',\n kind: 'lifecycle',\n subscriptionId,\n status,\n ...(reason !== undefined ? { reason } : {}),\n };\n validateAndDispatch(record, frame);\n }\n\n function listForSubscriber(subscriberId: string): ReadonlyArray<WsSubscriptionSnapshot> {\n const subscriber = subscribers.get(subscriberId);\n if (subscriber === undefined) return Object.freeze([]);\n return Object.freeze(\n [...subscriber.subscriptions]\n .map((id) => subscriptions.get(id))\n .filter((entry): entry is SubscriptionRecord => entry !== undefined)\n .map(snapshotForRecord),\n );\n }\n\n function snapshotSubscription(subscriptionId: string): WsSubscriptionSnapshot | undefined {\n const record = subscriptions.get(subscriptionId);\n if (record === undefined) return undefined;\n return snapshotForRecord(record);\n }\n\n function size(): { readonly subscribers: number; readonly subscriptions: number } {\n return Object.freeze({\n subscribers: subscribers.size,\n subscriptions: subscriptions.size,\n });\n }\n\n function shutdown(): void {\n for (const subscription of subscriptions.values()) {\n const subscriber = subscribers.get(subscription.subscriberId);\n try {\n subscriber?.handle.send({\n v: '1',\n kind: 'lifecycle',\n subscriptionId: subscription.subscriptionId,\n status: 'aborted',\n reason: 'server-shutdown',\n });\n } catch {\n // swallow - we are tearing down.\n }\n }\n subscriptions.clear();\n subjectIndex.clear();\n subscribers.clear();\n }\n\n return Object.freeze({\n sanitizer,\n replayBuffer,\n registerSubscriber,\n subscribe,\n unsubscribe,\n emit,\n emitLifecycle,\n listForSubscriber,\n snapshotSubscription,\n size,\n shutdown,\n });\n}\n\nfunction snapshotForRecord(record: SubscriptionRecord): WsSubscriptionSnapshot {\n return Object.freeze({\n subscriptionId: record.subscriptionId,\n subject: record.subject,\n subjectKind: record.parsed.kind,\n subscriberId: record.subscriberId,\n tokenId: record.tokenId,\n createdAt: record.createdAt,\n lastEventId: record.lastEventId,\n });\n}\n"],"mappings":"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"}
|
package/dist/ws/subjects.d.ts
CHANGED
|
@@ -60,7 +60,7 @@ declare function tryParseSubject(raw: string): ParseSubjectResult;
|
|
|
60
60
|
*/
|
|
61
61
|
declare function requiredScopeFor(subject: ParsedSubject): ParsedScope;
|
|
62
62
|
/**
|
|
63
|
-
* Compatibility shim
|
|
63
|
+
* Compatibility shim - re-exports `scopeMatches` so consumers don't
|
|
64
64
|
* have to learn the security package's surface.
|
|
65
65
|
*
|
|
66
66
|
* @stable
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"subjects.d.ts","names":[],"sources":["../../src/ws/subjects.ts"],"sourcesContent":[],"mappings":";;;;;;;;;;;KAwBY,aAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;KA0BA,kBAAA;;oBAC+B;;;;;;;;;;iBAiB3B,eAAA,eAA8B;;;;;;;;;iBAqD9B,gBAAA,UAA0B,gBAAgB;;;;;;;
|
|
1
|
+
{"version":3,"file":"subjects.d.ts","names":[],"sources":["../../src/ws/subjects.ts"],"sourcesContent":[],"mappings":";;;;;;;;;;;KAwBY,aAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;KA0BA,kBAAA;;oBAC+B;;;;;;;;;;iBAiB3B,eAAA,eAA8B;;;;;;;;;iBAqD9B,gBAAA,UAA0B,gBAAgB;;;;;;;iBA+B1C,gBAAA,UACL,cAAc,uBACd"}
|
package/dist/ws/subjects.js
CHANGED
|
@@ -86,8 +86,8 @@ function tryParseSubject(raw) {
|
|
|
86
86
|
*/
|
|
87
87
|
function requiredScopeFor(subject) {
|
|
88
88
|
switch (subject.kind) {
|
|
89
|
-
case "session-events": return parseScope(`
|
|
90
|
-
case "session-run-events": return parseScope(`
|
|
89
|
+
case "session-events": return parseScope(`sessions:read:${subject.sessionId}`);
|
|
90
|
+
case "session-run-events": return parseScope(`sessions:read:${subject.sessionId}`);
|
|
91
91
|
case "agent-run-events": return parseScope(`agents:read:${subject.agentId}`);
|
|
92
92
|
case "workflow-events": return parseScope(`workflows:read:${subject.workflowId}`);
|
|
93
93
|
case "workflow-run-events": return parseScope(`workflows:read:${subject.workflowId}`);
|
|
@@ -96,7 +96,7 @@ function requiredScopeFor(subject) {
|
|
|
96
96
|
}
|
|
97
97
|
}
|
|
98
98
|
/**
|
|
99
|
-
* Compatibility shim
|
|
99
|
+
* Compatibility shim - re-exports `scopeMatches` so consumers don't
|
|
100
100
|
* have to learn the security package's surface.
|
|
101
101
|
*
|
|
102
102
|
* @stable
|
package/dist/ws/subjects.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"subjects.js","names":[],"sources":["../../src/ws/subjects.ts"],"sourcesContent":["/**\n * Strict subject grammar enforced by the WS dispatcher. Subjects are\n * the multiplexing primitive over a single WS connection: every\n * subscription is keyed by a subject, every event carries the\n * subject it was emitted into, and the per-subject scope check\n * happens at subscription time.\n *\n * Wildcards are deferred to v0.2+
|
|
1
|
+
{"version":3,"file":"subjects.js","names":[],"sources":["../../src/ws/subjects.ts"],"sourcesContent":["/**\n * Strict subject grammar enforced by the WS dispatcher. Subjects are\n * the multiplexing primitive over a single WS connection: every\n * subscription is keyed by a subject, every event carries the\n * subject it was emitted into, and the per-subject scope check\n * happens at subscription time.\n *\n * Wildcards are deferred to v0.2+ - the dispatcher rejects any\n * subject containing `'*'` or `'#'` so the ACL surface stays\n * tractable.\n *\n * @packageDocumentation\n */\n\nimport type { ParsedScope } from '@graphorin/security/auth';\nimport { parseScope, scopeMatches } from '@graphorin/security/auth';\n\n/**\n * Discriminated union of every recognised subject form. Surfaced on\n * audit log entries + diagnostics; the wire still carries the raw\n * string.\n *\n * @stable\n */\nexport type ParsedSubject =\n | { readonly kind: 'session-events'; readonly sessionId: string }\n | {\n readonly kind: 'session-run-events';\n readonly sessionId: string;\n readonly runId: string;\n }\n | {\n readonly kind: 'agent-run-events';\n readonly agentId: string;\n readonly runId: string;\n }\n | { readonly kind: 'workflow-events'; readonly workflowId: string }\n | {\n readonly kind: 'workflow-run-events';\n readonly workflowId: string;\n readonly runId: string;\n }\n | { readonly kind: 'memory-conflicts' }\n | { readonly kind: 'audit-events' };\n\n/**\n * Result of {@link tryParseSubject}.\n *\n * @stable\n */\nexport type ParseSubjectResult =\n | { readonly ok: true; readonly subject: ParsedSubject }\n | {\n readonly ok: false;\n readonly reason: 'wildcard-not-supported' | 'unknown-subject' | 'malformed';\n };\n\nconst SESSION_EVENTS = /^session:([A-Za-z0-9_-]+)\\/events$/;\nconst SESSION_RUN_EVENTS = /^session:([A-Za-z0-9_-]+)\\/runs\\/([A-Za-z0-9_-]+)\\/events$/;\nconst AGENT_RUN_EVENTS = /^agent:([A-Za-z0-9_-]+)\\/runs\\/([A-Za-z0-9_-]+)\\/events$/;\nconst WORKFLOW_EVENTS = /^workflow:([A-Za-z0-9_-]+)\\/events$/;\nconst WORKFLOW_RUN_EVENTS = /^workflow:([A-Za-z0-9_-]+)\\/runs\\/([A-Za-z0-9_-]+)\\/events$/;\n\n/**\n * Parse a subject string into the {@link ParsedSubject} discriminator.\n *\n * @stable\n */\nexport function tryParseSubject(raw: string): ParseSubjectResult {\n if (typeof raw !== 'string' || raw.length === 0) {\n return { ok: false, reason: 'malformed' };\n }\n if (raw.includes('*') || raw.includes('#')) {\n return { ok: false, reason: 'wildcard-not-supported' };\n }\n if (raw === 'memory/conflicts') {\n return { ok: true, subject: { kind: 'memory-conflicts' } };\n }\n if (raw === 'audit/events') {\n return { ok: true, subject: { kind: 'audit-events' } };\n }\n let m = raw.match(SESSION_RUN_EVENTS);\n if (m !== null) {\n return {\n ok: true,\n subject: { kind: 'session-run-events', sessionId: m[1] ?? '', runId: m[2] ?? '' },\n };\n }\n m = raw.match(AGENT_RUN_EVENTS);\n if (m !== null) {\n return {\n ok: true,\n subject: { kind: 'agent-run-events', agentId: m[1] ?? '', runId: m[2] ?? '' },\n };\n }\n m = raw.match(SESSION_EVENTS);\n if (m !== null) {\n return { ok: true, subject: { kind: 'session-events', sessionId: m[1] ?? '' } };\n }\n m = raw.match(WORKFLOW_RUN_EVENTS);\n if (m !== null) {\n return {\n ok: true,\n subject: { kind: 'workflow-run-events', workflowId: m[1] ?? '', runId: m[2] ?? '' },\n };\n }\n m = raw.match(WORKFLOW_EVENTS);\n if (m !== null) {\n return { ok: true, subject: { kind: 'workflow-events', workflowId: m[1] ?? '' } };\n }\n return { ok: false, reason: 'unknown-subject' };\n}\n\n/**\n * Required scope literal for every subject kind, expressed as a\n * `ParsedScope`. The matcher `scopeMatches(granted, required)` uses\n * the standard wildcard rules from `@graphorin/security/auth`\n * (e.g. `agents:*` matches `agents:invoke:foo`).\n *\n * @stable\n */\nexport function requiredScopeFor(subject: ParsedSubject): ParsedScope {\n switch (subject.kind) {\n // periphery-10: session streams are READ-ONLY, so they gate on\n // `sessions:read:<sessionId>` - consistent with the SSE route's\n // `sessions:read` requirement (which the old `agents:invoke:<x>`\n // requirement silently stacked on top of), and the resource slot\n // is a sessionId under the sessions family instead of overloading\n // `agents:invoke` (whose slot is an agentId everywhere else).\n case 'session-events':\n return parseScope(`sessions:read:${subject.sessionId}`);\n case 'session-run-events':\n return parseScope(`sessions:read:${subject.sessionId}`);\n case 'agent-run-events':\n return parseScope(`agents:read:${subject.agentId}`);\n case 'workflow-events':\n return parseScope(`workflows:read:${subject.workflowId}`);\n case 'workflow-run-events':\n return parseScope(`workflows:read:${subject.workflowId}`);\n case 'memory-conflicts':\n return parseScope('memory:read');\n case 'audit-events':\n return parseScope('audit:read');\n }\n}\n\n/**\n * Compatibility shim - re-exports `scopeMatches` so consumers don't\n * have to learn the security package's surface.\n *\n * @stable\n */\nexport function isSubjectAllowed(\n granted: ReadonlyArray<ParsedScope>,\n subject: ParsedSubject,\n): boolean {\n const required = requiredScopeFor(subject);\n for (const scope of granted) {\n if (scopeMatches(scope, required)) return true;\n }\n return false;\n}\n"],"mappings":";;;AAyDA,MAAM,iBAAiB;AACvB,MAAM,qBAAqB;AAC3B,MAAM,mBAAmB;AACzB,MAAM,kBAAkB;AACxB,MAAM,sBAAsB;;;;;;AAO5B,SAAgB,gBAAgB,KAAiC;AAC/D,KAAI,OAAO,QAAQ,YAAY,IAAI,WAAW,EAC5C,QAAO;EAAE,IAAI;EAAO,QAAQ;EAAa;AAE3C,KAAI,IAAI,SAAS,IAAI,IAAI,IAAI,SAAS,IAAI,CACxC,QAAO;EAAE,IAAI;EAAO,QAAQ;EAA0B;AAExD,KAAI,QAAQ,mBACV,QAAO;EAAE,IAAI;EAAM,SAAS,EAAE,MAAM,oBAAoB;EAAE;AAE5D,KAAI,QAAQ,eACV,QAAO;EAAE,IAAI;EAAM,SAAS,EAAE,MAAM,gBAAgB;EAAE;CAExD,IAAI,IAAI,IAAI,MAAM,mBAAmB;AACrC,KAAI,MAAM,KACR,QAAO;EACL,IAAI;EACJ,SAAS;GAAE,MAAM;GAAsB,WAAW,EAAE,MAAM;GAAI,OAAO,EAAE,MAAM;GAAI;EAClF;AAEH,KAAI,IAAI,MAAM,iBAAiB;AAC/B,KAAI,MAAM,KACR,QAAO;EACL,IAAI;EACJ,SAAS;GAAE,MAAM;GAAoB,SAAS,EAAE,MAAM;GAAI,OAAO,EAAE,MAAM;GAAI;EAC9E;AAEH,KAAI,IAAI,MAAM,eAAe;AAC7B,KAAI,MAAM,KACR,QAAO;EAAE,IAAI;EAAM,SAAS;GAAE,MAAM;GAAkB,WAAW,EAAE,MAAM;GAAI;EAAE;AAEjF,KAAI,IAAI,MAAM,oBAAoB;AAClC,KAAI,MAAM,KACR,QAAO;EACL,IAAI;EACJ,SAAS;GAAE,MAAM;GAAuB,YAAY,EAAE,MAAM;GAAI,OAAO,EAAE,MAAM;GAAI;EACpF;AAEH,KAAI,IAAI,MAAM,gBAAgB;AAC9B,KAAI,MAAM,KACR,QAAO;EAAE,IAAI;EAAM,SAAS;GAAE,MAAM;GAAmB,YAAY,EAAE,MAAM;GAAI;EAAE;AAEnF,QAAO;EAAE,IAAI;EAAO,QAAQ;EAAmB;;;;;;;;;;AAWjD,SAAgB,iBAAiB,SAAqC;AACpE,SAAQ,QAAQ,MAAhB;EAOE,KAAK,iBACH,QAAO,WAAW,iBAAiB,QAAQ,YAAY;EACzD,KAAK,qBACH,QAAO,WAAW,iBAAiB,QAAQ,YAAY;EACzD,KAAK,mBACH,QAAO,WAAW,eAAe,QAAQ,UAAU;EACrD,KAAK,kBACH,QAAO,WAAW,kBAAkB,QAAQ,aAAa;EAC3D,KAAK,sBACH,QAAO,WAAW,kBAAkB,QAAQ,aAAa;EAC3D,KAAK,mBACH,QAAO,WAAW,cAAc;EAClC,KAAK,eACH,QAAO,WAAW,aAAa;;;;;;;;;AAUrC,SAAgB,iBACd,SACA,SACS;CACT,MAAM,WAAW,iBAAiB,QAAQ;AAC1C,MAAK,MAAM,SAAS,QAClB,KAAI,aAAa,OAAO,SAAS,CAAE,QAAO;AAE5C,QAAO"}
|
package/dist/ws/ticket.d.ts
CHANGED
|
@@ -63,7 +63,7 @@ interface WsTicketStore {
|
|
|
63
63
|
/**
|
|
64
64
|
* Build the default in-memory ticket store. Production deployments
|
|
65
65
|
* use exactly one store per process (multiple processes would each
|
|
66
|
-
* issue their own tickets
|
|
66
|
+
* issue their own tickets - there is no shared state because the
|
|
67
67
|
* single-user-per-process default applies).
|
|
68
68
|
*
|
|
69
69
|
* @stable
|
package/dist/ws/ticket.js
CHANGED
|
@@ -22,7 +22,7 @@ import { randomBytes } from "node:crypto";
|
|
|
22
22
|
/**
|
|
23
23
|
* Build the default in-memory ticket store. Production deployments
|
|
24
24
|
* use exactly one store per process (multiple processes would each
|
|
25
|
-
* issue their own tickets
|
|
25
|
+
* issue their own tickets - there is no shared state because the
|
|
26
26
|
* single-user-per-process default applies).
|
|
27
27
|
*
|
|
28
28
|
* @stable
|
package/dist/ws/ticket.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ticket.js","names":["ticket: WsTicket"],"sources":["../../src/ws/ticket.ts"],"sourcesContent":["/**\n * In-memory single-use ticket store for browser WebSocket clients.\n * The browser WebSocket API does not allow custom headers, so the\n * server issues a short-lived ticket via `POST /v1/session/ws-ticket`\n * (HTTP-authenticated); the client then attaches the value as a\n * second `Sec-WebSocket-Protocol` token (`ticket.<value>`) on the\n * upgrade request.\n *\n * Tickets:\n * - default 5-minute TTL,\n * - single-use (the first valid `consume()` call marks the ticket\n * consumed; every subsequent attempt rejects),\n * - in-memory only (server restart invalidates everything; CLI /\n * SDK clients use HTTP bearer instead so the lost ticket window\n * does not affect them).\n *\n * @packageDocumentation\n */\n\nimport { randomBytes } from 'node:crypto';\nimport type { ParsedScope } from '@graphorin/security/auth';\n\n/**\n * Stable shape returned by {@link WsTicketStore.issue}.\n *\n * @stable\n */\nexport interface WsTicket {\n readonly value: string;\n readonly expiresAt: number;\n readonly issuedAt: number;\n readonly tokenId: string;\n readonly scopes: ReadonlyArray<ParsedScope>;\n}\n\n/**\n * Stable result of {@link WsTicketStore.consume}.\n *\n * @stable\n */\nexport type WsTicketConsumeResult =\n | { readonly ok: true; readonly ticket: WsTicket }\n | { readonly ok: false; readonly reason: 'unknown' | 'consumed' | 'expired' };\n\n/**\n * Options accepted by {@link createWsTicketStore}.\n *\n * @stable\n */\nexport interface WsTicketStoreOptions {\n /** Default `300_000` (5 min). */\n readonly ttlMs?: number;\n /** Cap on the number of unconsumed tickets retained. Default `1_000`. */\n readonly maxOutstanding?: number;\n readonly now?: () => number;\n /**\n * Random-bytes generator. Tests pass a deterministic source so\n * ticket values are reproducible.\n */\n readonly randomBytes?: (length: number) => Uint8Array;\n}\n\n/**\n * Pluggable in-memory ticket store used by the WS upgrade handler +\n * the `POST /v1/session/ws-ticket` route.\n *\n * @stable\n */\nexport interface WsTicketStore {\n readonly ttlMs: number;\n issue(input: { readonly tokenId: string; readonly scopes: ReadonlyArray<ParsedScope> }): WsTicket;\n consume(value: string): WsTicketConsumeResult;\n /** Drop expired entries; called on every `consume()`. */\n prune(): number;\n size(): number;\n}\n\ninterface InternalEntry {\n readonly ticket: WsTicket;\n consumed: boolean;\n}\n\n/**\n * Build the default in-memory ticket store. Production deployments\n * use exactly one store per process (multiple processes would each\n * issue their own tickets
|
|
1
|
+
{"version":3,"file":"ticket.js","names":["ticket: WsTicket"],"sources":["../../src/ws/ticket.ts"],"sourcesContent":["/**\n * In-memory single-use ticket store for browser WebSocket clients.\n * The browser WebSocket API does not allow custom headers, so the\n * server issues a short-lived ticket via `POST /v1/session/ws-ticket`\n * (HTTP-authenticated); the client then attaches the value as a\n * second `Sec-WebSocket-Protocol` token (`ticket.<value>`) on the\n * upgrade request.\n *\n * Tickets:\n * - default 5-minute TTL,\n * - single-use (the first valid `consume()` call marks the ticket\n * consumed; every subsequent attempt rejects),\n * - in-memory only (server restart invalidates everything; CLI /\n * SDK clients use HTTP bearer instead so the lost ticket window\n * does not affect them).\n *\n * @packageDocumentation\n */\n\nimport { randomBytes } from 'node:crypto';\nimport type { ParsedScope } from '@graphorin/security/auth';\n\n/**\n * Stable shape returned by {@link WsTicketStore.issue}.\n *\n * @stable\n */\nexport interface WsTicket {\n readonly value: string;\n readonly expiresAt: number;\n readonly issuedAt: number;\n readonly tokenId: string;\n readonly scopes: ReadonlyArray<ParsedScope>;\n}\n\n/**\n * Stable result of {@link WsTicketStore.consume}.\n *\n * @stable\n */\nexport type WsTicketConsumeResult =\n | { readonly ok: true; readonly ticket: WsTicket }\n | { readonly ok: false; readonly reason: 'unknown' | 'consumed' | 'expired' };\n\n/**\n * Options accepted by {@link createWsTicketStore}.\n *\n * @stable\n */\nexport interface WsTicketStoreOptions {\n /** Default `300_000` (5 min). */\n readonly ttlMs?: number;\n /** Cap on the number of unconsumed tickets retained. Default `1_000`. */\n readonly maxOutstanding?: number;\n readonly now?: () => number;\n /**\n * Random-bytes generator. Tests pass a deterministic source so\n * ticket values are reproducible.\n */\n readonly randomBytes?: (length: number) => Uint8Array;\n}\n\n/**\n * Pluggable in-memory ticket store used by the WS upgrade handler +\n * the `POST /v1/session/ws-ticket` route.\n *\n * @stable\n */\nexport interface WsTicketStore {\n readonly ttlMs: number;\n issue(input: { readonly tokenId: string; readonly scopes: ReadonlyArray<ParsedScope> }): WsTicket;\n consume(value: string): WsTicketConsumeResult;\n /** Drop expired entries; called on every `consume()`. */\n prune(): number;\n size(): number;\n}\n\ninterface InternalEntry {\n readonly ticket: WsTicket;\n consumed: boolean;\n}\n\n/**\n * Build the default in-memory ticket store. Production deployments\n * use exactly one store per process (multiple processes would each\n * issue their own tickets - there is no shared state because the\n * single-user-per-process default applies).\n *\n * @stable\n */\nexport function createWsTicketStore(options: WsTicketStoreOptions = {}): WsTicketStore {\n const ttlMs = options.ttlMs ?? 5 * 60_000;\n const maxOutstanding = options.maxOutstanding ?? 1_000;\n const now = options.now ?? Date.now;\n const random = options.randomBytes ?? ((n: number) => randomBytes(n));\n const entries = new Map<string, InternalEntry>();\n\n function evictOldest(): void {\n const oldest = entries.keys().next().value;\n if (oldest !== undefined) entries.delete(oldest);\n }\n\n function prune(): number {\n const cutoff = now();\n let removed = 0;\n for (const [value, entry] of entries) {\n if (entry.ticket.expiresAt <= cutoff) {\n entries.delete(value);\n removed += 1;\n }\n }\n return removed;\n }\n\n function issue(input: {\n readonly tokenId: string;\n readonly scopes: ReadonlyArray<ParsedScope>;\n }): WsTicket {\n prune();\n while (entries.size >= maxOutstanding) {\n evictOldest();\n }\n const issuedAt = now();\n const value = encodeTicket(random(24));\n const ticket: WsTicket = {\n value,\n issuedAt,\n expiresAt: issuedAt + ttlMs,\n tokenId: input.tokenId,\n scopes: Object.freeze(input.scopes.slice()),\n };\n entries.set(value, { ticket, consumed: false });\n return ticket;\n }\n\n function consume(value: string): WsTicketConsumeResult {\n const entry = entries.get(value);\n if (entry === undefined) {\n prune();\n return { ok: false, reason: 'unknown' };\n }\n if (entry.ticket.expiresAt <= now()) {\n entries.delete(value);\n prune();\n return { ok: false, reason: 'expired' };\n }\n if (entry.consumed) {\n prune();\n return { ok: false, reason: 'consumed' };\n }\n entry.consumed = true;\n prune();\n return { ok: true, ticket: entry.ticket };\n }\n\n return Object.freeze({\n ttlMs,\n issue,\n consume,\n prune,\n size: () => entries.size,\n });\n}\n\nfunction encodeTicket(bytes: Uint8Array): string {\n // URL-safe base64 - Buffer.from is available in every supported\n // Node runtime; the implementation never touches the value once\n // encoded so the choice is purely cosmetic.\n return Buffer.from(bytes)\n .toString('base64')\n .replace(/\\+/g, '-')\n .replace(/\\//g, '_')\n .replace(/=+$/g, '');\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0FA,SAAgB,oBAAoB,UAAgC,EAAE,EAAiB;CACrF,MAAM,QAAQ,QAAQ,SAAS,IAAI;CACnC,MAAM,iBAAiB,QAAQ,kBAAkB;CACjD,MAAM,MAAM,QAAQ,OAAO,KAAK;CAChC,MAAM,SAAS,QAAQ,iBAAiB,MAAc,YAAY,EAAE;CACpE,MAAM,0BAAU,IAAI,KAA4B;CAEhD,SAAS,cAAoB;EAC3B,MAAM,SAAS,QAAQ,MAAM,CAAC,MAAM,CAAC;AACrC,MAAI,WAAW,OAAW,SAAQ,OAAO,OAAO;;CAGlD,SAAS,QAAgB;EACvB,MAAM,SAAS,KAAK;EACpB,IAAI,UAAU;AACd,OAAK,MAAM,CAAC,OAAO,UAAU,QAC3B,KAAI,MAAM,OAAO,aAAa,QAAQ;AACpC,WAAQ,OAAO,MAAM;AACrB,cAAW;;AAGf,SAAO;;CAGT,SAAS,MAAM,OAGF;AACX,SAAO;AACP,SAAO,QAAQ,QAAQ,eACrB,cAAa;EAEf,MAAM,WAAW,KAAK;EACtB,MAAM,QAAQ,aAAa,OAAO,GAAG,CAAC;EACtC,MAAMA,SAAmB;GACvB;GACA;GACA,WAAW,WAAW;GACtB,SAAS,MAAM;GACf,QAAQ,OAAO,OAAO,MAAM,OAAO,OAAO,CAAC;GAC5C;AACD,UAAQ,IAAI,OAAO;GAAE;GAAQ,UAAU;GAAO,CAAC;AAC/C,SAAO;;CAGT,SAAS,QAAQ,OAAsC;EACrD,MAAM,QAAQ,QAAQ,IAAI,MAAM;AAChC,MAAI,UAAU,QAAW;AACvB,UAAO;AACP,UAAO;IAAE,IAAI;IAAO,QAAQ;IAAW;;AAEzC,MAAI,MAAM,OAAO,aAAa,KAAK,EAAE;AACnC,WAAQ,OAAO,MAAM;AACrB,UAAO;AACP,UAAO;IAAE,IAAI;IAAO,QAAQ;IAAW;;AAEzC,MAAI,MAAM,UAAU;AAClB,UAAO;AACP,UAAO;IAAE,IAAI;IAAO,QAAQ;IAAY;;AAE1C,QAAM,WAAW;AACjB,SAAO;AACP,SAAO;GAAE,IAAI;GAAM,QAAQ,MAAM;GAAQ;;AAG3C,QAAO,OAAO,OAAO;EACnB;EACA;EACA;EACA;EACA,YAAY,QAAQ;EACrB,CAAC;;AAGJ,SAAS,aAAa,OAA2B;AAI/C,QAAO,OAAO,KAAK,MAAM,CACtB,SAAS,SAAS,CAClB,QAAQ,OAAO,IAAI,CACnB,QAAQ,OAAO,IAAI,CACnB,QAAQ,QAAQ,GAAG"}
|
package/dist/ws/upgrade.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"upgrade.d.ts","names":[],"sources":["../../src/ws/upgrade.ts"],"sourcesContent":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"upgrade.d.ts","names":[],"sources":["../../src/ws/upgrade.ts"],"sourcesContent":[],"mappings":";;;;;;;;;;;;AA2DA;;;AAQsB,UARL,gBAAA,CAQK;EAQJ,SAAA,UAAA,EAfK,YAeL;EAAe,SAAA,OAAA,EAdb,aAca;EA2BX;;;;;EAGnB,SAAA,QAAA,CAAA,EAtCmB,aAsCnB;EAAO;;;;;;;kBA9BQ;;;;;;;;;;;;;;;;;;;;;;;;;;iBA2BI,qBAAA,IACjB;aAAqB;aACf,mBACR,QAAQ"}
|
package/dist/ws/upgrade.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { version } from "../package.js";
|
|
1
2
|
import { ClientMessageSchema, RPC_ERROR_CODES, SUBPROTOCOL_NAME, closeCodeFor, isCancelledNotification, isInitializeRequest, isPingRequest, isRunCancelRequest, isSubscribeRequest, isUnsubscribeRequest, negotiateSubprotocol, parseTicketSubprotocol } from "@graphorin/protocol";
|
|
2
3
|
import { parseScope, scopeMatches } from "@graphorin/security/auth";
|
|
3
4
|
|
|
@@ -12,14 +13,14 @@ import { parseScope, scopeMatches } from "@graphorin/security/auth";
|
|
|
12
13
|
* ## Subprotocol + browser ticket flow
|
|
13
14
|
*
|
|
14
15
|
* The negotiated subprotocol is always {@link SUBPROTOCOL_NAME}
|
|
15
|
-
* (`graphorin.protocol.v1`)
|
|
16
|
+
* (`graphorin.protocol.v1`) - the canonical wire contract lives in
|
|
16
17
|
* `@graphorin/protocol`'s `subprotocol.ts`. Two auth paths exist:
|
|
17
18
|
*
|
|
18
19
|
* - **Bearer (non-browser):** the client sends `Authorization:
|
|
19
20
|
* Bearer <token>` and a single `Sec-WebSocket-Protocol:
|
|
20
21
|
* graphorin.protocol.v1` token.
|
|
21
22
|
* - **Ticket (browser):** the `WebSocket` constructor cannot set
|
|
22
|
-
* headers, so the browser client offers two subprotocol tokens
|
|
23
|
+
* headers, so the browser client offers two subprotocol tokens -
|
|
23
24
|
* `graphorin.protocol.v1` and `ticket.<value>`. The server echoes
|
|
24
25
|
* back only the canonical name (in `app.ts`'s `handleProtocols`),
|
|
25
26
|
* and {@link resolveUpgradeAuth} extracts the ticket via
|
|
@@ -100,7 +101,7 @@ async function createWsUpgradeEvents(c, options) {
|
|
|
100
101
|
sendRpcSuccess(ws, message.id, {
|
|
101
102
|
serverInfo: {
|
|
102
103
|
name: "graphorin-server",
|
|
103
|
-
version
|
|
104
|
+
version
|
|
104
105
|
},
|
|
105
106
|
capabilities: {
|
|
106
107
|
subscriptions: true,
|
|
@@ -251,7 +252,8 @@ async function resolveUpgradeAuth(c, options) {
|
|
|
251
252
|
reason: "auth.required"
|
|
252
253
|
};
|
|
253
254
|
const token = header.slice(7).trim();
|
|
254
|
-
const
|
|
255
|
+
const ip = c.get("state")?.clientIp;
|
|
256
|
+
const verified = await options.verifier.verify(token, ip !== void 0 ? { ip } : {});
|
|
255
257
|
if (!verified.ok) return {
|
|
256
258
|
kind: "denied",
|
|
257
259
|
reason: "auth.invalid"
|
|
@@ -269,7 +271,7 @@ async function resolveUpgradeAuth(c, options) {
|
|
|
269
271
|
}
|
|
270
272
|
/**
|
|
271
273
|
* IP-8: the `agents:invoke` scope required to cancel a run over the WS
|
|
272
|
-
* transport
|
|
274
|
+
* transport - the same requirement the REST `POST /runs/:runId/abort` route
|
|
273
275
|
* enforces. `scopeMatches` honours wildcards (`agents:*`, `admin:*`).
|
|
274
276
|
*/
|
|
275
277
|
const INVOKE_SCOPE = parseScope("agents:invoke");
|