@databricks/appkit 0.61.1 → 0.63.0
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/CLAUDE.md +1 -0
- package/dist/agents/databricks.d.ts.map +1 -1
- package/dist/agents/databricks.js.map +1 -1
- package/dist/agents/supervisor-api.d.ts.map +1 -1
- package/dist/agents/supervisor-api.js.map +1 -1
- package/dist/app/index.d.ts.map +1 -1
- package/dist/app/index.js.map +1 -1
- package/dist/appkit/package.js +1 -1
- package/dist/cache/index.d.ts.map +1 -1
- package/dist/cache/index.js.map +1 -1
- package/dist/cache/storage/memory.js.map +1 -1
- package/dist/cache/storage/persistent.js.map +1 -1
- package/dist/cli/commands/codemod/index.js.map +1 -1
- package/dist/cli/commands/codemod/on-plugins-ready.js.map +1 -1
- package/dist/cli/commands/docs.js.map +1 -1
- package/dist/cli/commands/doctor/bundle.js.map +1 -1
- package/dist/cli/commands/doctor/index.js.map +1 -1
- package/dist/cli/commands/doctor/report.js.map +1 -1
- package/dist/cli/commands/doctor/resolve-targets.js.map +1 -1
- package/dist/cli/commands/doctor/run.js.map +1 -1
- package/dist/cli/commands/generate-types.js.map +1 -1
- package/dist/cli/commands/lint.js.map +1 -1
- package/dist/cli/commands/plugin/add-resource/add-resource.js.map +1 -1
- package/dist/cli/commands/plugin/create/create.js.map +1 -1
- package/dist/cli/commands/plugin/create/prompt-resource.js.map +1 -1
- package/dist/cli/commands/plugin/create/scaffold.js.map +1 -1
- package/dist/cli/commands/plugin/index.js.map +1 -1
- package/dist/cli/commands/plugin/list/list.js.map +1 -1
- package/dist/cli/commands/plugin/promote/promote.js.map +1 -1
- package/dist/cli/commands/plugin/sync/sync.js.map +1 -1
- package/dist/cli/commands/plugin/validate/validate-manifest.js.map +1 -1
- package/dist/cli/commands/plugin/validate/validate.js.map +1 -1
- package/dist/cli/commands/registry/add.js.map +1 -1
- package/dist/cli/commands/registry/client.js.map +1 -1
- package/dist/cli/commands/registry/config-writer.js.map +1 -1
- package/dist/cli/commands/registry/env-reconcile.js.map +1 -1
- package/dist/cli/commands/registry/env-writer.js.map +1 -1
- package/dist/cli/commands/registry/index.js.map +1 -1
- package/dist/cli/commands/registry/info.js.map +1 -1
- package/dist/cli/commands/registry/list.js.map +1 -1
- package/dist/cli/commands/registry/requirements.js.map +1 -1
- package/dist/cli/commands/registry/server-register.js.map +1 -1
- package/dist/cli/commands/registry/workspace-picker.js.map +1 -1
- package/dist/cli/commands/setup.js.map +1 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/connectors/files/client.js.map +1 -1
- package/dist/connectors/lakebase/index.d.ts.map +1 -1
- package/dist/connectors/lakebase/index.js.map +1 -1
- package/dist/connectors/lakebase/pool-manager.d.ts.map +1 -1
- package/dist/connectors/lakebase/pool-manager.js.map +1 -1
- package/dist/connectors/lakebase/routing-pool.d.ts.map +1 -1
- package/dist/connectors/lakebase/routing-pool.js.map +1 -1
- package/dist/connectors/mcp/client.d.ts.map +1 -1
- package/dist/connectors/mcp/client.js.map +1 -1
- package/dist/connectors/sql-warehouse/client.js.map +1 -1
- package/dist/context/client-options.js.map +1 -1
- package/dist/context/execution-context.d.ts.map +1 -1
- package/dist/context/execution-context.js.map +1 -1
- package/dist/context/index.d.ts +1 -1
- package/dist/context/service-context.d.ts +55 -3
- package/dist/context/service-context.d.ts.map +1 -1
- package/dist/context/service-context.js.map +1 -1
- package/dist/core/agent/build-toolkit.js.map +1 -1
- package/dist/core/agent/load-agents.d.ts.map +1 -1
- package/dist/core/agent/load-agents.js.map +1 -1
- package/dist/core/agent/run-agent.d.ts.map +1 -1
- package/dist/core/agent/run-agent.js.map +1 -1
- package/dist/core/agent/toolkit-resolver.js.map +1 -1
- package/dist/core/agent/tools/define-tool.d.ts.map +1 -1
- package/dist/core/agent/tools/define-tool.js.map +1 -1
- package/dist/core/agent/tools/tool.d.ts.map +1 -1
- package/dist/core/agent/tools/tool.js.map +1 -1
- package/dist/core/agent/types.d.ts.map +1 -1
- package/dist/core/agent/types.js.map +1 -1
- package/dist/core/appkit.d.ts.map +1 -1
- package/dist/core/appkit.js.map +1 -1
- package/dist/core/lifecycle-manager.js.map +1 -1
- package/dist/core/plugin-context.d.ts +13 -0
- package/dist/core/plugin-context.d.ts.map +1 -1
- package/dist/core/plugin-context.js +12 -1
- package/dist/core/plugin-context.js.map +1 -1
- package/dist/errors/configuration.d.ts.map +1 -1
- package/dist/errors/configuration.js.map +1 -1
- package/dist/logging/logger.js.map +1 -1
- package/dist/logging/wide-event-emitter.js.map +1 -1
- package/dist/plugin/dev-reader.d.ts.map +1 -1
- package/dist/plugin/dev-reader.js.map +1 -1
- package/dist/plugin/interceptors/cache.js.map +1 -1
- package/dist/plugin/interceptors/retry.js.map +1 -1
- package/dist/plugin/interceptors/telemetry.js.map +1 -1
- package/dist/plugin/plugin.d.ts +2 -2
- package/dist/plugin/plugin.d.ts.map +1 -1
- package/dist/plugin/plugin.js.map +1 -1
- package/dist/plugins/agents/agents.d.ts.map +1 -1
- package/dist/plugins/agents/agents.js.map +1 -1
- package/dist/plugins/agents/event-translator.js.map +1 -1
- package/dist/plugins/agents/thread-store.js.map +1 -1
- package/dist/plugins/ai-search/ai-search.d.ts.map +1 -1
- package/dist/plugins/ai-search/ai-search.js.map +1 -1
- package/dist/plugins/analytics/analytics.d.ts.map +1 -1
- package/dist/plugins/analytics/analytics.js.map +1 -1
- package/dist/plugins/analytics/mv/cache.js.map +1 -1
- package/dist/plugins/analytics/mv/constants.js.map +1 -1
- package/dist/plugins/analytics/mv/formatters.js.map +1 -1
- package/dist/plugins/analytics/mv/metadata.js.map +1 -1
- package/dist/plugins/analytics/mv/registry.js.map +1 -1
- package/dist/plugins/analytics/mv/schemas.js.map +1 -1
- package/dist/plugins/analytics/query.js.map +1 -1
- package/dist/plugins/analytics/result-delivery.js.map +1 -1
- package/dist/plugins/files/helpers.js.map +1 -1
- package/dist/plugins/files/plugin.d.ts.map +1 -1
- package/dist/plugins/files/plugin.js.map +1 -1
- package/dist/plugins/files/types.d.ts.map +1 -1
- package/dist/plugins/genie/genie.d.ts.map +1 -1
- package/dist/plugins/genie/genie.js.map +1 -1
- package/dist/plugins/jobs/plugin.d.ts.map +1 -1
- package/dist/plugins/jobs/plugin.js.map +1 -1
- package/dist/plugins/jobs/types.d.ts.map +1 -1
- package/dist/plugins/lakebase/lakebase.d.ts.map +1 -1
- package/dist/plugins/lakebase/lakebase.js.map +1 -1
- package/dist/plugins/lakebase/types.d.ts.map +1 -1
- package/dist/plugins/server/base-server.js.map +1 -1
- package/dist/plugins/server/client-config-sanitizer.js.map +1 -1
- package/dist/plugins/server/index.d.ts.map +1 -1
- package/dist/plugins/server/index.js.map +1 -1
- package/dist/plugins/server/react-source-loc-vite-plugin.js.map +1 -1
- package/dist/plugins/server/remote-tunnel/remote-tunnel-controller.js.map +1 -1
- package/dist/plugins/server/remote-tunnel/remote-tunnel-manager.js.map +1 -1
- package/dist/plugins/server/static-server.js.map +1 -1
- package/dist/plugins/server/utils.js.map +1 -1
- package/dist/plugins/server/vite-dev-server.js.map +1 -1
- package/dist/plugins/serving/schema-filter.js.map +1 -1
- package/dist/plugins/serving/serving.d.ts.map +1 -1
- package/dist/plugins/serving/serving.js.map +1 -1
- package/dist/plugins/ui-variants/index.js.map +1 -1
- package/dist/registry/manifest-loader.d.ts.map +1 -1
- package/dist/registry/manifest-loader.js.map +1 -1
- package/dist/registry/resource-registry.d.ts.map +1 -1
- package/dist/registry/resource-registry.js.map +1 -1
- package/dist/registry/types.d.ts.map +1 -1
- package/dist/registry/types.js.map +1 -1
- package/dist/schemas/manifest.d.ts.map +1 -1
- package/dist/schemas/manifest.js.map +1 -1
- package/dist/schemas/metric-fqn.js.map +1 -1
- package/dist/shared/src/plugin.d.ts.map +1 -1
- package/dist/shared/src/schemas/manifest.d.ts.map +1 -1
- package/dist/shared/src/schemas/metric-fqn.js.map +1 -1
- package/dist/shared/src/schemas/metric-metadata-bundle.js.map +1 -1
- package/dist/shared/src/schemas/metric-source.js.map +1 -1
- package/dist/shared/src/sse/analytics.js.map +1 -1
- package/dist/shared/src/workspace-client/legacy.js.map +1 -1
- package/dist/shared/src/workspace-client/types.d.ts.map +1 -1
- package/dist/stream/sse-writer.js.map +1 -1
- package/dist/stream/stream-manager.d.ts.map +1 -1
- package/dist/stream/stream-manager.js.map +1 -1
- package/dist/stream/types.js.map +1 -1
- package/dist/telemetry/instrumentations.js.map +1 -1
- package/dist/telemetry/telemetry-manager.js.map +1 -1
- package/dist/telemetry/telemetry-provider.js.map +1 -1
- package/dist/telemetry/trace-sampler.js.map +1 -1
- package/dist/testing/expect-stream.d.ts +111 -0
- package/dist/testing/expect-stream.d.ts.map +1 -0
- package/dist/testing/expect-stream.js +154 -0
- package/dist/testing/expect-stream.js.map +1 -0
- package/dist/testing/fixtures.d.ts +257 -0
- package/dist/testing/fixtures.d.ts.map +1 -0
- package/dist/testing/fixtures.js +385 -0
- package/dist/testing/fixtures.js.map +1 -0
- package/dist/testing/index.d.ts +5 -0
- package/dist/testing/index.js +5 -0
- package/dist/testing/test-plugin-context.d.ts +158 -0
- package/dist/testing/test-plugin-context.d.ts.map +1 -0
- package/dist/testing/test-plugin-context.js +139 -0
- package/dist/testing/test-plugin-context.js.map +1 -0
- package/dist/type-generator/cache.js.map +1 -1
- package/dist/type-generator/index.js.map +1 -1
- package/dist/type-generator/migration.js.map +1 -1
- package/dist/type-generator/mv-registry/config.js.map +1 -1
- package/dist/type-generator/query-registry.js.map +1 -1
- package/dist/type-generator/serving/cache.js.map +1 -1
- package/dist/type-generator/serving/generator.js.map +1 -1
- package/dist/type-generator/serving/server-file-extractor.d.ts.map +1 -1
- package/dist/type-generator/serving/server-file-extractor.js.map +1 -1
- package/dist/type-generator/serving/vite-plugin.d.ts.map +1 -1
- package/dist/type-generator/serving/vite-plugin.js.map +1 -1
- package/dist/type-generator/vite-plugin.d.ts.map +1 -1
- package/dist/type-generator/vite-plugin.js.map +1 -1
- package/dist/workspace-client/legacy.js.map +1 -1
- package/dist/workspace-client/types.d.ts.map +1 -1
- package/docs/plugins/testing.md +245 -0
- package/llms.txt +1 -0
- package/package.json +15 -2
- package/sbom.cdx.json +1 -1
- package/skills/appkit-ui-variants/SKILL.md +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"stream-manager.js","names":[],"sources":["../../src/stream/stream-manager.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport { context } from \"@opentelemetry/api\";\nimport type { IAppResponse, StreamConfig } from \"shared\";\nimport { AppKitError } from \"../errors/base\";\nimport { ExecutionError } from \"../errors/execution\";\nimport { createLogger } from \"../logging/logger\";\nimport { EventRingBuffer } from \"./buffers\";\nimport { streamDefaults } from \"./defaults\";\nimport { SSEWriter } from \"./sse-writer\";\nimport { StreamRegistry } from \"./stream-registry\";\nimport { clearGraceTimer, clearRemovalTimer } from \"./timers\";\nimport { SSEErrorCode, type StreamEntry, type StreamOperation } from \"./types\";\nimport { StreamValidator } from \"./validator\";\n\nconst logger = createLogger(\"stream\");\n\n// main entry point for Server-Sent events streaming\nexport class StreamManager {\n private activeOperations: Set<StreamOperation>;\n private streamRegistry: StreamRegistry;\n private sseWriter: SSEWriter;\n private maxEventSize: number;\n private bufferTTL: number;\n private disconnectGraceMs: number;\n\n constructor(options?: StreamConfig) {\n this.streamRegistry = new StreamRegistry(\n options?.maxActiveStreams ?? streamDefaults.maxActiveStreams,\n );\n this.sseWriter = new SSEWriter();\n this.maxEventSize = options?.maxEventSize ?? streamDefaults.maxEventSize;\n this.bufferTTL = options?.bufferTTL ?? streamDefaults.bufferTTL;\n this.disconnectGraceMs =\n options?.disconnectGraceMs ?? streamDefaults.disconnectGraceMs;\n this.activeOperations = new Set();\n }\n\n // main streaming method - handles new connection and reconnection\n async stream(\n res: IAppResponse,\n handler: (signal: AbortSignal) => AsyncGenerator<any, void, unknown>,\n options?: StreamConfig,\n ownerKey?: string,\n ): Promise<void> {\n const { streamId } = options || {};\n\n // check if response is already closed\n if (res.writableEnded || res.destroyed) {\n return;\n }\n\n // setup SSE headers\n this.sseWriter.setupHeaders(res);\n\n // handle reconnection\n if (streamId && StreamValidator.validateStreamId(streamId)) {\n const existingStream = this.streamRegistry.get(streamId);\n if (existingStream) {\n // Enforce per-user binding: the stream's owner key must match the\n // requesting caller's owner key. This prevents cross-user stream\n // takeover via guessed/leaked stream IDs (the SSE registry was\n // previously a global lookup with no authorization step).\n if (existingStream.ownerKey !== ownerKey) {\n this.sseWriter.writeError(\n res,\n randomUUID(),\n \"Stream not found or access denied\",\n SSEErrorCode.STREAM_FORBIDDEN,\n );\n res.end();\n return;\n }\n return this._attachToExistingStream(res, existingStream, options);\n }\n }\n\n // if stream does not exist, create a new one\n return this._createNewStream(res, handler, options, ownerKey);\n }\n\n // abort all active operations\n abortAll(): void {\n // pending disconnect-grace timers are cleared by streamRegistry.clear() below\n this.activeOperations.forEach((operation) => {\n if (operation.heartbeat) clearInterval(operation.heartbeat);\n operation.controller.abort(\n new DOMException(\"Server shutdown\", \"AbortError\"),\n );\n });\n this.activeOperations.clear();\n this.streamRegistry.clear();\n }\n\n // get the number of active operations\n getActiveCount(): number {\n return this.activeOperations.size;\n }\n\n // attach to existing stream\n private async _attachToExistingStream(\n res: IAppResponse,\n streamEntry: StreamEntry,\n options?: StreamConfig,\n ): Promise<void> {\n // handle reconnection - replay missed events\n const lastEventId = res.req?.headers[\"last-event-id\"];\n\n if (StreamValidator.validateEventId(lastEventId)) {\n // cast to string after validation\n const validEventId = lastEventId as string;\n if (streamEntry.eventBuffer.has(validEventId)) {\n const missedEvents =\n streamEntry.eventBuffer.getEventsSince(validEventId);\n // broadcast missed events to client\n for (const event of missedEvents) {\n if (options?.userSignal?.aborted) break;\n this.sseWriter.writeBufferedEvent(res, event);\n }\n } else {\n // buffer overflow - send warning\n this.sseWriter.writeBufferOverflowWarning(res, validEventId);\n }\n }\n\n // a reconnecting client cancels the pending disconnect-grace abort\n clearGraceTimer(streamEntry);\n\n // a reconnect cancels any pending registry removal so the entry isn't\n // pulled out from under the newly attached client\n clearRemovalTimer(streamEntry);\n\n // add client to stream entry\n streamEntry.clients.add(res);\n streamEntry.lastAccess = Date.now();\n\n // start heartbeat\n const combinedSignal = this._combineSignals(\n streamEntry.abortController.signal,\n options?.userSignal,\n );\n const heartbeat = this.sseWriter.startHeartbeat(res, combinedSignal);\n\n // track operation\n const streamOperation: StreamOperation = {\n controller: streamEntry.abortController,\n type: \"stream\",\n heartbeat,\n };\n this.activeOperations.add(streamOperation);\n\n // handle client disconnect\n res.on(\"close\", () => {\n clearInterval(heartbeat);\n streamEntry.clients.delete(res);\n this.activeOperations.delete(streamOperation);\n\n // grace-abort instead of aborting now, so a reconnect can resume\n if (streamEntry.clients.size === 0 && !streamEntry.isCompleted) {\n this._scheduleGraceAbort(streamEntry);\n }\n\n // cleanup if stream is completed and no clients are connected\n this._scheduleRemovalAfterTTL(streamEntry);\n });\n\n // if stream is completed, close connection\n if (streamEntry.isCompleted) {\n res.end();\n // cleanup operation\n this.activeOperations.delete(streamOperation);\n clearInterval(heartbeat);\n // we deliberately ended this client, so drop it from the entry and\n // schedule removal now instead of relying on the transport's `close`\n // event (the close handler's later delete is a safe no-op)\n streamEntry.clients.delete(res);\n this._scheduleRemovalAfterTTL(streamEntry);\n }\n }\n private async _createNewStream(\n res: IAppResponse,\n handler: (signal: AbortSignal) => AsyncGenerator<any, void, unknown>,\n options?: StreamConfig,\n ownerKey?: string,\n ): Promise<void> {\n const streamId = options?.streamId ?? randomUUID();\n\n // abort stream if response is closed\n if (res.writableEnded || res.destroyed) {\n return;\n }\n\n const abortController = new AbortController();\n\n // create event buffer\n const eventBuffer = new EventRingBuffer(\n options?.bufferSize ?? streamDefaults.bufferSize,\n );\n\n // setup signals and heartbeat\n const combinedSignal = this._combineSignals(\n abortController.signal,\n options?.userSignal,\n );\n const heartbeat = this.sseWriter.startHeartbeat(res, combinedSignal);\n\n // capture the current trace context at stream creation time\n const traceContext = context.active();\n\n // abort stream if response is closed\n if (res.writableEnded || res.destroyed) {\n clearInterval(heartbeat);\n return;\n }\n\n // create stream entry\n const streamEntry: StreamEntry = {\n streamId,\n ownerKey,\n generator: handler(combinedSignal),\n eventBuffer,\n clients: new Set([res]),\n isCompleted: false,\n lastAccess: Date.now(),\n abortController,\n traceContext,\n };\n this.streamRegistry.add(streamEntry);\n\n // track operation\n const streamOperation: StreamOperation = {\n controller: abortController,\n type: \"stream\",\n heartbeat,\n };\n this.activeOperations.add(streamOperation);\n\n res.on(\"close\", () => {\n clearInterval(heartbeat);\n this.activeOperations.delete(streamOperation);\n streamEntry.clients.delete(res);\n\n // grace-abort instead of aborting now, so a reconnect can resume\n if (streamEntry.clients.size === 0 && !streamEntry.isCompleted) {\n this._scheduleGraceAbort(streamEntry);\n }\n\n // if the stream already finished (completed or errored), schedule\n // registry removal once the buffer TTL elapses so completed streams\n // don't accumulate in the registry forever\n this._scheduleRemovalAfterTTL(streamEntry);\n });\n\n await this._processGeneratorInBackground(streamEntry);\n\n // cleanup\n clearInterval(heartbeat);\n this.activeOperations.delete(streamOperation);\n }\n\n private async _processGeneratorInBackground(\n streamEntry: StreamEntry,\n ): Promise<void> {\n // run the entire generator processing within the captured trace context\n return context.with(streamEntry.traceContext, async () => {\n try {\n // retrieve all events from generator\n for await (const event of streamEntry.generator) {\n if (streamEntry.abortController.signal.aborted) break;\n const eventId = randomUUID();\n const eventData = JSON.stringify(event);\n\n // validate event size\n if (eventData.length > this.maxEventSize) {\n const errorMsg = `Event exceeds max size of ${this.maxEventSize} bytes`;\n const errorCode = SSEErrorCode.INVALID_REQUEST;\n // broadcast error to all connected clients\n this._broadcastErrorToClients(\n streamEntry,\n eventId,\n errorMsg,\n errorCode,\n );\n continue;\n }\n\n // buffer event for reconnection\n streamEntry.eventBuffer.add({\n id: eventId,\n type: event.type,\n data: eventData,\n timestamp: Date.now(),\n });\n\n // broadcast to all connected clients\n this._broadcastEventsToClients(streamEntry, eventId, event);\n streamEntry.lastAccess = Date.now();\n }\n\n this._finalizeStream(streamEntry);\n } catch (error) {\n // Two distinct messages: a *raw* one for server-side logs (full\n // detail, statement fragments, correlation IDs) and a *client*\n // one for the SSE payload (sanitized, stable, safe to render in\n // a UI). Mixing them leaks upstream wording to anyone connected\n // to the stream — see CWE-209.\n const rawMsg =\n error instanceof Error ? error.message : \"Internal server error\";\n const clientMsg =\n error instanceof AppKitError\n ? error.clientMessage\n : \"Internal server error\";\n // Upstream structured code (e.g. RESULT_TOO_LARGE_FOR_JSON_FALLBACK,\n // NOT_IMPLEMENTED). UI should branch on this, not on `error`.\n const upstreamCode =\n error instanceof ExecutionError ? error.errorCode : undefined;\n const errorEventId = randomUUID();\n const errorCode = this._categorizeError(error);\n\n // client cancellation is a normal control-flow signal, not a failure\n if (errorCode === SSEErrorCode.STREAM_ABORTED) {\n logger.info(\"Stream aborted by client (code=%s)\", errorCode);\n this._finalizeStream(streamEntry);\n return;\n }\n\n logger.error(\n \"Stream execution failed: %s (code=%s upstreamCode=%s)\",\n rawMsg,\n errorCode,\n upstreamCode ?? \"n/a\",\n );\n\n const payload: Record<string, unknown> = {\n error: clientMsg,\n code: errorCode,\n };\n if (upstreamCode) payload.errorCode = upstreamCode;\n\n // buffer error event\n streamEntry.eventBuffer.add({\n id: errorEventId,\n type: \"error\",\n data: JSON.stringify(payload),\n timestamp: Date.now(),\n });\n\n // send error event to all connected clients\n this._broadcastErrorToClients(\n streamEntry,\n errorEventId,\n clientMsg,\n errorCode,\n true,\n upstreamCode,\n );\n // the broadcast above already ended the connected clients; finalize\n // still detaches them so removal is scheduled without waiting on a\n // transport `close` event\n this._finalizeStream(streamEntry);\n }\n });\n }\n\n private _combineSignals(\n internalSignal?: AbortSignal,\n userSignal?: AbortSignal,\n ): AbortSignal {\n if (!userSignal) return internalSignal || new AbortController().signal;\n\n const signals = [internalSignal, userSignal].filter(\n Boolean,\n ) as AbortSignal[];\n const controller = new AbortController();\n\n signals.forEach((signal) => {\n if (signal?.aborted) {\n controller.abort(signal.reason);\n return;\n }\n\n signal?.addEventListener(\n \"abort\",\n () => {\n controller.abort(signal.reason);\n },\n { once: true },\n );\n });\n return controller.signal;\n }\n\n // broadcast events to all connected clients\n private _broadcastEventsToClients(\n streamEntry: StreamEntry,\n eventId: string,\n event: any,\n ): void {\n for (const client of streamEntry.clients) {\n if (!client.writableEnded) {\n this.sseWriter.writeEvent(client, eventId, event);\n }\n }\n }\n\n // broadcast error to all connected clients\n private _broadcastErrorToClients(\n streamEntry: StreamEntry,\n eventId: string,\n errorMessage: string,\n errorCode: SSEErrorCode,\n closeClients: boolean = false,\n upstreamCode?: string,\n ): void {\n for (const client of streamEntry.clients) {\n if (!client.writableEnded) {\n this.sseWriter.writeError(\n client,\n eventId,\n errorMessage,\n errorCode,\n upstreamCode,\n );\n if (closeClients) {\n client.end();\n }\n }\n }\n }\n\n private _finalizeStream(streamEntry: StreamEntry): void {\n streamEntry.isCompleted = true;\n clearGraceTimer(streamEntry);\n this._closeAllClients(streamEntry);\n this._scheduleRemovalAfterTTL(streamEntry);\n }\n\n // close all connected clients and remove them from the stream entry.\n // We are deliberately terminating these connections, so cleanup must not\n // depend on the transport emitting a `close` event for each client (it may\n // never fire). The close handlers' later `clients.delete(...)` calls remain\n // safe no-ops.\n private _closeAllClients(streamEntry: StreamEntry): void {\n for (const client of streamEntry.clients) {\n if (!client.writableEnded) {\n client.end();\n }\n }\n streamEntry.clients.clear();\n }\n\n // abort the generator after the grace window unless a client reconnects first\n private _scheduleGraceAbort(streamEntry: StreamEntry): void {\n // clear any existing timer to avoid stacking\n clearGraceTimer(streamEntry);\n\n const timer = setTimeout(() => {\n streamEntry.disconnectGraceTimer = undefined;\n if (streamEntry.clients.size === 0 && !streamEntry.isCompleted) {\n streamEntry.abortController.abort(\n new DOMException(\"Client disconnected (grace expired)\", \"AbortError\"),\n );\n }\n }, this.disconnectGraceMs);\n\n // never keep the process alive solely for a grace timer\n timer.unref?.();\n streamEntry.disconnectGraceTimer = timer;\n }\n\n // schedule registry removal once a finished (completed or errored) stream\n // has no connected clients. The event buffer stays available for\n // reconnect replay for `bufferTTL` after the last client disconnects;\n // after that the stream entry is removed from the registry so it can be\n // garbage collected.\n private _scheduleRemovalAfterTTL(streamEntry: StreamEntry): void {\n if (!streamEntry.isCompleted || streamEntry.clients.size > 0) {\n return;\n }\n\n // at most one removal timer per stream: rescheduling replaces any\n // pending timer instead of stacking a new one (each pending timer pins\n // the entry's buffer/generator/trace context for the full TTL)\n clearRemovalTimer(streamEntry);\n\n // mark the moment the stream became idle so a reconnect during the TTL\n // window (which refreshes lastAccess) makes the pending timer a no-op\n streamEntry.lastAccess = Date.now();\n\n streamEntry.removalTimer = setTimeout(() => {\n streamEntry.removalTimer = undefined;\n // safety net: no-op if a client reconnected during the TTL window;\n // a fresh removal is scheduled when that client disconnects\n if (\n streamEntry.clients.size === 0 &&\n Date.now() - streamEntry.lastAccess >= this.bufferTTL\n ) {\n this.streamRegistry.remove(streamEntry.streamId);\n }\n }, this.bufferTTL);\n\n // don't keep the process alive just to clean up finished streams\n streamEntry.removalTimer.unref?.();\n }\n\n private _categorizeError(error: unknown): SSEErrorCode {\n if (error instanceof Error) {\n const message = error.message.toLowerCase();\n if (message.includes(\"timeout\") || message.includes(\"timed out\")) {\n return SSEErrorCode.TIMEOUT;\n }\n\n if (message.includes(\"unavailable\") || message.includes(\"econnrefused\")) {\n return SSEErrorCode.TEMPORARY_UNAVAILABLE;\n }\n\n if (error.name === \"AbortError\") {\n return SSEErrorCode.STREAM_ABORTED;\n }\n\n // Defense-in-depth: upstream layers (SQL client, cache) may wrap an\n // AbortError into ExecutionError, losing `name` but keeping the message.\n if (\n message.includes(\"operation was aborted\") ||\n message.includes(\"the request was aborted\") ||\n message.includes(\"statement was canceled\")\n ) {\n return SSEErrorCode.STREAM_ABORTED;\n }\n\n // Detect upstream API errors (e.g., from Databricks SDK ApiError)\n if (\n \"statusCode\" in error &&\n typeof (error as any).statusCode === \"number\"\n ) {\n return SSEErrorCode.UPSTREAM_ERROR;\n }\n }\n\n return SSEErrorCode.INTERNAL_ERROR;\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"stream-manager.js","names":[],"sources":["../../src/stream/stream-manager.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\n\nimport { context } from \"@opentelemetry/api\";\nimport type { IAppResponse, StreamConfig } from \"shared\";\n\nimport { AppKitError } from \"../errors/base\";\nimport { ExecutionError } from \"../errors/execution\";\nimport { createLogger } from \"../logging/logger\";\nimport { EventRingBuffer } from \"./buffers\";\nimport { streamDefaults } from \"./defaults\";\nimport { SSEWriter } from \"./sse-writer\";\nimport { StreamRegistry } from \"./stream-registry\";\nimport { clearGraceTimer, clearRemovalTimer } from \"./timers\";\nimport { SSEErrorCode, type StreamEntry, type StreamOperation } from \"./types\";\nimport { StreamValidator } from \"./validator\";\n\nconst logger = createLogger(\"stream\");\n\n// main entry point for Server-Sent events streaming\nexport class StreamManager {\n private activeOperations: Set<StreamOperation>;\n private streamRegistry: StreamRegistry;\n private sseWriter: SSEWriter;\n private maxEventSize: number;\n private bufferTTL: number;\n private disconnectGraceMs: number;\n\n constructor(options?: StreamConfig) {\n this.streamRegistry = new StreamRegistry(\n options?.maxActiveStreams ?? streamDefaults.maxActiveStreams,\n );\n this.sseWriter = new SSEWriter();\n this.maxEventSize = options?.maxEventSize ?? streamDefaults.maxEventSize;\n this.bufferTTL = options?.bufferTTL ?? streamDefaults.bufferTTL;\n this.disconnectGraceMs =\n options?.disconnectGraceMs ?? streamDefaults.disconnectGraceMs;\n this.activeOperations = new Set();\n }\n\n // main streaming method - handles new connection and reconnection\n async stream(\n res: IAppResponse,\n handler: (signal: AbortSignal) => AsyncGenerator<any, void, unknown>,\n options?: StreamConfig,\n ownerKey?: string,\n ): Promise<void> {\n const { streamId } = options || {};\n\n // check if response is already closed\n if (res.writableEnded || res.destroyed) {\n return;\n }\n\n // setup SSE headers\n this.sseWriter.setupHeaders(res);\n\n // handle reconnection\n if (streamId && StreamValidator.validateStreamId(streamId)) {\n const existingStream = this.streamRegistry.get(streamId);\n if (existingStream) {\n // Enforce per-user binding: the stream's owner key must match the\n // requesting caller's owner key. This prevents cross-user stream\n // takeover via guessed/leaked stream IDs (the SSE registry was\n // previously a global lookup with no authorization step).\n if (existingStream.ownerKey !== ownerKey) {\n this.sseWriter.writeError(\n res,\n randomUUID(),\n \"Stream not found or access denied\",\n SSEErrorCode.STREAM_FORBIDDEN,\n );\n res.end();\n return;\n }\n return this._attachToExistingStream(res, existingStream, options);\n }\n }\n\n // if stream does not exist, create a new one\n return this._createNewStream(res, handler, options, ownerKey);\n }\n\n // abort all active operations\n abortAll(): void {\n // pending disconnect-grace timers are cleared by streamRegistry.clear() below\n this.activeOperations.forEach((operation) => {\n if (operation.heartbeat) clearInterval(operation.heartbeat);\n operation.controller.abort(\n new DOMException(\"Server shutdown\", \"AbortError\"),\n );\n });\n this.activeOperations.clear();\n this.streamRegistry.clear();\n }\n\n // get the number of active operations\n getActiveCount(): number {\n return this.activeOperations.size;\n }\n\n // attach to existing stream\n private async _attachToExistingStream(\n res: IAppResponse,\n streamEntry: StreamEntry,\n options?: StreamConfig,\n ): Promise<void> {\n // handle reconnection - replay missed events\n const lastEventId = res.req?.headers[\"last-event-id\"];\n\n if (StreamValidator.validateEventId(lastEventId)) {\n // cast to string after validation\n const validEventId = lastEventId as string;\n if (streamEntry.eventBuffer.has(validEventId)) {\n const missedEvents =\n streamEntry.eventBuffer.getEventsSince(validEventId);\n // broadcast missed events to client\n for (const event of missedEvents) {\n if (options?.userSignal?.aborted) break;\n this.sseWriter.writeBufferedEvent(res, event);\n }\n } else {\n // buffer overflow - send warning\n this.sseWriter.writeBufferOverflowWarning(res, validEventId);\n }\n }\n\n // a reconnecting client cancels the pending disconnect-grace abort\n clearGraceTimer(streamEntry);\n\n // a reconnect cancels any pending registry removal so the entry isn't\n // pulled out from under the newly attached client\n clearRemovalTimer(streamEntry);\n\n // add client to stream entry\n streamEntry.clients.add(res);\n streamEntry.lastAccess = Date.now();\n\n // start heartbeat\n const combinedSignal = this._combineSignals(\n streamEntry.abortController.signal,\n options?.userSignal,\n );\n const heartbeat = this.sseWriter.startHeartbeat(res, combinedSignal);\n\n // track operation\n const streamOperation: StreamOperation = {\n controller: streamEntry.abortController,\n type: \"stream\",\n heartbeat,\n };\n this.activeOperations.add(streamOperation);\n\n // handle client disconnect\n res.on(\"close\", () => {\n clearInterval(heartbeat);\n streamEntry.clients.delete(res);\n this.activeOperations.delete(streamOperation);\n\n // grace-abort instead of aborting now, so a reconnect can resume\n if (streamEntry.clients.size === 0 && !streamEntry.isCompleted) {\n this._scheduleGraceAbort(streamEntry);\n }\n\n // cleanup if stream is completed and no clients are connected\n this._scheduleRemovalAfterTTL(streamEntry);\n });\n\n // if stream is completed, close connection\n if (streamEntry.isCompleted) {\n res.end();\n // cleanup operation\n this.activeOperations.delete(streamOperation);\n clearInterval(heartbeat);\n // we deliberately ended this client, so drop it from the entry and\n // schedule removal now instead of relying on the transport's `close`\n // event (the close handler's later delete is a safe no-op)\n streamEntry.clients.delete(res);\n this._scheduleRemovalAfterTTL(streamEntry);\n }\n }\n private async _createNewStream(\n res: IAppResponse,\n handler: (signal: AbortSignal) => AsyncGenerator<any, void, unknown>,\n options?: StreamConfig,\n ownerKey?: string,\n ): Promise<void> {\n const streamId = options?.streamId ?? randomUUID();\n\n // abort stream if response is closed\n if (res.writableEnded || res.destroyed) {\n return;\n }\n\n const abortController = new AbortController();\n\n // create event buffer\n const eventBuffer = new EventRingBuffer(\n options?.bufferSize ?? streamDefaults.bufferSize,\n );\n\n // setup signals and heartbeat\n const combinedSignal = this._combineSignals(\n abortController.signal,\n options?.userSignal,\n );\n const heartbeat = this.sseWriter.startHeartbeat(res, combinedSignal);\n\n // capture the current trace context at stream creation time\n const traceContext = context.active();\n\n // abort stream if response is closed\n if (res.writableEnded || res.destroyed) {\n clearInterval(heartbeat);\n return;\n }\n\n // create stream entry\n const streamEntry: StreamEntry = {\n streamId,\n ownerKey,\n generator: handler(combinedSignal),\n eventBuffer,\n clients: new Set([res]),\n isCompleted: false,\n lastAccess: Date.now(),\n abortController,\n traceContext,\n };\n this.streamRegistry.add(streamEntry);\n\n // track operation\n const streamOperation: StreamOperation = {\n controller: abortController,\n type: \"stream\",\n heartbeat,\n };\n this.activeOperations.add(streamOperation);\n\n res.on(\"close\", () => {\n clearInterval(heartbeat);\n this.activeOperations.delete(streamOperation);\n streamEntry.clients.delete(res);\n\n // grace-abort instead of aborting now, so a reconnect can resume\n if (streamEntry.clients.size === 0 && !streamEntry.isCompleted) {\n this._scheduleGraceAbort(streamEntry);\n }\n\n // if the stream already finished (completed or errored), schedule\n // registry removal once the buffer TTL elapses so completed streams\n // don't accumulate in the registry forever\n this._scheduleRemovalAfterTTL(streamEntry);\n });\n\n await this._processGeneratorInBackground(streamEntry);\n\n // cleanup\n clearInterval(heartbeat);\n this.activeOperations.delete(streamOperation);\n }\n\n private async _processGeneratorInBackground(\n streamEntry: StreamEntry,\n ): Promise<void> {\n // run the entire generator processing within the captured trace context\n return context.with(streamEntry.traceContext, async () => {\n try {\n // retrieve all events from generator\n for await (const event of streamEntry.generator) {\n if (streamEntry.abortController.signal.aborted) break;\n const eventId = randomUUID();\n const eventData = JSON.stringify(event);\n\n // validate event size\n if (eventData.length > this.maxEventSize) {\n const errorMsg = `Event exceeds max size of ${this.maxEventSize} bytes`;\n const errorCode = SSEErrorCode.INVALID_REQUEST;\n // broadcast error to all connected clients\n this._broadcastErrorToClients(\n streamEntry,\n eventId,\n errorMsg,\n errorCode,\n );\n continue;\n }\n\n // buffer event for reconnection\n streamEntry.eventBuffer.add({\n id: eventId,\n type: event.type,\n data: eventData,\n timestamp: Date.now(),\n });\n\n // broadcast to all connected clients\n this._broadcastEventsToClients(streamEntry, eventId, event);\n streamEntry.lastAccess = Date.now();\n }\n\n this._finalizeStream(streamEntry);\n } catch (error) {\n // Two distinct messages: a *raw* one for server-side logs (full\n // detail, statement fragments, correlation IDs) and a *client*\n // one for the SSE payload (sanitized, stable, safe to render in\n // a UI). Mixing them leaks upstream wording to anyone connected\n // to the stream — see CWE-209.\n const rawMsg =\n error instanceof Error ? error.message : \"Internal server error\";\n const clientMsg =\n error instanceof AppKitError\n ? error.clientMessage\n : \"Internal server error\";\n // Upstream structured code (e.g. RESULT_TOO_LARGE_FOR_JSON_FALLBACK,\n // NOT_IMPLEMENTED). UI should branch on this, not on `error`.\n const upstreamCode =\n error instanceof ExecutionError ? error.errorCode : undefined;\n const errorEventId = randomUUID();\n const errorCode = this._categorizeError(error);\n\n // client cancellation is a normal control-flow signal, not a failure\n if (errorCode === SSEErrorCode.STREAM_ABORTED) {\n logger.info(\"Stream aborted by client (code=%s)\", errorCode);\n this._finalizeStream(streamEntry);\n return;\n }\n\n logger.error(\n \"Stream execution failed: %s (code=%s upstreamCode=%s)\",\n rawMsg,\n errorCode,\n upstreamCode ?? \"n/a\",\n );\n\n const payload: Record<string, unknown> = {\n error: clientMsg,\n code: errorCode,\n };\n if (upstreamCode) payload.errorCode = upstreamCode;\n\n // buffer error event\n streamEntry.eventBuffer.add({\n id: errorEventId,\n type: \"error\",\n data: JSON.stringify(payload),\n timestamp: Date.now(),\n });\n\n // send error event to all connected clients\n this._broadcastErrorToClients(\n streamEntry,\n errorEventId,\n clientMsg,\n errorCode,\n true,\n upstreamCode,\n );\n // the broadcast above already ended the connected clients; finalize\n // still detaches them so removal is scheduled without waiting on a\n // transport `close` event\n this._finalizeStream(streamEntry);\n }\n });\n }\n\n private _combineSignals(\n internalSignal?: AbortSignal,\n userSignal?: AbortSignal,\n ): AbortSignal {\n if (!userSignal) return internalSignal || new AbortController().signal;\n\n const signals = [internalSignal, userSignal].filter(\n Boolean,\n ) as AbortSignal[];\n const controller = new AbortController();\n\n signals.forEach((signal) => {\n if (signal?.aborted) {\n controller.abort(signal.reason);\n return;\n }\n\n signal?.addEventListener(\n \"abort\",\n () => {\n controller.abort(signal.reason);\n },\n { once: true },\n );\n });\n return controller.signal;\n }\n\n // broadcast events to all connected clients\n private _broadcastEventsToClients(\n streamEntry: StreamEntry,\n eventId: string,\n event: any,\n ): void {\n for (const client of streamEntry.clients) {\n if (!client.writableEnded) {\n this.sseWriter.writeEvent(client, eventId, event);\n }\n }\n }\n\n // broadcast error to all connected clients\n private _broadcastErrorToClients(\n streamEntry: StreamEntry,\n eventId: string,\n errorMessage: string,\n errorCode: SSEErrorCode,\n closeClients: boolean = false,\n upstreamCode?: string,\n ): void {\n for (const client of streamEntry.clients) {\n if (!client.writableEnded) {\n this.sseWriter.writeError(\n client,\n eventId,\n errorMessage,\n errorCode,\n upstreamCode,\n );\n if (closeClients) {\n client.end();\n }\n }\n }\n }\n\n private _finalizeStream(streamEntry: StreamEntry): void {\n streamEntry.isCompleted = true;\n clearGraceTimer(streamEntry);\n this._closeAllClients(streamEntry);\n this._scheduleRemovalAfterTTL(streamEntry);\n }\n\n // close all connected clients and remove them from the stream entry.\n // We are deliberately terminating these connections, so cleanup must not\n // depend on the transport emitting a `close` event for each client (it may\n // never fire). The close handlers' later `clients.delete(...)` calls remain\n // safe no-ops.\n private _closeAllClients(streamEntry: StreamEntry): void {\n for (const client of streamEntry.clients) {\n if (!client.writableEnded) {\n client.end();\n }\n }\n streamEntry.clients.clear();\n }\n\n // abort the generator after the grace window unless a client reconnects first\n private _scheduleGraceAbort(streamEntry: StreamEntry): void {\n // clear any existing timer to avoid stacking\n clearGraceTimer(streamEntry);\n\n const timer = setTimeout(() => {\n streamEntry.disconnectGraceTimer = undefined;\n if (streamEntry.clients.size === 0 && !streamEntry.isCompleted) {\n streamEntry.abortController.abort(\n new DOMException(\"Client disconnected (grace expired)\", \"AbortError\"),\n );\n }\n }, this.disconnectGraceMs);\n\n // never keep the process alive solely for a grace timer\n timer.unref?.();\n streamEntry.disconnectGraceTimer = timer;\n }\n\n // schedule registry removal once a finished (completed or errored) stream\n // has no connected clients. The event buffer stays available for\n // reconnect replay for `bufferTTL` after the last client disconnects;\n // after that the stream entry is removed from the registry so it can be\n // garbage collected.\n private _scheduleRemovalAfterTTL(streamEntry: StreamEntry): void {\n if (!streamEntry.isCompleted || streamEntry.clients.size > 0) {\n return;\n }\n\n // at most one removal timer per stream: rescheduling replaces any\n // pending timer instead of stacking a new one (each pending timer pins\n // the entry's buffer/generator/trace context for the full TTL)\n clearRemovalTimer(streamEntry);\n\n // mark the moment the stream became idle so a reconnect during the TTL\n // window (which refreshes lastAccess) makes the pending timer a no-op\n streamEntry.lastAccess = Date.now();\n\n streamEntry.removalTimer = setTimeout(() => {\n streamEntry.removalTimer = undefined;\n // safety net: no-op if a client reconnected during the TTL window;\n // a fresh removal is scheduled when that client disconnects\n if (\n streamEntry.clients.size === 0 &&\n Date.now() - streamEntry.lastAccess >= this.bufferTTL\n ) {\n this.streamRegistry.remove(streamEntry.streamId);\n }\n }, this.bufferTTL);\n\n // don't keep the process alive just to clean up finished streams\n streamEntry.removalTimer.unref?.();\n }\n\n private _categorizeError(error: unknown): SSEErrorCode {\n if (error instanceof Error) {\n const message = error.message.toLowerCase();\n if (message.includes(\"timeout\") || message.includes(\"timed out\")) {\n return SSEErrorCode.TIMEOUT;\n }\n\n if (message.includes(\"unavailable\") || message.includes(\"econnrefused\")) {\n return SSEErrorCode.TEMPORARY_UNAVAILABLE;\n }\n\n if (error.name === \"AbortError\") {\n return SSEErrorCode.STREAM_ABORTED;\n }\n\n // Defense-in-depth: upstream layers (SQL client, cache) may wrap an\n // AbortError into ExecutionError, losing `name` but keeping the message.\n if (\n message.includes(\"operation was aborted\") ||\n message.includes(\"the request was aborted\") ||\n message.includes(\"statement was canceled\")\n ) {\n return SSEErrorCode.STREAM_ABORTED;\n }\n\n // Detect upstream API errors (e.g., from Databricks SDK ApiError)\n if (\n \"statusCode\" in error &&\n typeof (error as any).statusCode === \"number\"\n ) {\n return SSEErrorCode.UPSTREAM_ERROR;\n }\n }\n\n return SSEErrorCode.INTERNAL_ERROR;\n }\n}\n"],"mappings":"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"}
|
package/dist/stream/types.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","names":[],"sources":["../../src/stream/types.ts"],"sourcesContent":["import type { Context } from \"@opentelemetry/api\";\nimport type { IAppResponse } from \"shared\";\nimport type { EventRingBuffer } from \"./buffers\";\n\nexport const SSEWarningCode = {\n BUFFER_OVERFLOW_RESTART: \"BUFFER_OVERFLOW_RESTART\",\n} as const satisfies Record<string, string>;\n\nexport type SSEWarningCode =\n (typeof SSEWarningCode)[keyof typeof SSEWarningCode];\n\nexport const SSEErrorCode = {\n TEMPORARY_UNAVAILABLE: \"TEMPORARY_UNAVAILABLE\",\n TIMEOUT: \"TIMEOUT\",\n INTERNAL_ERROR: \"INTERNAL_ERROR\",\n INVALID_REQUEST: \"INVALID_REQUEST\",\n STREAM_ABORTED: \"STREAM_ABORTED\",\n STREAM_EVICTED: \"STREAM_EVICTED\",\n STREAM_FORBIDDEN: \"STREAM_FORBIDDEN\",\n UPSTREAM_ERROR: \"UPSTREAM_ERROR\",\n} as const satisfies Record<string, string>;\n\nexport type SSEErrorCode = (typeof SSEErrorCode)[keyof typeof SSEErrorCode];\n\nexport interface SSEError {\n error: string;\n code: SSEErrorCode;\n /**\n * Upstream-domain structured code (e.g. `RESULT_TOO_LARGE_FOR_JSON_FALLBACK`,\n * `NOT_IMPLEMENTED`). UI code should branch on this instead of parsing\n * the human-readable `error` string.\n */\n errorCode?: string;\n}\n\nexport interface BufferedEvent {\n id: string;\n type: string;\n data: string;\n timestamp: number;\n}\n\nexport interface StreamEntry {\n streamId: string;\n /**\n * Identifier of the principal that created the stream (e.g. end-user ID\n * or service principal user ID). When set, only requests sharing the\n * same owner key may reconnect to the stream.\n */\n ownerKey?: string;\n generator: AsyncGenerator<any, void, unknown>;\n eventBuffer: EventRingBuffer;\n clients: Set<IAppResponse>;\n isCompleted: boolean;\n lastAccess: number;\n abortController: AbortController;\n traceContext: Context;\n // pending grace-window abort, set while the last client is disconnected\n disconnectGraceTimer?: NodeJS.Timeout;\n /**\n * Pending registry-removal timer. At most one removal timer exists per\n * stream; scheduling a new one clears any previous timer first.\n */\n removalTimer?: NodeJS.Timeout;\n}\n\nexport interface StreamOperation {\n controller: AbortController;\n type: \"query\" | \"stream\";\n heartbeat?: NodeJS.Timeout;\n}\n"],"mappings":";
|
|
1
|
+
{"version":3,"file":"types.js","names":[],"sources":["../../src/stream/types.ts"],"sourcesContent":["import type { Context } from \"@opentelemetry/api\";\nimport type { IAppResponse } from \"shared\";\n\nimport type { EventRingBuffer } from \"./buffers\";\n\nexport const SSEWarningCode = {\n BUFFER_OVERFLOW_RESTART: \"BUFFER_OVERFLOW_RESTART\",\n} as const satisfies Record<string, string>;\n\nexport type SSEWarningCode =\n (typeof SSEWarningCode)[keyof typeof SSEWarningCode];\n\nexport const SSEErrorCode = {\n TEMPORARY_UNAVAILABLE: \"TEMPORARY_UNAVAILABLE\",\n TIMEOUT: \"TIMEOUT\",\n INTERNAL_ERROR: \"INTERNAL_ERROR\",\n INVALID_REQUEST: \"INVALID_REQUEST\",\n STREAM_ABORTED: \"STREAM_ABORTED\",\n STREAM_EVICTED: \"STREAM_EVICTED\",\n STREAM_FORBIDDEN: \"STREAM_FORBIDDEN\",\n UPSTREAM_ERROR: \"UPSTREAM_ERROR\",\n} as const satisfies Record<string, string>;\n\nexport type SSEErrorCode = (typeof SSEErrorCode)[keyof typeof SSEErrorCode];\n\nexport interface SSEError {\n error: string;\n code: SSEErrorCode;\n /**\n * Upstream-domain structured code (e.g. `RESULT_TOO_LARGE_FOR_JSON_FALLBACK`,\n * `NOT_IMPLEMENTED`). UI code should branch on this instead of parsing\n * the human-readable `error` string.\n */\n errorCode?: string;\n}\n\nexport interface BufferedEvent {\n id: string;\n type: string;\n data: string;\n timestamp: number;\n}\n\nexport interface StreamEntry {\n streamId: string;\n /**\n * Identifier of the principal that created the stream (e.g. end-user ID\n * or service principal user ID). When set, only requests sharing the\n * same owner key may reconnect to the stream.\n */\n ownerKey?: string;\n generator: AsyncGenerator<any, void, unknown>;\n eventBuffer: EventRingBuffer;\n clients: Set<IAppResponse>;\n isCompleted: boolean;\n lastAccess: number;\n abortController: AbortController;\n traceContext: Context;\n // pending grace-window abort, set while the last client is disconnected\n disconnectGraceTimer?: NodeJS.Timeout;\n /**\n * Pending registry-removal timer. At most one removal timer exists per\n * stream; scheduling a new one clears any previous timer first.\n */\n removalTimer?: NodeJS.Timeout;\n}\n\nexport interface StreamOperation {\n controller: AbortController;\n type: \"query\" | \"stream\";\n heartbeat?: NodeJS.Timeout;\n}\n"],"mappings":";AAKA,MAAa,iBAAiB,EAC5B,yBAAyB,2BAC1B;AAKD,MAAa,eAAe;CAC1B,uBAAuB;CACvB,SAAS;CACT,gBAAgB;CAChB,iBAAiB;CACjB,gBAAgB;CAChB,gBAAgB;CAChB,kBAAkB;CAClB,gBAAgB;CACjB"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"instrumentations.js","names":[],"sources":["../../src/telemetry/instrumentations.ts"],"sourcesContent":["import type { Instrumentation } from \"@opentelemetry/instrumentation\";\nimport { ExpressInstrumentation } from \"@opentelemetry/instrumentation-express\";\nimport { HttpInstrumentation } from \"@opentelemetry/instrumentation-http\";\nimport { shouldIgnoreRequest } from \"../utils/path-exclusions\";\n\n/**\n * Registry of pre-configured instrumentations for common use cases.\n * These can be selectively registered by plugins that need them.\n *\n * While instrumentations are generally safe to re-register,\n * the recommended approach is to register them once in a corresponding plugin constructor.\n */\nexport const instrumentations: Record<string, Instrumentation> = {\n http: new HttpInstrumentation({\n // Filter out requests before creating spans - this is the most efficient approach\n ignoreIncomingRequestHook: shouldIgnoreRequest,\n\n applyCustomAttributesOnSpan(span: any, request: any) {\n let spanName: string | null = null;\n\n if (request.route) {\n const baseUrl = request.baseUrl || \"\";\n const url = request.url?.split(\"?\")[0] || \"\";\n const fullPath = baseUrl + url;\n if (fullPath) {\n spanName = `${request.method} ${fullPath}`;\n }\n } else if (request.url) {\n // No Express route (e.g., static assets) - use the raw URL path\n // Remove query string for cleaner trace names\n const path = request.url.split(\"?\")[0];\n spanName = `${request.method} ${path}`;\n }\n\n if (spanName) {\n span.updateName(spanName);\n }\n },\n }),\n express: new ExpressInstrumentation({\n requestHook: (span: any, info: any) => {\n const req = info.request;\n\n // Only update span name for route handlers (layerType: request_handler)\n // This ensures we're not renaming middleware spans\n if (info.layerType === \"request_handler\" && req.route) {\n // Combine baseUrl with url to get full path with actual parameter values\n // e.g., baseUrl=\"/api/analytics\" + url=\"/query/spend_data\" = \"/api/analytics/query/spend_data\"\n const baseUrl = req.baseUrl || \"\";\n const url = req.url?.split(\"?\")[0] || \"\";\n const fullPath = baseUrl + url;\n if (fullPath) {\n const spanName = `${req.method} ${fullPath}`;\n span.updateName(spanName);\n }\n }\n },\n }),\n};\n"],"mappings":";;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"instrumentations.js","names":[],"sources":["../../src/telemetry/instrumentations.ts"],"sourcesContent":["import type { Instrumentation } from \"@opentelemetry/instrumentation\";\nimport { ExpressInstrumentation } from \"@opentelemetry/instrumentation-express\";\nimport { HttpInstrumentation } from \"@opentelemetry/instrumentation-http\";\n\nimport { shouldIgnoreRequest } from \"../utils/path-exclusions\";\n\n/**\n * Registry of pre-configured instrumentations for common use cases.\n * These can be selectively registered by plugins that need them.\n *\n * While instrumentations are generally safe to re-register,\n * the recommended approach is to register them once in a corresponding plugin constructor.\n */\nexport const instrumentations: Record<string, Instrumentation> = {\n http: new HttpInstrumentation({\n // Filter out requests before creating spans - this is the most efficient approach\n ignoreIncomingRequestHook: shouldIgnoreRequest,\n\n applyCustomAttributesOnSpan(span: any, request: any) {\n let spanName: string | null = null;\n\n if (request.route) {\n const baseUrl = request.baseUrl || \"\";\n const url = request.url?.split(\"?\")[0] || \"\";\n const fullPath = baseUrl + url;\n if (fullPath) {\n spanName = `${request.method} ${fullPath}`;\n }\n } else if (request.url) {\n // No Express route (e.g., static assets) - use the raw URL path\n // Remove query string for cleaner trace names\n const path = request.url.split(\"?\")[0];\n spanName = `${request.method} ${path}`;\n }\n\n if (spanName) {\n span.updateName(spanName);\n }\n },\n }),\n express: new ExpressInstrumentation({\n requestHook: (span: any, info: any) => {\n const req = info.request;\n\n // Only update span name for route handlers (layerType: request_handler)\n // This ensures we're not renaming middleware spans\n if (info.layerType === \"request_handler\" && req.route) {\n // Combine baseUrl with url to get full path with actual parameter values\n // e.g., baseUrl=\"/api/analytics\" + url=\"/query/spend_data\" = \"/api/analytics/query/spend_data\"\n const baseUrl = req.baseUrl || \"\";\n const url = req.url?.split(\"?\")[0] || \"\";\n const fullPath = baseUrl + url;\n if (fullPath) {\n const spanName = `${req.method} ${fullPath}`;\n span.updateName(spanName);\n }\n }\n },\n }),\n};\n"],"mappings":";;;;;;;;;;;;AAaA,MAAa,mBAAoD;CAC/D,MAAM,IAAI,oBAAoB;EAE5B,2BAA2B;EAE3B,4BAA4B,MAAW,SAAc;GACnD,IAAI,WAA0B;AAE9B,OAAI,QAAQ,OAAO;IAGjB,MAAM,YAFU,QAAQ,WAAW,OACvB,QAAQ,KAAK,MAAM,IAAI,CAAC,MAAM;AAE1C,QAAI,SACF,YAAW,GAAG,QAAQ,OAAO,GAAG;cAEzB,QAAQ,KAAK;IAGtB,MAAM,OAAO,QAAQ,IAAI,MAAM,IAAI,CAAC;AACpC,eAAW,GAAG,QAAQ,OAAO,GAAG;;AAGlC,OAAI,SACF,MAAK,WAAW,SAAS;;EAG9B,CAAC;CACF,SAAS,IAAI,uBAAuB,EAClC,cAAc,MAAW,SAAc;EACrC,MAAM,MAAM,KAAK;AAIjB,MAAI,KAAK,cAAc,qBAAqB,IAAI,OAAO;GAKrD,MAAM,YAFU,IAAI,WAAW,OACnB,IAAI,KAAK,MAAM,IAAI,CAAC,MAAM;AAEtC,OAAI,UAAU;IACZ,MAAM,WAAW,GAAG,IAAI,OAAO,GAAG;AAClC,SAAK,WAAW,SAAS;;;IAIhC,CAAC;CACH"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"telemetry-manager.js","names":[],"sources":["../../src/telemetry/telemetry-manager.ts"],"sourcesContent":["import { getNodeAutoInstrumentations } from \"@opentelemetry/auto-instrumentations-node\";\nimport { OTLPLogExporter } from \"@opentelemetry/exporter-logs-otlp-proto\";\nimport { OTLPMetricExporter } from \"@opentelemetry/exporter-metrics-otlp-proto\";\nimport { OTLPTraceExporter } from \"@opentelemetry/exporter-trace-otlp-proto\";\nimport {\n type Instrumentation,\n registerInstrumentations as otelRegisterInstrumentations,\n} from \"@opentelemetry/instrumentation\";\nimport {\n detectResources,\n envDetector,\n hostDetector,\n processDetector,\n type Resource,\n resourceFromAttributes,\n} from \"@opentelemetry/resources\";\nimport { BatchLogRecordProcessor } from \"@opentelemetry/sdk-logs\";\nimport { PeriodicExportingMetricReader } from \"@opentelemetry/sdk-metrics\";\nimport { NodeSDK } from \"@opentelemetry/sdk-node\";\nimport {\n ATTR_SERVICE_NAME,\n ATTR_SERVICE_VERSION,\n} from \"@opentelemetry/semantic-conventions\";\nimport type { TelemetryOptions } from \"shared\";\nimport { createLogger } from \"../logging/logger\";\nimport { TelemetryProvider } from \"./telemetry-provider\";\nimport { AppKitSampler } from \"./trace-sampler\";\nimport type { TelemetryConfig } from \"./types\";\n\nconst logger = createLogger(\"telemetry\");\n\nexport class TelemetryManager {\n private static readonly DEFAULT_EXPORT_INTERVAL_MS = 10000;\n private static readonly DEFAULT_FALLBACK_APP_NAME = \"databricks-app\";\n\n private static instance?: TelemetryManager;\n private sdk?: NodeSDK;\n private shutdownPromise?: Promise<void>;\n\n /**\n * Create a scoped telemetry provider for a specific plugin.\n * The plugin's name will be used as the default tracer/meter name.\n * @param pluginName - The name of the plugin to create scoped telemetry for\n * @param telemetryConfig - The telemetry configuration for the plugin\n * @returns A scoped telemetry instance for the plugin\n */\n static getProvider(\n pluginName: string,\n telemetryConfig?: TelemetryOptions,\n ): TelemetryProvider {\n const globalManager = TelemetryManager.getInstance();\n return new TelemetryProvider(pluginName, globalManager, telemetryConfig);\n }\n\n private constructor() {}\n\n static getInstance(): TelemetryManager {\n if (!TelemetryManager.instance) {\n TelemetryManager.instance = new TelemetryManager();\n }\n return TelemetryManager.instance;\n }\n\n static initialize(config: Partial<TelemetryConfig> = {}): void {\n const instance = TelemetryManager.getInstance();\n instance._initialize(config);\n }\n\n private _initialize(config: Partial<TelemetryConfig>): void {\n if (this.sdk) return;\n\n if (!process.env.OTEL_EXPORTER_OTLP_ENDPOINT) {\n return;\n }\n\n try {\n this.sdk = new NodeSDK({\n resource: this.createResource(config),\n autoDetectResources: false,\n sampler: new AppKitSampler(),\n traceExporter: new OTLPTraceExporter({ headers: config.headers }),\n metricReaders: [\n new PeriodicExportingMetricReader({\n exporter: new OTLPMetricExporter({ headers: config.headers }),\n exportIntervalMillis:\n config.exportIntervalMs ||\n TelemetryManager.DEFAULT_EXPORT_INTERVAL_MS,\n }),\n ],\n logRecordProcessors: [\n new BatchLogRecordProcessor(\n new OTLPLogExporter({ headers: config.headers }),\n ),\n ],\n instrumentations: this.getDefaultInstrumentations(),\n });\n\n this.sdk.start();\n logger.debug(\"Initialized successfully\");\n } catch (error) {\n logger.error(\"Failed to initialize: %O\", error);\n }\n }\n\n /**\n * Register OpenTelemetry instrumentations.\n * Can be called at any time, but recommended to call in plugin constructor.\n * @param instrumentations - Array of OpenTelemetry instrumentations to register\n */\n registerInstrumentations(instrumentations: Instrumentation[]): void {\n otelRegisterInstrumentations({\n // global providers set by NodeSDK.start()\n instrumentations,\n });\n }\n\n private createResource(config: Partial<TelemetryConfig>): Resource {\n const serviceName =\n config.serviceName ||\n process.env.OTEL_SERVICE_NAME ||\n process.env.DATABRICKS_APP_NAME ||\n TelemetryManager.DEFAULT_FALLBACK_APP_NAME;\n const initialResource = resourceFromAttributes({\n [ATTR_SERVICE_NAME]: serviceName,\n [ATTR_SERVICE_VERSION]: config.serviceVersion ?? undefined,\n });\n const detectedResource = detectResources({\n detectors: [envDetector, hostDetector, processDetector],\n });\n return initialResource.merge(detectedResource);\n }\n\n private getDefaultInstrumentations(): Instrumentation[] {\n return [\n ...getNodeAutoInstrumentations({\n //\n // enabled as a part of the server plugin\n //\n \"@opentelemetry/instrumentation-http\": {\n enabled: false,\n },\n \"@opentelemetry/instrumentation-express\": {\n enabled: false,\n },\n //\n // reduce noise\n //\n \"@opentelemetry/instrumentation-fs\": {\n enabled: false,\n },\n \"@opentelemetry/instrumentation-dns\": {\n enabled: false,\n },\n \"@opentelemetry/instrumentation-net\": {\n enabled: false,\n },\n }),\n ];\n }\n\n /**\n * Flush and shut down the OpenTelemetry SDK.\n *\n * Idempotent: the SDK reference is cleared synchronously and concurrent\n * or repeated calls await the same in-flight flush. Awaited by the core\n * lifecycle manager during graceful shutdown — that manager owns the\n * process signal handlers, so telemetry no longer registers its own.\n */\n async shutdown(): Promise<void> {\n if (this.sdk) {\n const sdk = this.sdk;\n this.sdk = undefined;\n this.shutdownPromise = (async () => {\n try {\n await sdk.shutdown();\n } catch (error) {\n logger.error(\"Error shutting down: %O\", error);\n }\n })();\n }\n\n return this.shutdownPromise;\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"telemetry-manager.js","names":[],"sources":["../../src/telemetry/telemetry-manager.ts"],"sourcesContent":["import { getNodeAutoInstrumentations } from \"@opentelemetry/auto-instrumentations-node\";\nimport { OTLPLogExporter } from \"@opentelemetry/exporter-logs-otlp-proto\";\nimport { OTLPMetricExporter } from \"@opentelemetry/exporter-metrics-otlp-proto\";\nimport { OTLPTraceExporter } from \"@opentelemetry/exporter-trace-otlp-proto\";\nimport {\n type Instrumentation,\n registerInstrumentations as otelRegisterInstrumentations,\n} from \"@opentelemetry/instrumentation\";\nimport {\n detectResources,\n envDetector,\n hostDetector,\n processDetector,\n type Resource,\n resourceFromAttributes,\n} from \"@opentelemetry/resources\";\nimport { BatchLogRecordProcessor } from \"@opentelemetry/sdk-logs\";\nimport { PeriodicExportingMetricReader } from \"@opentelemetry/sdk-metrics\";\nimport { NodeSDK } from \"@opentelemetry/sdk-node\";\nimport {\n ATTR_SERVICE_NAME,\n ATTR_SERVICE_VERSION,\n} from \"@opentelemetry/semantic-conventions\";\nimport type { TelemetryOptions } from \"shared\";\n\nimport { createLogger } from \"../logging/logger\";\nimport { TelemetryProvider } from \"./telemetry-provider\";\nimport { AppKitSampler } from \"./trace-sampler\";\nimport type { TelemetryConfig } from \"./types\";\n\nconst logger = createLogger(\"telemetry\");\n\nexport class TelemetryManager {\n private static readonly DEFAULT_EXPORT_INTERVAL_MS = 10000;\n private static readonly DEFAULT_FALLBACK_APP_NAME = \"databricks-app\";\n\n private static instance?: TelemetryManager;\n private sdk?: NodeSDK;\n private shutdownPromise?: Promise<void>;\n\n /**\n * Create a scoped telemetry provider for a specific plugin.\n * The plugin's name will be used as the default tracer/meter name.\n * @param pluginName - The name of the plugin to create scoped telemetry for\n * @param telemetryConfig - The telemetry configuration for the plugin\n * @returns A scoped telemetry instance for the plugin\n */\n static getProvider(\n pluginName: string,\n telemetryConfig?: TelemetryOptions,\n ): TelemetryProvider {\n const globalManager = TelemetryManager.getInstance();\n return new TelemetryProvider(pluginName, globalManager, telemetryConfig);\n }\n\n private constructor() {}\n\n static getInstance(): TelemetryManager {\n if (!TelemetryManager.instance) {\n TelemetryManager.instance = new TelemetryManager();\n }\n return TelemetryManager.instance;\n }\n\n static initialize(config: Partial<TelemetryConfig> = {}): void {\n const instance = TelemetryManager.getInstance();\n instance._initialize(config);\n }\n\n private _initialize(config: Partial<TelemetryConfig>): void {\n if (this.sdk) return;\n\n if (!process.env.OTEL_EXPORTER_OTLP_ENDPOINT) {\n return;\n }\n\n try {\n this.sdk = new NodeSDK({\n resource: this.createResource(config),\n autoDetectResources: false,\n sampler: new AppKitSampler(),\n traceExporter: new OTLPTraceExporter({ headers: config.headers }),\n metricReaders: [\n new PeriodicExportingMetricReader({\n exporter: new OTLPMetricExporter({ headers: config.headers }),\n exportIntervalMillis:\n config.exportIntervalMs ||\n TelemetryManager.DEFAULT_EXPORT_INTERVAL_MS,\n }),\n ],\n logRecordProcessors: [\n new BatchLogRecordProcessor(\n new OTLPLogExporter({ headers: config.headers }),\n ),\n ],\n instrumentations: this.getDefaultInstrumentations(),\n });\n\n this.sdk.start();\n logger.debug(\"Initialized successfully\");\n } catch (error) {\n logger.error(\"Failed to initialize: %O\", error);\n }\n }\n\n /**\n * Register OpenTelemetry instrumentations.\n * Can be called at any time, but recommended to call in plugin constructor.\n * @param instrumentations - Array of OpenTelemetry instrumentations to register\n */\n registerInstrumentations(instrumentations: Instrumentation[]): void {\n otelRegisterInstrumentations({\n // global providers set by NodeSDK.start()\n instrumentations,\n });\n }\n\n private createResource(config: Partial<TelemetryConfig>): Resource {\n const serviceName =\n config.serviceName ||\n process.env.OTEL_SERVICE_NAME ||\n process.env.DATABRICKS_APP_NAME ||\n TelemetryManager.DEFAULT_FALLBACK_APP_NAME;\n const initialResource = resourceFromAttributes({\n [ATTR_SERVICE_NAME]: serviceName,\n [ATTR_SERVICE_VERSION]: config.serviceVersion ?? undefined,\n });\n const detectedResource = detectResources({\n detectors: [envDetector, hostDetector, processDetector],\n });\n return initialResource.merge(detectedResource);\n }\n\n private getDefaultInstrumentations(): Instrumentation[] {\n return [\n ...getNodeAutoInstrumentations({\n //\n // enabled as a part of the server plugin\n //\n \"@opentelemetry/instrumentation-http\": {\n enabled: false,\n },\n \"@opentelemetry/instrumentation-express\": {\n enabled: false,\n },\n //\n // reduce noise\n //\n \"@opentelemetry/instrumentation-fs\": {\n enabled: false,\n },\n \"@opentelemetry/instrumentation-dns\": {\n enabled: false,\n },\n \"@opentelemetry/instrumentation-net\": {\n enabled: false,\n },\n }),\n ];\n }\n\n /**\n * Flush and shut down the OpenTelemetry SDK.\n *\n * Idempotent: the SDK reference is cleared synchronously and concurrent\n * or repeated calls await the same in-flight flush. Awaited by the core\n * lifecycle manager during graceful shutdown — that manager owns the\n * process signal handlers, so telemetry no longer registers its own.\n */\n async shutdown(): Promise<void> {\n if (this.sdk) {\n const sdk = this.sdk;\n this.sdk = undefined;\n this.shutdownPromise = (async () => {\n try {\n await sdk.shutdown();\n } catch (error) {\n logger.error(\"Error shutting down: %O\", error);\n }\n })();\n }\n\n return this.shutdownPromise;\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;AA8BA,MAAM,SAAS,aAAa,YAAY;AAExC,IAAa,mBAAb,MAAa,iBAAiB;CAC5B,OAAwB,6BAA6B;CACrD,OAAwB,4BAA4B;CAEpD,OAAe;CACf,AAAQ;CACR,AAAQ;;;;;;;;CASR,OAAO,YACL,YACA,iBACmB;AAEnB,SAAO,IAAI,kBAAkB,YADP,iBAAiB,aAAa,EACI,gBAAgB;;CAG1E,AAAQ,cAAc;CAEtB,OAAO,cAAgC;AACrC,MAAI,CAAC,iBAAiB,SACpB,kBAAiB,WAAW,IAAI,kBAAkB;AAEpD,SAAO,iBAAiB;;CAG1B,OAAO,WAAW,SAAmC,EAAE,EAAQ;AAE7D,EADiB,iBAAiB,aAAa,CACtC,YAAY,OAAO;;CAG9B,AAAQ,YAAY,QAAwC;AAC1D,MAAI,KAAK,IAAK;AAEd,MAAI,CAAC,QAAQ,IAAI,4BACf;AAGF,MAAI;AACF,QAAK,MAAM,IAAI,QAAQ;IACrB,UAAU,KAAK,eAAe,OAAO;IACrC,qBAAqB;IACrB,SAAS,IAAI,eAAe;IAC5B,eAAe,IAAI,kBAAkB,EAAE,SAAS,OAAO,SAAS,CAAC;IACjE,eAAe,CACb,IAAI,8BAA8B;KAChC,UAAU,IAAI,mBAAmB,EAAE,SAAS,OAAO,SAAS,CAAC;KAC7D,sBACE,OAAO,oBACP,iBAAiB;KACpB,CAAC,CACH;IACD,qBAAqB,CACnB,IAAI,wBACF,IAAI,gBAAgB,EAAE,SAAS,OAAO,SAAS,CAAC,CACjD,CACF;IACD,kBAAkB,KAAK,4BAA4B;IACpD,CAAC;AAEF,QAAK,IAAI,OAAO;AAChB,UAAO,MAAM,2BAA2B;WACjC,OAAO;AACd,UAAO,MAAM,4BAA4B,MAAM;;;;;;;;CASnD,yBAAyB,kBAA2C;AAClE,2BAA6B,EAE3B,kBACD,CAAC;;CAGJ,AAAQ,eAAe,QAA4C;EACjE,MAAM,cACJ,OAAO,eACP,QAAQ,IAAI,qBACZ,QAAQ,IAAI,uBACZ,iBAAiB;EACnB,MAAM,kBAAkB,uBAAuB;IAC5C,oBAAoB;IACpB,uBAAuB,OAAO,kBAAkB;GAClD,CAAC;EACF,MAAM,mBAAmB,gBAAgB,EACvC,WAAW;GAAC;GAAa;GAAc;GAAgB,EACxD,CAAC;AACF,SAAO,gBAAgB,MAAM,iBAAiB;;CAGhD,AAAQ,6BAAgD;AACtD,SAAO,CACL,GAAG,4BAA4B;GAI7B,uCAAuC,EACrC,SAAS,OACV;GACD,0CAA0C,EACxC,SAAS,OACV;GAID,qCAAqC,EACnC,SAAS,OACV;GACD,sCAAsC,EACpC,SAAS,OACV;GACD,sCAAsC,EACpC,SAAS,OACV;GACF,CAAC,CACH;;;;;;;;;;CAWH,MAAM,WAA0B;AAC9B,MAAI,KAAK,KAAK;GACZ,MAAM,MAAM,KAAK;AACjB,QAAK,MAAM;AACX,QAAK,mBAAmB,YAAY;AAClC,QAAI;AACF,WAAM,IAAI,UAAU;aACb,OAAO;AACd,YAAO,MAAM,2BAA2B,MAAM;;OAE9C;;AAGN,SAAO,KAAK"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"telemetry-provider.js","names":[],"sources":["../../src/telemetry/telemetry-provider.ts"],"sourcesContent":["import type { Meter, Span, SpanOptions, Tracer } from \"@opentelemetry/api\";\nimport { metrics, trace } from \"@opentelemetry/api\";\nimport { type Logger, type LogRecord, logs } from \"@opentelemetry/api-logs\";\nimport type { Instrumentation } from \"@opentelemetry/instrumentation\";\nimport type { TelemetryOptions } from \"shared\";\nimport {\n normalizeTelemetryOptions,\n type TelemetryProviderConfig,\n} from \"./config\";\nimport { NOOP_LOGGER, NOOP_METER, NOOP_TRACER } from \"./noop\";\nimport type { TelemetryManager } from \"./telemetry-manager\";\nimport type { InstrumentConfig, ITelemetry } from \"./types\";\n\n/**\n * Scoped telemetry instance for specific plugins and other classes.\n * Automatically uses the plugin name as the default tracer/meter name.\n */\nexport class TelemetryProvider implements ITelemetry {\n private readonly pluginName: string;\n private readonly globalManager: TelemetryManager;\n private readonly config: TelemetryProviderConfig;\n\n constructor(\n pluginName: string,\n globalManager: TelemetryManager,\n telemetryConfig?: TelemetryOptions,\n ) {\n this.pluginName = pluginName;\n this.globalManager = globalManager;\n this.config = normalizeTelemetryOptions(telemetryConfig);\n }\n\n /**\n * Gets a tracer for creating spans.\n * @param options - Optional tracer configuration.\n */\n getTracer(options?: InstrumentConfig): Tracer {\n if (!this.config.traces) {\n return NOOP_TRACER;\n }\n\n const tracerName = this.getInstrumentName(options);\n return trace.getTracer(tracerName);\n }\n\n /**\n * Gets a meter for recording metrics.\n * @param config - Optional meter configuration.\n */\n getMeter(config?: InstrumentConfig): Meter {\n if (!this.config.metrics) {\n return NOOP_METER;\n }\n\n const meterName = this.getInstrumentName(config);\n return metrics.getMeter(meterName);\n }\n\n /**\n * Gets a logger for emitting log records.\n * @param config - Optional logger configuration.\n */\n getLogger(config?: InstrumentConfig): Logger {\n if (!this.config.logs) {\n return NOOP_LOGGER;\n }\n\n const loggerName = this.getInstrumentName(config);\n return logs.getLogger(loggerName);\n }\n\n /**\n * Emits a log record using the default logger.\n * Convenience method for logging without explicitly getting a logger.\n * @param logRecord - The log record to emit\n */\n emit(logRecord: LogRecord): void {\n const logger = this.getLogger();\n logger.emit(logRecord);\n }\n\n /**\n * Register OpenTelemetry instrumentations.\n * Can be called at any time, but recommended to call in plugin constructor.\n * @param instrumentations - Array of OpenTelemetry instrumentations to register\n */\n registerInstrumentations(instrumentations: Instrumentation[]): void {\n if (!this.config.traces) {\n return;\n }\n\n this.globalManager.registerInstrumentations(instrumentations);\n }\n\n /**\n * Starts an active span and executes a callback function within its context.\n * Uses the plugin's default tracer unless custom tracer options are provided.\n * @param name - The name of the span\n * @param options - Span options including attributes, kind, etc.\n * @param fn - Callback function to execute within the span context\n * @param tracerOptions - Optional tracer configuration\n * @returns Promise resolving to the callback's return value\n */\n startActiveSpan<T>(\n name: string,\n options: SpanOptions,\n fn: (span: Span) => Promise<T>,\n tracerOptions?: InstrumentConfig,\n ): Promise<T> {\n const tracer = this.getTracer(tracerOptions);\n return tracer.startActiveSpan(name, options, fn);\n }\n\n private getInstrumentName(options?: InstrumentConfig): string {\n const prefix = this.pluginName;\n if (!options || !options.name) {\n return this.pluginName;\n }\n\n return options.includePrefix ? `${prefix}-${options.name}` : options.name;\n }\n}\n"],"mappings":";;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"telemetry-provider.js","names":[],"sources":["../../src/telemetry/telemetry-provider.ts"],"sourcesContent":["import type { Meter, Span, SpanOptions, Tracer } from \"@opentelemetry/api\";\nimport { metrics, trace } from \"@opentelemetry/api\";\nimport { type Logger, type LogRecord, logs } from \"@opentelemetry/api-logs\";\nimport type { Instrumentation } from \"@opentelemetry/instrumentation\";\nimport type { TelemetryOptions } from \"shared\";\n\nimport {\n normalizeTelemetryOptions,\n type TelemetryProviderConfig,\n} from \"./config\";\nimport { NOOP_LOGGER, NOOP_METER, NOOP_TRACER } from \"./noop\";\nimport type { TelemetryManager } from \"./telemetry-manager\";\nimport type { InstrumentConfig, ITelemetry } from \"./types\";\n\n/**\n * Scoped telemetry instance for specific plugins and other classes.\n * Automatically uses the plugin name as the default tracer/meter name.\n */\nexport class TelemetryProvider implements ITelemetry {\n private readonly pluginName: string;\n private readonly globalManager: TelemetryManager;\n private readonly config: TelemetryProviderConfig;\n\n constructor(\n pluginName: string,\n globalManager: TelemetryManager,\n telemetryConfig?: TelemetryOptions,\n ) {\n this.pluginName = pluginName;\n this.globalManager = globalManager;\n this.config = normalizeTelemetryOptions(telemetryConfig);\n }\n\n /**\n * Gets a tracer for creating spans.\n * @param options - Optional tracer configuration.\n */\n getTracer(options?: InstrumentConfig): Tracer {\n if (!this.config.traces) {\n return NOOP_TRACER;\n }\n\n const tracerName = this.getInstrumentName(options);\n return trace.getTracer(tracerName);\n }\n\n /**\n * Gets a meter for recording metrics.\n * @param config - Optional meter configuration.\n */\n getMeter(config?: InstrumentConfig): Meter {\n if (!this.config.metrics) {\n return NOOP_METER;\n }\n\n const meterName = this.getInstrumentName(config);\n return metrics.getMeter(meterName);\n }\n\n /**\n * Gets a logger for emitting log records.\n * @param config - Optional logger configuration.\n */\n getLogger(config?: InstrumentConfig): Logger {\n if (!this.config.logs) {\n return NOOP_LOGGER;\n }\n\n const loggerName = this.getInstrumentName(config);\n return logs.getLogger(loggerName);\n }\n\n /**\n * Emits a log record using the default logger.\n * Convenience method for logging without explicitly getting a logger.\n * @param logRecord - The log record to emit\n */\n emit(logRecord: LogRecord): void {\n const logger = this.getLogger();\n logger.emit(logRecord);\n }\n\n /**\n * Register OpenTelemetry instrumentations.\n * Can be called at any time, but recommended to call in plugin constructor.\n * @param instrumentations - Array of OpenTelemetry instrumentations to register\n */\n registerInstrumentations(instrumentations: Instrumentation[]): void {\n if (!this.config.traces) {\n return;\n }\n\n this.globalManager.registerInstrumentations(instrumentations);\n }\n\n /**\n * Starts an active span and executes a callback function within its context.\n * Uses the plugin's default tracer unless custom tracer options are provided.\n * @param name - The name of the span\n * @param options - Span options including attributes, kind, etc.\n * @param fn - Callback function to execute within the span context\n * @param tracerOptions - Optional tracer configuration\n * @returns Promise resolving to the callback's return value\n */\n startActiveSpan<T>(\n name: string,\n options: SpanOptions,\n fn: (span: Span) => Promise<T>,\n tracerOptions?: InstrumentConfig,\n ): Promise<T> {\n const tracer = this.getTracer(tracerOptions);\n return tracer.startActiveSpan(name, options, fn);\n }\n\n private getInstrumentName(options?: InstrumentConfig): string {\n const prefix = this.pluginName;\n if (!options || !options.name) {\n return this.pluginName;\n }\n\n return options.includePrefix ? `${prefix}-${options.name}` : options.name;\n }\n}\n"],"mappings":";;;;;;;;;;AAkBA,IAAa,oBAAb,MAAqD;CACnD,AAAiB;CACjB,AAAiB;CACjB,AAAiB;CAEjB,YACE,YACA,eACA,iBACA;AACA,OAAK,aAAa;AAClB,OAAK,gBAAgB;AACrB,OAAK,SAAS,0BAA0B,gBAAgB;;;;;;CAO1D,UAAU,SAAoC;AAC5C,MAAI,CAAC,KAAK,OAAO,OACf,QAAO;EAGT,MAAM,aAAa,KAAK,kBAAkB,QAAQ;AAClD,SAAO,MAAM,UAAU,WAAW;;;;;;CAOpC,SAAS,QAAkC;AACzC,MAAI,CAAC,KAAK,OAAO,QACf,QAAO;EAGT,MAAM,YAAY,KAAK,kBAAkB,OAAO;AAChD,SAAO,QAAQ,SAAS,UAAU;;;;;;CAOpC,UAAU,QAAmC;AAC3C,MAAI,CAAC,KAAK,OAAO,KACf,QAAO;EAGT,MAAM,aAAa,KAAK,kBAAkB,OAAO;AACjD,SAAO,KAAK,UAAU,WAAW;;;;;;;CAQnC,KAAK,WAA4B;AAE/B,EADe,KAAK,WAAW,CACxB,KAAK,UAAU;;;;;;;CAQxB,yBAAyB,kBAA2C;AAClE,MAAI,CAAC,KAAK,OAAO,OACf;AAGF,OAAK,cAAc,yBAAyB,iBAAiB;;;;;;;;;;;CAY/D,gBACE,MACA,SACA,IACA,eACY;AAEZ,SADe,KAAK,UAAU,cAAc,CAC9B,gBAAgB,MAAM,SAAS,GAAG;;CAGlD,AAAQ,kBAAkB,SAAoC;EAC5D,MAAM,SAAS,KAAK;AACpB,MAAI,CAAC,WAAW,CAAC,QAAQ,KACvB,QAAO,KAAK;AAGd,SAAO,QAAQ,gBAAgB,GAAG,OAAO,GAAG,QAAQ,SAAS,QAAQ"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"trace-sampler.js","names":[],"sources":["../../src/telemetry/trace-sampler.ts"],"sourcesContent":["import type { Attributes, Context, Link } from \"@opentelemetry/api\";\nimport type { Sampler, SamplingResult } from \"@opentelemetry/sdk-trace-base\";\nimport { SamplingDecision } from \"@opentelemetry/sdk-trace-base\";\nimport { shouldExcludePath } from \"../utils/path-exclusions\";\n\n/**\n * Custom sampler that filters out asset requests and other noise.\n *\n * This acts as a secondary filter after HttpInstrumentation.ignoreIncomingRequestHook.\n * It catches any spans that slip through the primary filter.\n */\nexport class AppKitSampler implements Sampler {\n shouldSample(\n _context: Context,\n _traceId: string,\n spanName: string,\n _spanKind: number,\n attributes: Attributes,\n _links: Link[],\n ): SamplingResult {\n // Check if this is an HTTP request span\n const httpTarget = attributes[\"http.target\"] as string | undefined;\n const httpRoute = attributes[\"http.route\"] as string | undefined;\n const httpUrl = attributes[\"http.url\"] as string | undefined;\n\n // Try to extract path from various attributes\n let path = httpTarget || httpRoute;\n if (!path && httpUrl) {\n try {\n path = new URL(httpUrl).pathname;\n } catch {\n // Not a valid URL, use as-is\n path = httpUrl;\n }\n }\n if (!path) {\n path = spanName;\n }\n\n // Check if path should be excluded\n if (shouldExcludePath(path)) {\n return {\n decision: SamplingDecision.NOT_RECORD,\n };\n }\n\n // For all other requests, record and sample\n return {\n decision: SamplingDecision.RECORD_AND_SAMPLED,\n };\n }\n\n toString(): string {\n return \"AppKitSampler\";\n }\n}\n"],"mappings":";;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"trace-sampler.js","names":[],"sources":["../../src/telemetry/trace-sampler.ts"],"sourcesContent":["import type { Attributes, Context, Link } from \"@opentelemetry/api\";\nimport type { Sampler, SamplingResult } from \"@opentelemetry/sdk-trace-base\";\nimport { SamplingDecision } from \"@opentelemetry/sdk-trace-base\";\n\nimport { shouldExcludePath } from \"../utils/path-exclusions\";\n\n/**\n * Custom sampler that filters out asset requests and other noise.\n *\n * This acts as a secondary filter after HttpInstrumentation.ignoreIncomingRequestHook.\n * It catches any spans that slip through the primary filter.\n */\nexport class AppKitSampler implements Sampler {\n shouldSample(\n _context: Context,\n _traceId: string,\n spanName: string,\n _spanKind: number,\n attributes: Attributes,\n _links: Link[],\n ): SamplingResult {\n // Check if this is an HTTP request span\n const httpTarget = attributes[\"http.target\"] as string | undefined;\n const httpRoute = attributes[\"http.route\"] as string | undefined;\n const httpUrl = attributes[\"http.url\"] as string | undefined;\n\n // Try to extract path from various attributes\n let path = httpTarget || httpRoute;\n if (!path && httpUrl) {\n try {\n path = new URL(httpUrl).pathname;\n } catch {\n // Not a valid URL, use as-is\n path = httpUrl;\n }\n }\n if (!path) {\n path = spanName;\n }\n\n // Check if path should be excluded\n if (shouldExcludePath(path)) {\n return {\n decision: SamplingDecision.NOT_RECORD,\n };\n }\n\n // For all other requests, record and sample\n return {\n decision: SamplingDecision.RECORD_AND_SAMPLED,\n };\n }\n\n toString(): string {\n return \"AppKitSampler\";\n }\n}\n"],"mappings":";;;;;;;;;;AAYA,IAAa,gBAAb,MAA8C;CAC5C,aACE,UACA,UACA,UACA,WACA,YACA,QACgB;EAEhB,MAAM,aAAa,WAAW;EAC9B,MAAM,YAAY,WAAW;EAC7B,MAAM,UAAU,WAAW;EAG3B,IAAI,OAAO,cAAc;AACzB,MAAI,CAAC,QAAQ,QACX,KAAI;AACF,UAAO,IAAI,IAAI,QAAQ,CAAC;UAClB;AAEN,UAAO;;AAGX,MAAI,CAAC,KACH,QAAO;AAIT,MAAI,kBAAkB,KAAK,CACzB,QAAO,EACL,UAAU,iBAAiB,YAC5B;AAIH,SAAO,EACL,UAAU,iBAAiB,oBAC5B;;CAGH,WAAmB;AACjB,SAAO"}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
//#region src/testing/expect-stream.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* A single event observed on a stream. AppKit adapters yield objects with a
|
|
4
|
+
* `type` discriminator; SSE frames parsed from an HTTP response carry the
|
|
5
|
+
* event name under `event`. {@link expectStream} normalizes both to a type
|
|
6
|
+
* string, preferring `type` and falling back to `event`.
|
|
7
|
+
*/
|
|
8
|
+
interface StreamEvent {
|
|
9
|
+
type?: string;
|
|
10
|
+
event?: string;
|
|
11
|
+
[key: string]: unknown;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* A response double that captured what a streaming handler wrote and can
|
|
15
|
+
* replay it as a real `Response`. {@link createMockResponse} returns one; this
|
|
16
|
+
* structural type lets {@link expectStream} accept it without importing the
|
|
17
|
+
* fixtures module (which would form a cycle).
|
|
18
|
+
*/
|
|
19
|
+
interface CapturedSSEResponse {
|
|
20
|
+
sseResponse(): Response;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Anything {@link expectStream} can consume:
|
|
24
|
+
* - an async event stream (an adapter's `run()`, an SSE reader),
|
|
25
|
+
* - an already-collected array of events,
|
|
26
|
+
* - an SSE `Response` (or a promise of one) — its body is parsed into events,
|
|
27
|
+
* - a captured mock response ({@link createMockResponse}) — its written SSE
|
|
28
|
+
* bytes are parsed into events.
|
|
29
|
+
*/
|
|
30
|
+
type StreamSource = AsyncIterable<StreamEvent> | Iterable<StreamEvent> | Response | Promise<Response> | CapturedSSEResponse;
|
|
31
|
+
/** Assertions over the events collected from a {@link StreamSource}. */
|
|
32
|
+
interface StreamAssertion {
|
|
33
|
+
/**
|
|
34
|
+
* Assert that `eventTypes` appear, in this order, among the emitted event
|
|
35
|
+
* types. Extra events (heartbeats, metadata, deltas) may appear before,
|
|
36
|
+
* between, or after — this is an in-order subsequence match, which is what
|
|
37
|
+
* you want for streams that interleave bookkeeping events. Resolves to the
|
|
38
|
+
* full list of emitted types on success; rejects with a diff otherwise.
|
|
39
|
+
*/
|
|
40
|
+
toEmit(...eventTypes: string[]): Promise<string[]>;
|
|
41
|
+
/**
|
|
42
|
+
* Assert that the emitted event types are exactly `eventTypes`, in order and
|
|
43
|
+
* with nothing else. Use when the stream's shape is fully determined.
|
|
44
|
+
*/
|
|
45
|
+
toEmitExactly(...eventTypes: string[]): Promise<string[]>;
|
|
46
|
+
/** Collect and return the normalized events without asserting. */
|
|
47
|
+
collect(): Promise<StreamEvent[]>;
|
|
48
|
+
/** Collect and return just the event type strings, in order. */
|
|
49
|
+
collectTypes(): Promise<string[]>;
|
|
50
|
+
}
|
|
51
|
+
/** Options for {@link expectStream}. */
|
|
52
|
+
interface ExpectStreamOptions {
|
|
53
|
+
/**
|
|
54
|
+
* Fail with a clear error if the source has not finished within this many
|
|
55
|
+
* milliseconds, instead of hanging until the test runner's own timeout.
|
|
56
|
+
* Omit to buffer the source with no bound (the default).
|
|
57
|
+
*/
|
|
58
|
+
timeout?: number;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Consume a stream and make ordered assertions about the event types it emits.
|
|
62
|
+
*
|
|
63
|
+
* Deterministic and network-free: pair it with {@link createTestPluginContext} to
|
|
64
|
+
* exercise a plugin's streaming handler and assert what it emits.
|
|
65
|
+
*
|
|
66
|
+
* @example Async event stream (adapter output)
|
|
67
|
+
* ```ts
|
|
68
|
+
* await expectStream(agent.adapter.run(input)).toEmit("tool_call", "message_delta");
|
|
69
|
+
* ```
|
|
70
|
+
*
|
|
71
|
+
* @example SSE HTTP response
|
|
72
|
+
* ```ts
|
|
73
|
+
* const res = await fetch("/api/analytics/query/top_users", { method: "POST" });
|
|
74
|
+
* await expectStream(res).toEmit("warehouse_status", "result");
|
|
75
|
+
* ```
|
|
76
|
+
*
|
|
77
|
+
* @example A plugin's streaming route (via {@link createMockResponse})
|
|
78
|
+
* ```ts
|
|
79
|
+
* const res = createMockResponse();
|
|
80
|
+
* await plugin._handleStream(req, res); // writes SSE to res
|
|
81
|
+
* await expectStream(res).toEmit("status", "result");
|
|
82
|
+
* ```
|
|
83
|
+
*
|
|
84
|
+
* @example Guard against a non-terminating stream
|
|
85
|
+
* ```ts
|
|
86
|
+
* await expectStream(handler.stream(req), { timeout: 1000 }).toEmit("result");
|
|
87
|
+
* ```
|
|
88
|
+
*
|
|
89
|
+
* @param source - The stream, iterable, or SSE `Response` to consume.
|
|
90
|
+
* @param options - See {@link ExpectStreamOptions}; pass `{ timeout }` to fail
|
|
91
|
+
* fast on a stream that never ends.
|
|
92
|
+
*/
|
|
93
|
+
declare function expectStream(source: StreamSource, options?: ExpectStreamOptions): StreamAssertion;
|
|
94
|
+
/**
|
|
95
|
+
* Parse an SSE `Response` and return its **last** event flattened to
|
|
96
|
+
* `{ eventType, ...data }`.
|
|
97
|
+
*
|
|
98
|
+
* A convenience for one-shot assertions on a reply's final event; prefer
|
|
99
|
+
* {@link expectStream} for multi-event ordering. It shares {@link parseSSEBody}
|
|
100
|
+
* with `expectStream`, so the two never diverge on CRLF handling, comment
|
|
101
|
+
* lines, or field parsing.
|
|
102
|
+
*
|
|
103
|
+
* @throws if the response carries no data-bearing event.
|
|
104
|
+
*/
|
|
105
|
+
declare function parseSSEResponse(response: Response): Promise<{
|
|
106
|
+
eventType: string | null;
|
|
107
|
+
[key: string]: unknown;
|
|
108
|
+
}>;
|
|
109
|
+
//#endregion
|
|
110
|
+
export { CapturedSSEResponse, ExpectStreamOptions, StreamAssertion, StreamEvent, StreamSource, expectStream, parseSSEResponse };
|
|
111
|
+
//# sourceMappingURL=expect-stream.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"expect-stream.d.ts","names":[],"sources":["../../src/testing/expect-stream.ts"],"mappings":";;AAMA;;;;;UAAiB,WAAA;EACf,IAAA;EACA,KAAA;EAAA,CACC,GAAA;AAAA;;;;;AAqBH;;UAZiB,mBAAA;EACf,WAAA,IAAe,QAAA;AAAA;;;;;;;;;KAWL,YAAA,GACR,aAAA,CAAc,WAAA,IACd,QAAA,CAAS,WAAA,IACT,QAAA,GACA,OAAA,CAAQ,QAAA,IACR,mBAAA;;UAYa,eAAA;EAfJ;;;;;;;EAuBX,MAAA,IAAU,UAAA,aAAuB,OAAA;EARH;;;;EAa9B,aAAA,IAAiB,UAAA,aAAuB,OAAA;EAE7B;EAAX,OAAA,IAAW,OAAA,CAAQ,WAAA;EAEI;EAAvB,YAAA,IAAgB,OAAA;AAAA;;UAiJD,mBAAA;EArJf;;;;;EA2JA,OAAA;AAAA;;;;AANF;;;;;AAoDA;;;;;;;;;;;;;;;AAoDA;;;;;;;;;;iBApDgB,YAAA,CACd,MAAA,EAAQ,YAAA,EACR,OAAA,GAAS,mBAAA,GACR,eAAA;;;;;;;;;;;;iBAiDmB,gBAAA,CAAiB,QAAA,EAAU,QAAA,GAAW,OAAA;EAC1D,SAAA;EAAA,CACC,GAAA;AAAA"}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
//#region src/testing/expect-stream.ts
|
|
2
|
+
/** Does `value` expose a `sseResponse()` — i.e. is it a captured mock response? */
|
|
3
|
+
function isCapturedSSEResponse(value) {
|
|
4
|
+
return typeof value === "object" && value !== null && typeof value.sseResponse === "function";
|
|
5
|
+
}
|
|
6
|
+
function eventType(event) {
|
|
7
|
+
return event.type ?? event.event ?? "";
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Parse a finished SSE response body into events. Blocks are delimited by a
|
|
11
|
+
* blank line; within a block, `event:` sets the name and `data:` lines are
|
|
12
|
+
* joined and JSON-parsed when possible. Comment/heartbeat lines (`:`) and
|
|
13
|
+
* blocks without data are ignored.
|
|
14
|
+
*/
|
|
15
|
+
function parseSSEBody(text) {
|
|
16
|
+
const events = [];
|
|
17
|
+
const blocks = text.replace(/\r\n/g, "\n").split("\n\n");
|
|
18
|
+
for (const block of blocks) {
|
|
19
|
+
let name;
|
|
20
|
+
const dataLines = [];
|
|
21
|
+
for (const line of block.split("\n")) if (line.startsWith("event:")) name = line.slice(6).trim();
|
|
22
|
+
else if (line.startsWith("data:")) dataLines.push(line.slice(5).replace(/^ /, ""));
|
|
23
|
+
if (dataLines.length === 0) continue;
|
|
24
|
+
const data = dataLines.join("\n");
|
|
25
|
+
let parsed = {};
|
|
26
|
+
if (data) try {
|
|
27
|
+
const json = JSON.parse(data);
|
|
28
|
+
if (json && typeof json === "object" && !Array.isArray(json)) parsed = json;
|
|
29
|
+
else parsed = { data: json };
|
|
30
|
+
} catch {
|
|
31
|
+
parsed = { data };
|
|
32
|
+
}
|
|
33
|
+
events.push({
|
|
34
|
+
...parsed,
|
|
35
|
+
type: name ?? parsed.type
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return events;
|
|
39
|
+
}
|
|
40
|
+
async function collectEventsInner(source) {
|
|
41
|
+
const resolved = await source;
|
|
42
|
+
if (typeof resolved === "string") throw new Error("expectStream: received a raw string. Pass a Response, a captured response from createMockResponse(), or call its sseResponse() — not the SSE body text (a string iterates one character at a time).");
|
|
43
|
+
if (resolved instanceof Response) return parseSSEBody(await resolved.text());
|
|
44
|
+
if (isCapturedSSEResponse(resolved)) return parseSSEBody(await resolved.sseResponse().text());
|
|
45
|
+
if (resolved && typeof resolved === "object") {
|
|
46
|
+
if (Symbol.asyncIterator in resolved) {
|
|
47
|
+
const events = [];
|
|
48
|
+
for await (const event of resolved) events.push(event);
|
|
49
|
+
return events;
|
|
50
|
+
}
|
|
51
|
+
if (Symbol.iterator in resolved) return Array.from(resolved);
|
|
52
|
+
}
|
|
53
|
+
throw new Error("expectStream: source must be an async iterable, an iterable, or a Response");
|
|
54
|
+
}
|
|
55
|
+
async function collectEvents(source, timeoutMs) {
|
|
56
|
+
if (timeoutMs === void 0) return collectEventsInner(source);
|
|
57
|
+
let timer;
|
|
58
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
59
|
+
timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`expectStream: stream did not terminate within ${timeoutMs}ms. Ensure the source ends, or raise the { timeout } option.`)), timeoutMs);
|
|
60
|
+
});
|
|
61
|
+
try {
|
|
62
|
+
return await Promise.race([collectEventsInner(source), timeout]);
|
|
63
|
+
} finally {
|
|
64
|
+
if (timer) clearTimeout(timer);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** Does `expected` appear as an in-order subsequence of `actual`? */
|
|
68
|
+
function isSubsequence(actual, expected) {
|
|
69
|
+
let i = 0;
|
|
70
|
+
for (const type of actual) {
|
|
71
|
+
if (i < expected.length && type === expected[i]) i++;
|
|
72
|
+
if (i === expected.length) break;
|
|
73
|
+
}
|
|
74
|
+
return i === expected.length;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Consume a stream and make ordered assertions about the event types it emits.
|
|
78
|
+
*
|
|
79
|
+
* Deterministic and network-free: pair it with {@link createTestPluginContext} to
|
|
80
|
+
* exercise a plugin's streaming handler and assert what it emits.
|
|
81
|
+
*
|
|
82
|
+
* @example Async event stream (adapter output)
|
|
83
|
+
* ```ts
|
|
84
|
+
* await expectStream(agent.adapter.run(input)).toEmit("tool_call", "message_delta");
|
|
85
|
+
* ```
|
|
86
|
+
*
|
|
87
|
+
* @example SSE HTTP response
|
|
88
|
+
* ```ts
|
|
89
|
+
* const res = await fetch("/api/analytics/query/top_users", { method: "POST" });
|
|
90
|
+
* await expectStream(res).toEmit("warehouse_status", "result");
|
|
91
|
+
* ```
|
|
92
|
+
*
|
|
93
|
+
* @example A plugin's streaming route (via {@link createMockResponse})
|
|
94
|
+
* ```ts
|
|
95
|
+
* const res = createMockResponse();
|
|
96
|
+
* await plugin._handleStream(req, res); // writes SSE to res
|
|
97
|
+
* await expectStream(res).toEmit("status", "result");
|
|
98
|
+
* ```
|
|
99
|
+
*
|
|
100
|
+
* @example Guard against a non-terminating stream
|
|
101
|
+
* ```ts
|
|
102
|
+
* await expectStream(handler.stream(req), { timeout: 1000 }).toEmit("result");
|
|
103
|
+
* ```
|
|
104
|
+
*
|
|
105
|
+
* @param source - The stream, iterable, or SSE `Response` to consume.
|
|
106
|
+
* @param options - See {@link ExpectStreamOptions}; pass `{ timeout }` to fail
|
|
107
|
+
* fast on a stream that never ends.
|
|
108
|
+
*/
|
|
109
|
+
function expectStream(source, options = {}) {
|
|
110
|
+
const events = collectEvents(source, options.timeout);
|
|
111
|
+
return {
|
|
112
|
+
async collect() {
|
|
113
|
+
return events;
|
|
114
|
+
},
|
|
115
|
+
async collectTypes() {
|
|
116
|
+
return (await events).map(eventType);
|
|
117
|
+
},
|
|
118
|
+
async toEmit(...eventTypes) {
|
|
119
|
+
const types = (await events).map(eventType);
|
|
120
|
+
if (!isSubsequence(types, eventTypes)) throw new Error(`expectStream(...).toEmit: expected events ${JSON.stringify(eventTypes)} in order, but stream emitted ${JSON.stringify(types)}`);
|
|
121
|
+
return types;
|
|
122
|
+
},
|
|
123
|
+
async toEmitExactly(...eventTypes) {
|
|
124
|
+
const types = (await events).map(eventType);
|
|
125
|
+
if (!(types.length === eventTypes.length && types.every((t, i) => t === eventTypes[i]))) throw new Error(`expectStream(...).toEmitExactly: expected exactly ${JSON.stringify(eventTypes)}, but stream emitted ${JSON.stringify(types)}`);
|
|
126
|
+
return types;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Parse an SSE `Response` and return its **last** event flattened to
|
|
132
|
+
* `{ eventType, ...data }`.
|
|
133
|
+
*
|
|
134
|
+
* A convenience for one-shot assertions on a reply's final event; prefer
|
|
135
|
+
* {@link expectStream} for multi-event ordering. It shares {@link parseSSEBody}
|
|
136
|
+
* with `expectStream`, so the two never diverge on CRLF handling, comment
|
|
137
|
+
* lines, or field parsing.
|
|
138
|
+
*
|
|
139
|
+
* @throws if the response carries no data-bearing event.
|
|
140
|
+
*/
|
|
141
|
+
async function parseSSEResponse(response) {
|
|
142
|
+
const text = await response.text();
|
|
143
|
+
const last = parseSSEBody(text).at(-1);
|
|
144
|
+
if (!last) throw new Error(`No data found in SSE response: ${text}`);
|
|
145
|
+
const { type, ...rest } = last;
|
|
146
|
+
return {
|
|
147
|
+
eventType: type ?? null,
|
|
148
|
+
...rest
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
//#endregion
|
|
153
|
+
export { expectStream, parseSSEResponse };
|
|
154
|
+
//# sourceMappingURL=expect-stream.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"expect-stream.js","names":[],"sources":["../../src/testing/expect-stream.ts"],"sourcesContent":["/**\n * A single event observed on a stream. AppKit adapters yield objects with a\n * `type` discriminator; SSE frames parsed from an HTTP response carry the\n * event name under `event`. {@link expectStream} normalizes both to a type\n * string, preferring `type` and falling back to `event`.\n */\nexport interface StreamEvent {\n type?: string;\n event?: string;\n [key: string]: unknown;\n}\n\n/**\n * A response double that captured what a streaming handler wrote and can\n * replay it as a real `Response`. {@link createMockResponse} returns one; this\n * structural type lets {@link expectStream} accept it without importing the\n * fixtures module (which would form a cycle).\n */\nexport interface CapturedSSEResponse {\n sseResponse(): Response;\n}\n\n/**\n * Anything {@link expectStream} can consume:\n * - an async event stream (an adapter's `run()`, an SSE reader),\n * - an already-collected array of events,\n * - an SSE `Response` (or a promise of one) — its body is parsed into events,\n * - a captured mock response ({@link createMockResponse}) — its written SSE\n * bytes are parsed into events.\n */\nexport type StreamSource =\n | AsyncIterable<StreamEvent>\n | Iterable<StreamEvent>\n | Response\n | Promise<Response>\n | CapturedSSEResponse;\n\n/** Does `value` expose a `sseResponse()` — i.e. is it a captured mock response? */\nfunction isCapturedSSEResponse(value: unknown): value is CapturedSSEResponse {\n return (\n typeof value === \"object\" &&\n value !== null &&\n typeof (value as CapturedSSEResponse).sseResponse === \"function\"\n );\n}\n\n/** Assertions over the events collected from a {@link StreamSource}. */\nexport interface StreamAssertion {\n /**\n * Assert that `eventTypes` appear, in this order, among the emitted event\n * types. Extra events (heartbeats, metadata, deltas) may appear before,\n * between, or after — this is an in-order subsequence match, which is what\n * you want for streams that interleave bookkeeping events. Resolves to the\n * full list of emitted types on success; rejects with a diff otherwise.\n */\n toEmit(...eventTypes: string[]): Promise<string[]>;\n /**\n * Assert that the emitted event types are exactly `eventTypes`, in order and\n * with nothing else. Use when the stream's shape is fully determined.\n */\n toEmitExactly(...eventTypes: string[]): Promise<string[]>;\n /** Collect and return the normalized events without asserting. */\n collect(): Promise<StreamEvent[]>;\n /** Collect and return just the event type strings, in order. */\n collectTypes(): Promise<string[]>;\n}\n\nfunction eventType(event: StreamEvent): string {\n return event.type ?? event.event ?? \"\";\n}\n\n/**\n * Parse a finished SSE response body into events. Blocks are delimited by a\n * blank line; within a block, `event:` sets the name and `data:` lines are\n * joined and JSON-parsed when possible. Comment/heartbeat lines (`:`) and\n * blocks without data are ignored.\n */\nfunction parseSSEBody(text: string): StreamEvent[] {\n const events: StreamEvent[] = [];\n // Normalize CRLF to LF first so frames delimited by `\\r\\n\\r\\n` (spec-compliant\n // SSE from a real server) split the same as AppKit's own `\\n\\n` writer.\n const blocks = text.replace(/\\r\\n/g, \"\\n\").split(\"\\n\\n\");\n\n for (const block of blocks) {\n let name: string | undefined;\n const dataLines: string[] = [];\n\n for (const line of block.split(\"\\n\")) {\n if (line.startsWith(\"event:\")) {\n name = line.slice(\"event:\".length).trim();\n } else if (line.startsWith(\"data:\")) {\n dataLines.push(line.slice(\"data:\".length).replace(/^ /, \"\"));\n }\n // `id:` and comment (`:`) lines carry no event type/data we assert on.\n }\n\n // A frame with no data line is bookkeeping (a bare `event:`, an `id:`, or a\n // `:` comment/heartbeat) that a real SSE client does not surface as an\n // event — skip it whether or not it carried an `event:` name.\n if (dataLines.length === 0) continue;\n\n const data = dataLines.join(\"\\n\");\n let parsed: Record<string, unknown> = {};\n if (data) {\n try {\n const json = JSON.parse(data);\n if (json && typeof json === \"object\" && !Array.isArray(json)) {\n parsed = json as Record<string, unknown>;\n } else {\n parsed = { data: json };\n }\n } catch {\n parsed = { data };\n }\n }\n\n // The wire `event:` name is authoritative. Spread the payload FIRST, then\n // set `type`, so a `data` payload that happens to carry its own `type`\n // field (e.g. `event: error` + `data: {\"type\":\"result\"}`) cannot override\n // the frame's real event name.\n events.push({\n ...parsed,\n type: name ?? (parsed.type as string | undefined),\n });\n }\n\n return events;\n}\n\nasync function collectEventsInner(\n source: StreamSource,\n): Promise<StreamEvent[]> {\n const resolved = await source;\n\n // A raw SSE body string is a trap: a string is itself an iterable, so it\n // would be walked one character at a time. Reject it with a pointer to the\n // right input rather than silently producing per-character \"events\".\n if (typeof resolved === \"string\") {\n throw new Error(\n \"expectStream: received a raw string. Pass a Response, a captured \" +\n \"response from createMockResponse(), or call its sseResponse() — \" +\n \"not the SSE body text (a string iterates one character at a time).\",\n );\n }\n\n if (resolved instanceof Response) {\n const text = await resolved.text();\n return parseSSEBody(text);\n }\n\n // A captured mock response ({@link createMockResponse}) — replay the SSE it\n // recorded. Checked before the generic iterable branches (it is a plain\n // object without an iterator) so a streaming route reads back as events.\n if (isCapturedSSEResponse(resolved)) {\n const text = await resolved.sseResponse().text();\n return parseSSEBody(text);\n }\n\n if (resolved && typeof resolved === \"object\") {\n if (Symbol.asyncIterator in resolved) {\n const events: StreamEvent[] = [];\n for await (const event of resolved as AsyncIterable<StreamEvent>) {\n events.push(event);\n }\n return events;\n }\n if (Symbol.iterator in resolved) {\n return Array.from(resolved as Iterable<StreamEvent>);\n }\n }\n\n throw new Error(\n \"expectStream: source must be an async iterable, an iterable, or a Response\",\n );\n}\n\nasync function collectEvents(\n source: StreamSource,\n timeoutMs?: number,\n): Promise<StreamEvent[]> {\n // `expectStream` buffers the whole source before asserting. Without a bound,\n // a stream that never terminates hangs until Vitest's per-test timeout —\n // a poor signal. When a timeout is given, surface a clear, kit-specific\n // error instead. The pending collection is abandoned (it cannot be force\n // -cancelled), so callers should pair this with an aborting source.\n if (timeoutMs === undefined) return collectEventsInner(source);\n\n let timer: ReturnType<typeof setTimeout> | undefined;\n const timeout = new Promise<never>((_resolve, reject) => {\n timer = setTimeout(\n () =>\n reject(\n new Error(\n `expectStream: stream did not terminate within ${timeoutMs}ms. ` +\n \"Ensure the source ends, or raise the { timeout } option.\",\n ),\n ),\n timeoutMs,\n );\n });\n\n try {\n return await Promise.race([collectEventsInner(source), timeout]);\n } finally {\n if (timer) clearTimeout(timer);\n }\n}\n\n/** Options for {@link expectStream}. */\nexport interface ExpectStreamOptions {\n /**\n * Fail with a clear error if the source has not finished within this many\n * milliseconds, instead of hanging until the test runner's own timeout.\n * Omit to buffer the source with no bound (the default).\n */\n timeout?: number;\n}\n\n/** Does `expected` appear as an in-order subsequence of `actual`? */\nfunction isSubsequence(actual: string[], expected: string[]): boolean {\n let i = 0;\n for (const type of actual) {\n if (i < expected.length && type === expected[i]) i++;\n if (i === expected.length) break;\n }\n return i === expected.length;\n}\n\n/**\n * Consume a stream and make ordered assertions about the event types it emits.\n *\n * Deterministic and network-free: pair it with {@link createTestPluginContext} to\n * exercise a plugin's streaming handler and assert what it emits.\n *\n * @example Async event stream (adapter output)\n * ```ts\n * await expectStream(agent.adapter.run(input)).toEmit(\"tool_call\", \"message_delta\");\n * ```\n *\n * @example SSE HTTP response\n * ```ts\n * const res = await fetch(\"/api/analytics/query/top_users\", { method: \"POST\" });\n * await expectStream(res).toEmit(\"warehouse_status\", \"result\");\n * ```\n *\n * @example A plugin's streaming route (via {@link createMockResponse})\n * ```ts\n * const res = createMockResponse();\n * await plugin._handleStream(req, res); // writes SSE to res\n * await expectStream(res).toEmit(\"status\", \"result\");\n * ```\n *\n * @example Guard against a non-terminating stream\n * ```ts\n * await expectStream(handler.stream(req), { timeout: 1000 }).toEmit(\"result\");\n * ```\n *\n * @param source - The stream, iterable, or SSE `Response` to consume.\n * @param options - See {@link ExpectStreamOptions}; pass `{ timeout }` to fail\n * fast on a stream that never ends.\n */\nexport function expectStream(\n source: StreamSource,\n options: ExpectStreamOptions = {},\n): StreamAssertion {\n const events = collectEvents(source, options.timeout);\n\n return {\n async collect() {\n return events;\n },\n async collectTypes() {\n return (await events).map(eventType);\n },\n async toEmit(...eventTypes: string[]) {\n const types = (await events).map(eventType);\n if (!isSubsequence(types, eventTypes)) {\n throw new Error(\n `expectStream(...).toEmit: expected events ${JSON.stringify(\n eventTypes,\n )} in order, but stream emitted ${JSON.stringify(types)}`,\n );\n }\n return types;\n },\n async toEmitExactly(...eventTypes: string[]) {\n const types = (await events).map(eventType);\n const equal =\n types.length === eventTypes.length &&\n types.every((t, i) => t === eventTypes[i]);\n if (!equal) {\n throw new Error(\n `expectStream(...).toEmitExactly: expected exactly ${JSON.stringify(\n eventTypes,\n )}, but stream emitted ${JSON.stringify(types)}`,\n );\n }\n return types;\n },\n };\n}\n\n/**\n * Parse an SSE `Response` and return its **last** event flattened to\n * `{ eventType, ...data }`.\n *\n * A convenience for one-shot assertions on a reply's final event; prefer\n * {@link expectStream} for multi-event ordering. It shares {@link parseSSEBody}\n * with `expectStream`, so the two never diverge on CRLF handling, comment\n * lines, or field parsing.\n *\n * @throws if the response carries no data-bearing event.\n */\nexport async function parseSSEResponse(response: Response): Promise<{\n eventType: string | null;\n [key: string]: unknown;\n}> {\n const text = await response.text();\n const events = parseSSEBody(text);\n const last = events.at(-1);\n\n if (!last) {\n throw new Error(`No data found in SSE response: ${text}`);\n }\n\n // `parseSSEBody` already spread the JSON payload's fields onto the event and\n // set `type` from the wire name. Re-key `type` -> `eventType` for this\n // helper's historical shape, dropping the internal `type` alias.\n const { type, ...rest } = last;\n return { eventType: type ?? null, ...rest };\n}\n"],"mappings":"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"}
|