@nightfuel-ai/app-sdk 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NightFUEL
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # @nightfuel-ai/app-sdk
2
+
3
+ `@nightfuel-ai/app-sdk` is for the **server process of a NightFUEL Cloud App**.
4
+ It uses the App's Cloud Run workload identity to reach the private NightFUEL
5
+ App gateway. It has no browser build and must never be placed in client code.
6
+
7
+ ```bash
8
+ npm install @nightfuel-ai/app-sdk
9
+ ```
10
+
11
+ Node 18 or newer. The package has no dependencies.
12
+
13
+ ```js
14
+ const { createNightfuelAppSdk } = require('@nightfuel-ai/app-sdk');
15
+
16
+ const nightfuel = createNightfuelAppSdk();
17
+
18
+ const inventory = await nightfuel.data.query({
19
+ bindingId: 'inventory',
20
+ operation: 'query',
21
+ collection: 'items',
22
+ filter: { active: true },
23
+ limit: 50,
24
+ });
25
+
26
+ await nightfuel.data.mutate({
27
+ bindingId: 'inventory',
28
+ operation: 'update',
29
+ collection: 'items',
30
+ filter: { sku: 'CAF-001' },
31
+ update: { available: 12 },
32
+ }, { idempotencyKey: requestId });
33
+
34
+ const run = await nightfuel.workflows.run('notify-low-stock', {
35
+ sku: 'CAF-001',
36
+ }, { idempotencyKey: requestId });
37
+
38
+ const { terminalRunStatus } = require('@nightfuel-ai/app-sdk');
39
+ for await (const event of nightfuel.workflows.stream('notify-low-stock', run.runId, {
40
+ maxReconnects: 4,
41
+ reconnectBaseDelayMs: 250,
42
+ idleTimeoutMs: 90_000,
43
+ })) {
44
+ if (event.event === 'agent-run:event') {
45
+ console.log(event.data.seq, event.data.type, event.data.payload);
46
+ }
47
+ const terminal = terminalRunStatus(event);
48
+ if (terminal) console.log(`run ${terminal}`);
49
+ }
50
+ ```
51
+
52
+ ## Runtime environment
53
+
54
+ A Cloud App receives these variables from the platform; the SDK reads them
55
+ unless you pass the matching option:
56
+
57
+ | Variable | Option | Meaning |
58
+ | --- | --- | --- |
59
+ | `NF_APP_ID` | `appId` | The App's id. |
60
+ | `NF_APP_RUNTIME_AUTH_MODE` | `authMode` | Must be `workload_identity_v1`. |
61
+ | `NF_APP_RUNTIME_API_URL` | `apiUrl` | The private App gateway. |
62
+ | `NF_APP_RUNTIME_AUDIENCE` | `audience` | The identity token audience. |
63
+ | `NF_APP_CALL_SECRET` | `secret` (in `verifyNightfuelCall`) | Verifies calls the platform makes into the App. |
64
+
65
+ ## Streams
66
+
67
+ The SSE envelope is the same as the persisted run timeline: the outer event is
68
+ `agent-run:event`, while lifecycle and delta names such as `run.completed` and
69
+ `assistant.delta` are in `event.data.type`. The iterator stops after yielding
70
+ `run.completed`, `run.failed`, or `run.cancelled`.
71
+
72
+ A dropped stream reconnects only the bound stream **GET**, using `afterSeq`
73
+ from the last event acknowledged by the iterator consumer. It never submits
74
+ `workflows.run` again. Retries are bounded and abortable; 401/403/404 responses
75
+ fail immediately, an idle connection uses its own deadline, and explicit slow
76
+ consumer drops resume from the confirmed cursor. An exhausted error includes
77
+ its machine-readable stream code and `afterSeq`. Persist `data.seq` after the
78
+ App has committed its own effect when process-restart recovery matters; SSE is
79
+ at-least-once, not exactly-once.
80
+
81
+ ## Calls from NightFUEL into the App
82
+
83
+ The Apphook workflow node signs each request it sends to the App in the
84
+ `X-NF-App-Call` header. `verifyNightfuelCall` checks the signature, expiry,
85
+ App, method, path and body hash, and returns the claims. It makes no network
86
+ call, so a unit test can prove the App rejects a forged request.
87
+
88
+ ```js
89
+ const { verifyNightfuelCall } = require('@nightfuel-ai/app-sdk');
90
+
91
+ app.post('/webhooks/nightfuel', express.raw({ type: '*/*' }), (req, res) => {
92
+ try {
93
+ const claims = verifyNightfuelCall({
94
+ headers: req.headers, method: 'POST', path: req.originalUrl, rawBody: req.body,
95
+ });
96
+ res.json({ ok: true, runId: claims.runId });
97
+ } catch (error) {
98
+ res.status(error.status || 401).json({ error: error.message });
99
+ }
100
+ });
101
+ ```
102
+
103
+ ## Bindings
104
+
105
+ The App reaches only what its bindings allow. A database binding is created in
106
+ Cloud → Apps and is either `read` or explicitly permissioned `read_write`.
107
+ `read_write` is limited to bounded `insert`, filtered `update`, and filtered
108
+ `delete` of records on that exact bound database. It never enables raw
109
+ SQL/Mongo commands, schema changes, index changes, or database credentials.
110
+ A workflow binding pins one workflow version under an App-local name; agents
111
+ create it with the `hosting` tools (`hosting_app_workflow_list_bindable`,
112
+ then `hosting_app_workflow_bindings_update`). `bindingId` is the only
113
+ identifier an App runtime may use; it can never select an arbitrary database,
114
+ workflow, workflow version, or user identity.
115
+
116
+ The managed Sandbox starter is a Vite frontend, not a production backend: it
117
+ uses the platform's same-origin preview proxies and does not receive this SDK
118
+ in browser source.
package/index.d.ts ADDED
@@ -0,0 +1,220 @@
1
+ export interface NightfuelAppSdkErrorOptions {
2
+ status?: number;
3
+ code?: string;
4
+ payload?: unknown;
5
+ }
6
+
7
+ export class NightfuelAppSdkError extends Error {
8
+ readonly status: number;
9
+ readonly code: string;
10
+ readonly payload: unknown;
11
+ readonly afterSeq?: number;
12
+ readonly reconnects?: number;
13
+ readonly cause?: unknown;
14
+ constructor(message: string, options?: NightfuelAppSdkErrorOptions);
15
+ }
16
+
17
+ export interface NightfuelAppSdkOptions {
18
+ appId?: string;
19
+ authMode?: 'workload_identity_v1';
20
+ apiUrl?: string;
21
+ audience?: string;
22
+ metadataUrl?: string;
23
+ env?: Record<string, string | undefined>;
24
+ fetch?: typeof fetch;
25
+ }
26
+
27
+ export interface WorkflowRunAccepted {
28
+ runId: string;
29
+ status: string;
30
+ replay?: boolean;
31
+ workflowId?: string;
32
+ workflowVersionId?: string;
33
+ workflowVersion?: number;
34
+ streamUrl?: string;
35
+ }
36
+
37
+ export type NightfuelRunLifecycleType =
38
+ | 'run.started'
39
+ | 'run.resumed'
40
+ | 'run.paused'
41
+ | 'run.completed'
42
+ | 'run.failed'
43
+ | 'run.cancelled';
44
+ export type NightfuelRunDeltaType = 'assistant.delta' | 'assistant.thinking' | 'message.delta' | 'message.token';
45
+
46
+ export interface NightfuelWorkflowEventData<TType extends string = string, TPayload = Record<string, unknown>> {
47
+ run_id: string;
48
+ seq: number;
49
+ type: TType;
50
+ timestamp: string;
51
+ payload: TPayload;
52
+ turn_id?: string;
53
+ action_id?: string;
54
+ [key: string]: unknown;
55
+ }
56
+
57
+ export interface NightfuelWorkflowLifecyclePayload extends Record<string, unknown> {
58
+ status?: string;
59
+ text?: string;
60
+ error?: string | Record<string, unknown> | null;
61
+ errorCode?: string | null;
62
+ resultSummary?: unknown;
63
+ }
64
+
65
+ export interface NightfuelWorkflowDeltaPayload extends Record<string, unknown> {
66
+ text?: string;
67
+ target?: string;
68
+ format?: string;
69
+ }
70
+
71
+ export type NightfuelKnownWorkflowEventData =
72
+ | NightfuelWorkflowEventData<NightfuelRunLifecycleType, NightfuelWorkflowLifecyclePayload>
73
+ | NightfuelWorkflowEventData<NightfuelRunDeltaType, NightfuelWorkflowDeltaPayload>
74
+ | NightfuelWorkflowEventData<'tool.started' | 'tool.output' | 'tool.output.available', Record<string, unknown>>
75
+ | NightfuelWorkflowEventData<
76
+ | 'workflow.node.started'
77
+ | 'workflow.node.retry'
78
+ | 'workflow.node.skipped'
79
+ | 'workflow.node.cancelled'
80
+ | 'workflow.node.completed',
81
+ Record<string, unknown>
82
+ >;
83
+
84
+ export interface NightfuelWorkflowDataEvent<T = NightfuelKnownWorkflowEventData | NightfuelWorkflowEventData> {
85
+ id: string;
86
+ event: 'agent-run:event';
87
+ data: T;
88
+ }
89
+
90
+ export interface NightfuelWorkflowReadyEvent {
91
+ id: string;
92
+ event: 'agent-run:ready';
93
+ data: { runId: string; afterSeq: number };
94
+ }
95
+
96
+ export interface NightfuelWorkflowHeartbeatEvent {
97
+ id: string;
98
+ event: 'agent-run:heartbeat';
99
+ data: {
100
+ runId: string;
101
+ lastSeq: number;
102
+ paused: boolean;
103
+ queueDepth: number;
104
+ eventsDropped: number;
105
+ [key: string]: unknown;
106
+ };
107
+ }
108
+
109
+ export interface NightfuelWorkflowDroppedEvent {
110
+ id: string;
111
+ event: 'agent-run:dropped';
112
+ data: {
113
+ runId: string;
114
+ reason: 'subscriber_queue_overflow' | string;
115
+ eventsDropped: number;
116
+ queueHighWater: number;
117
+ [key: string]: unknown;
118
+ };
119
+ }
120
+
121
+ export type NightfuelWorkflowEvent<T = NightfuelKnownWorkflowEventData | NightfuelWorkflowEventData> =
122
+ | NightfuelWorkflowDataEvent<T>
123
+ | NightfuelWorkflowReadyEvent
124
+ | NightfuelWorkflowHeartbeatEvent
125
+ | NightfuelWorkflowDroppedEvent;
126
+
127
+ export function terminalRunStatus(
128
+ event: NightfuelWorkflowEvent
129
+ ): 'completed' | 'failed' | 'cancelled' | null;
130
+
131
+ export interface NightfuelWorkflowStreamOptions {
132
+ /** Starts after a cursor already committed by the App. */
133
+ afterSeq?: number | string;
134
+ /** Reconnects allowed without another acknowledged durable event. Default 4. */
135
+ maxReconnects?: number;
136
+ /** Initial exponential reconnect delay. Default 250ms, capped at 30000ms. */
137
+ reconnectBaseDelayMs?: number;
138
+ /** Reconnect when no stream bytes arrive by this deadline. Default 90000ms. */
139
+ idleTimeoutMs?: number;
140
+ signal?: AbortSignal;
141
+ }
142
+
143
+ export interface NightfuelDataClient {
144
+ query<T = unknown>(input?: Record<string, unknown>): Promise<T>;
145
+ semantic<T = unknown>(input?: Record<string, unknown>): Promise<T>;
146
+ mutate<T = unknown>(
147
+ input: NightfuelDatabaseMutationInput,
148
+ options: { idempotencyKey: string }
149
+ ): Promise<T>;
150
+ }
151
+
152
+ export type NightfuelDatabaseMutationOperation = 'insert' | 'update' | 'delete';
153
+
154
+ export interface NightfuelDatabaseMutationInput {
155
+ bindingId: string;
156
+ operation: NightfuelDatabaseMutationOperation;
157
+ collection: string;
158
+ documents?: Array<Record<string, unknown>>;
159
+ filter?: Record<string, unknown>;
160
+ update?: Record<string, unknown>;
161
+ }
162
+
163
+ export interface NightfuelWorkflowClient {
164
+ run(
165
+ bindingId: string,
166
+ input: Record<string, unknown>,
167
+ options: { idempotencyKey: string }
168
+ ): Promise<WorkflowRunAccepted>;
169
+ stream<T = NightfuelKnownWorkflowEventData | NightfuelWorkflowEventData>(
170
+ bindingId: string,
171
+ runId: string,
172
+ options?: NightfuelWorkflowStreamOptions
173
+ ): AsyncGenerator<NightfuelWorkflowEvent<T>, void, undefined>;
174
+ }
175
+
176
+ export interface NightfuelAppSdk {
177
+ readonly appId: string;
178
+ readonly data: NightfuelDataClient;
179
+ readonly workflows: NightfuelWorkflowClient;
180
+ createIdempotencyKey(): string;
181
+ }
182
+
183
+ export function createNightfuelAppSdk(options?: NightfuelAppSdkOptions): NightfuelAppSdk;
184
+
185
+ /** The claims NightFUEL signs into an Apphook request. */
186
+ export interface NightfuelCallClaims {
187
+ v: 1;
188
+ appId: string;
189
+ projectId: string;
190
+ method: string;
191
+ path: string;
192
+ /** SHA-256 (hex) of the JSON body, or of the multipart `payload` field. */
193
+ bodySha256: string;
194
+ runId: string;
195
+ nodeId: string;
196
+ iat: number;
197
+ exp: number;
198
+ nonce: string;
199
+ }
200
+
201
+ /**
202
+ * Verifies a call NightFUEL made to this App and returns its claims.
203
+ * `rawBody` is the JSON body, or the multipart `payload` field, exactly as
204
+ * received -- parse it only after this returns. Pure: no network, no platform
205
+ * identity, so an App can prove it rejects a forged call in a unit test.
206
+ */
207
+ export function verifyNightfuelCall(input: {
208
+ headers: Headers | Record<string, string | string[] | undefined>;
209
+ /** Pin the method the token was issued for. Omit to skip the check. */
210
+ method?: string;
211
+ /** Pin the path the token was issued for. Omit to skip the check. */
212
+ path?: string;
213
+ /** Omit to skip the body check; pass the raw bytes to enforce it. */
214
+ rawBody?: string | Buffer;
215
+ /** Defaults to process.env.NF_APP_CALL_SECRET. */
216
+ secret?: string;
217
+ /** Defaults to process.env.NF_APP_ID. */
218
+ appId?: string;
219
+ now?: number;
220
+ }): NightfuelCallClaims;
package/index.js ADDED
@@ -0,0 +1,339 @@
1
+ 'use strict';
2
+
3
+ // @nightfuel-ai/app-sdk
4
+ //
5
+ // This package intentionally has no browser build. It authenticates with the
6
+ // Google metadata server available to the App's Cloud Run service account and
7
+ // calls only pre-bound NightFUEL App runtime endpoints.
8
+
9
+ const crypto = require('crypto');
10
+ const { parseSseFrame, recoverableSseStream, terminalRunStatus } = require('./stream');
11
+
12
+ const METADATA_IDENTITY_URL =
13
+ 'http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity';
14
+ const APP_ID_RE = /^[a-f\d]{24}$/i;
15
+ const BINDING_ID_RE = /^[a-z][a-z0-9_-]{0,63}$/;
16
+ const IDEMPOTENCY_KEY_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{7,255}$/;
17
+ const MUTATION_IDEMPOTENCY_KEY_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$/;
18
+
19
+ class NightfuelAppSdkError extends Error {
20
+ constructor(message, { status = 0, code = '', payload = null } = {}) {
21
+ super(message);
22
+ this.name = 'NightfuelAppSdkError';
23
+ this.status = status;
24
+ this.code = code;
25
+ this.payload = payload;
26
+ }
27
+ }
28
+
29
+ function requiredServerRuntime() {
30
+ if (typeof window !== 'undefined' || typeof document !== 'undefined') {
31
+ throw new NightfuelAppSdkError(
32
+ '@nightfuel-ai/app-sdk is server-only. Browser code must call its own App backend.',
33
+ { code: 'NIGHTFUEL_APP_SDK_BROWSER_FORBIDDEN' }
34
+ );
35
+ }
36
+ }
37
+
38
+ function httpsOrigin(value, name) {
39
+ const raw = String(value || '').trim();
40
+ let url;
41
+ try {
42
+ url = new URL(raw);
43
+ } catch {
44
+ throw new NightfuelAppSdkError(`${name} must be an HTTPS origin`, { code: 'NIGHTFUEL_APP_SDK_CONFIG_INVALID' });
45
+ }
46
+ if (url.protocol !== 'https:' || url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
47
+ throw new NightfuelAppSdkError(`${name} must be an HTTPS origin`, { code: 'NIGHTFUEL_APP_SDK_CONFIG_INVALID' });
48
+ }
49
+ return url.origin;
50
+ }
51
+
52
+ function requiredAppId(value) {
53
+ const appId = String(value || '').trim().toLowerCase();
54
+ if (!APP_ID_RE.test(appId)) {
55
+ throw new NightfuelAppSdkError('NF_APP_ID must be a Hosted App ObjectId', { code: 'NIGHTFUEL_APP_SDK_CONFIG_INVALID' });
56
+ }
57
+ return appId;
58
+ }
59
+
60
+ function bindingId(value) {
61
+ const normalized = String(value || '').trim();
62
+ if (!BINDING_ID_RE.test(normalized)) {
63
+ throw new NightfuelAppSdkError('workflow bindingId is invalid', { code: 'NIGHTFUEL_APP_SDK_BINDING_INVALID' });
64
+ }
65
+ return normalized;
66
+ }
67
+
68
+ function idempotencyKey(value) {
69
+ const normalized = String(value || '').trim();
70
+ if (!IDEMPOTENCY_KEY_RE.test(normalized)) {
71
+ throw new NightfuelAppSdkError(
72
+ 'workflows.run requires a stable 8-256 character idempotencyKey; reuse it for transport retries.',
73
+ { code: 'NIGHTFUEL_APP_SDK_IDEMPOTENCY_KEY_INVALID' }
74
+ );
75
+ }
76
+ return normalized;
77
+ }
78
+
79
+ function mutationIdempotencyKey(value) {
80
+ const normalized = String(value || '').trim();
81
+ if (!MUTATION_IDEMPOTENCY_KEY_RE.test(normalized)) {
82
+ throw new NightfuelAppSdkError(
83
+ 'data.mutate requires a stable 8-128 character idempotencyKey; reuse it for transport retries.',
84
+ { code: 'NIGHTFUEL_APP_SDK_MUTATION_IDEMPOTENCY_KEY_INVALID' }
85
+ );
86
+ }
87
+ return normalized;
88
+ }
89
+
90
+ function parseJwtExpiry(token) {
91
+ const part = String(token || '').split('.')[1];
92
+ if (!part) return 0;
93
+ try {
94
+ const payload = JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
95
+ return Number(payload?.exp || 0) * 1000;
96
+ } catch {
97
+ return 0;
98
+ }
99
+ }
100
+
101
+ async function responsePayload(response) {
102
+ const contentType = String(response.headers?.get?.('content-type') || '').toLowerCase();
103
+ const text = await response.text();
104
+ if (!text) return null;
105
+ if (contentType.includes('application/json')) {
106
+ try {
107
+ return JSON.parse(text);
108
+ } catch {
109
+ return { raw: text };
110
+ }
111
+ }
112
+ return { raw: text };
113
+ }
114
+
115
+ function createNightfuelAppSdk(options = {}) {
116
+ requiredServerRuntime();
117
+ const env = options.env || process.env;
118
+ const fetchImpl = options.fetch || globalThis.fetch;
119
+ if (typeof fetchImpl !== 'function') {
120
+ throw new NightfuelAppSdkError('A fetch implementation is required', { code: 'NIGHTFUEL_APP_SDK_FETCH_REQUIRED' });
121
+ }
122
+ const appId = requiredAppId(options.appId || env.NF_APP_ID);
123
+ const authMode = String(options.authMode || env.NF_APP_RUNTIME_AUTH_MODE || '').trim();
124
+ if (authMode !== 'workload_identity_v1') {
125
+ throw new NightfuelAppSdkError(
126
+ '@nightfuel-ai/app-sdk requires NF_APP_RUNTIME_AUTH_MODE=workload_identity_v1',
127
+ { code: 'NIGHTFUEL_APP_SDK_AUTH_MODE_INVALID' }
128
+ );
129
+ }
130
+ const apiUrl = httpsOrigin(options.apiUrl || env.NF_APP_RUNTIME_API_URL, 'NF_APP_RUNTIME_API_URL');
131
+ const audience = httpsOrigin(options.audience || env.NF_APP_RUNTIME_AUDIENCE, 'NF_APP_RUNTIME_AUDIENCE');
132
+ const metadataUrl = String(options.metadataUrl || METADATA_IDENTITY_URL).trim();
133
+ let cachedToken = null;
134
+
135
+ async function workloadIdentityToken(signal) {
136
+ signal?.throwIfAborted();
137
+ if (cachedToken?.expiresAt > Date.now() + 300_000) return cachedToken.token;
138
+ const response = await fetchImpl(
139
+ `${metadataUrl}?audience=${encodeURIComponent(audience)}&format=full`,
140
+ { headers: { 'Metadata-Flavor': 'Google' }, signal }
141
+ );
142
+ signal?.throwIfAborted();
143
+ if (!response.ok) {
144
+ throw new NightfuelAppSdkError(`Cloud Run workload identity request failed (${response.status})`, {
145
+ status: response.status,
146
+ code: 'NIGHTFUEL_APP_SDK_IDENTITY_UNAVAILABLE',
147
+ });
148
+ }
149
+ const token = String(await response.text()).trim();
150
+ const expiresAt = parseJwtExpiry(token);
151
+ if (!token || !expiresAt || expiresAt <= Date.now()) {
152
+ throw new NightfuelAppSdkError('Cloud Run workload identity token is malformed or expired', {
153
+ code: 'NIGHTFUEL_APP_SDK_IDENTITY_INVALID',
154
+ });
155
+ }
156
+ cachedToken = { token, expiresAt };
157
+ return token;
158
+ }
159
+
160
+ async function request(path, init = {}, { allowErrorResponse = false } = {}) {
161
+ init.signal?.throwIfAborted();
162
+ const headers = new Headers(init.headers || {});
163
+ headers.set('Authorization', `Bearer ${await workloadIdentityToken(init.signal)}`);
164
+ init.signal?.throwIfAborted();
165
+ headers.set('Accept', headers.get('Accept') || 'application/json');
166
+ const response = await fetchImpl(`${apiUrl}${path}`, { ...init, headers });
167
+ init.signal?.throwIfAborted();
168
+ if (!response.ok && !allowErrorResponse) {
169
+ const payload = await responsePayload(response);
170
+ throw new NightfuelAppSdkError(
171
+ payload?.error || `NightFUEL App gateway request failed (${response.status})`,
172
+ { status: response.status, code: payload?.code || 'NIGHTFUEL_APP_SDK_GATEWAY_ERROR', payload }
173
+ );
174
+ }
175
+ return response;
176
+ }
177
+
178
+ async function postJson(path, body, headers = {}) {
179
+ const response = await request(path, {
180
+ method: 'POST',
181
+ headers: { 'Content-Type': 'application/json', ...headers },
182
+ body: JSON.stringify(body),
183
+ });
184
+ return responsePayload(response);
185
+ }
186
+
187
+ return Object.freeze({
188
+ appId,
189
+ data: Object.freeze({
190
+ query: (input) => postJson(`/internal/hosting/apps/${appId}/data/query`, input || {}),
191
+ semantic: (input) => postJson(`/internal/hosting/apps/${appId}/data/semantic`, input || {}),
192
+ // Keep the public async contract consistent with the other gateway
193
+ // methods: invalid keys are a rejected Promise instead of a synchronous
194
+ // throw while evaluating an argument to postJson.
195
+ async mutate(input, options = {}) {
196
+ const key = mutationIdempotencyKey(options.idempotencyKey);
197
+ return postJson(
198
+ `/internal/hosting/apps/${appId}/data/mutate`,
199
+ input || {},
200
+ { 'Idempotency-Key': key }
201
+ );
202
+ },
203
+ }),
204
+ workflows: Object.freeze({
205
+ async run(rawBindingId, input = {}, options = {}) {
206
+ const resolvedBindingId = bindingId(rawBindingId);
207
+ const key = idempotencyKey(options.idempotencyKey);
208
+ return postJson(
209
+ `/internal/hosting/apps/${appId}/workflows/${encodeURIComponent(resolvedBindingId)}/runs`,
210
+ { input },
211
+ { 'Idempotency-Key': key }
212
+ );
213
+ },
214
+ async *stream(rawBindingId, runId, options = {}) {
215
+ const resolvedBindingId = bindingId(rawBindingId);
216
+ const normalizedRunId = String(runId || '').trim();
217
+ if (!normalizedRunId) {
218
+ throw new NightfuelAppSdkError('workflow runId is required', { code: 'NIGHTFUEL_APP_SDK_RUN_ID_REQUIRED' });
219
+ }
220
+ const hadExplicitCursor = options.afterSeq !== undefined && options.afterSeq !== null;
221
+ yield* recoverableSseStream({
222
+ runId: normalizedRunId,
223
+ afterSeq: options.afterSeq,
224
+ signal: options.signal,
225
+ maxReconnects: options.maxReconnects,
226
+ reconnectBaseDelayMs: options.reconnectBaseDelayMs,
227
+ idleTimeoutMs: options.idleTimeoutMs,
228
+ createError(message, details) {
229
+ return new NightfuelAppSdkError(message, {
230
+ status: details.status,
231
+ code: details.code,
232
+ payload: details.payload,
233
+ });
234
+ },
235
+ open: ({ afterSeq, initial, signal }) => {
236
+ const query = !initial || hadExplicitCursor
237
+ ? `?afterSeq=${encodeURIComponent(String(afterSeq))}`
238
+ : '';
239
+ return request(
240
+ `/internal/hosting/apps/${appId}/workflows/${encodeURIComponent(resolvedBindingId)}/runs/${encodeURIComponent(normalizedRunId)}/events/stream${query}`,
241
+ { method: 'GET', headers: { Accept: 'text/event-stream' }, signal },
242
+ { allowErrorResponse: true }
243
+ );
244
+ },
245
+ });
246
+ },
247
+ }),
248
+ createIdempotencyKey: () => crypto.randomUUID(),
249
+ });
250
+ }
251
+
252
+ // ── Calls FROM NightFUEL (the Call App workflow node) ─────────────────────
253
+ //
254
+ // The platform signs every call with the App's NF_APP_CALL_SECRET:
255
+ // X-NF-App-Call: <base64url(claims)>.<base64url(HMAC-SHA256(secret, claims))>
256
+ // claims = { v: 1, appId, projectId, method, path, bodySha256, runId, nodeId, iat, exp, nonce }
257
+ // `bodySha256` is the SHA-256 (hex) of the JSON payload: the raw body for JSON
258
+ // requests, or the `payload` field for multipart requests, whose `files[]`
259
+ // entries carry each attachment's own sha256. Verify before acting.
260
+ const APP_CALL_TOKEN_VERSION = 1;
261
+ const APP_CALL_CLOCK_SKEW_MS = 30_000;
262
+
263
+ function callVerificationError(message, code) {
264
+ return new NightfuelAppSdkError(message, { status: 401, code });
265
+ }
266
+
267
+ function sha256Hex(value) {
268
+ const bytes = Buffer.isBuffer(value) ? value : Buffer.from(value === undefined || value === null ? '' : String(value), 'utf8');
269
+ return crypto.createHash('sha256').update(bytes).digest('hex');
270
+ }
271
+
272
+ function headerValue(headers, name) {
273
+ if (!headers) return '';
274
+ if (typeof headers.get === 'function') return String(headers.get(name) || '');
275
+ const key = Object.keys(headers).find((candidate) => candidate.toLowerCase() === name.toLowerCase());
276
+ const value = key ? headers[key] : '';
277
+ return String(Array.isArray(value) ? value[0] : value || '');
278
+ }
279
+
280
+ /**
281
+ * Verifies a call made by NightFUEL to this App. `rawBody` is the JSON body
282
+ * (or the multipart `payload` field) exactly as received. Returns the claims.
283
+ */
284
+ function verifyNightfuelCall({ headers, method, path, rawBody, secret, appId, now = Date.now() } = {}) {
285
+ const resolvedSecret = String(secret || process.env.NF_APP_CALL_SECRET || '');
286
+ if (!resolvedSecret) {
287
+ throw new NightfuelAppSdkError('NF_APP_CALL_SECRET is not configured for this App', { status: 503, code: 'NIGHTFUEL_APP_CALL_SECRET_MISSING' });
288
+ }
289
+ const token = headerValue(headers, 'x-nf-app-call').trim();
290
+ const [payload, signed, extra] = token.split('.');
291
+ if (!payload || !signed || extra) throw callVerificationError('Missing or malformed X-NF-App-Call token', 'NIGHTFUEL_APP_CALL_TOKEN_INVALID');
292
+ const expected = crypto.createHmac('sha256', resolvedSecret).update(payload).digest('base64url');
293
+ const left = Buffer.from(signed, 'utf8');
294
+ const right = Buffer.from(expected, 'utf8');
295
+ if (left.length !== right.length || !crypto.timingSafeEqual(left, right)) {
296
+ throw callVerificationError('X-NF-App-Call signature is invalid', 'NIGHTFUEL_APP_CALL_TOKEN_INVALID');
297
+ }
298
+ let claims;
299
+ try {
300
+ claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
301
+ } catch {
302
+ throw callVerificationError('X-NF-App-Call payload is invalid', 'NIGHTFUEL_APP_CALL_TOKEN_INVALID');
303
+ }
304
+ if (claims.v !== APP_CALL_TOKEN_VERSION || Number(claims.exp) <= now || Number(claims.iat) > now + APP_CALL_CLOCK_SKEW_MS) {
305
+ throw callVerificationError('X-NF-App-Call token has expired', 'NIGHTFUEL_APP_CALL_TOKEN_EXPIRED');
306
+ }
307
+ const expectedAppId = String(appId || process.env.NF_APP_ID || '').trim().toLowerCase();
308
+ if (expectedAppId && String(claims.appId || '').toLowerCase() !== expectedAppId) {
309
+ throw new NightfuelAppSdkError('X-NF-App-Call token belongs to another App', { status: 403, code: 'NIGHTFUEL_APP_CALL_TOKEN_SCOPE_MISMATCH' });
310
+ }
311
+ if (method !== undefined && claims.method !== String(method || '').trim().toUpperCase()) {
312
+ throw new NightfuelAppSdkError('X-NF-App-Call token was issued for another method', { status: 403, code: 'NIGHTFUEL_APP_CALL_TOKEN_SCOPE_MISMATCH' });
313
+ }
314
+ if (path !== undefined) {
315
+ const normalizedPath = String(path || '/').trim();
316
+ if (claims.path !== (normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`)) {
317
+ throw new NightfuelAppSdkError('X-NF-App-Call token was issued for another path', { status: 403, code: 'NIGHTFUEL_APP_CALL_TOKEN_SCOPE_MISMATCH' });
318
+ }
319
+ }
320
+ if (rawBody !== undefined && String(claims.bodySha256) !== sha256Hex(rawBody)) {
321
+ throw new NightfuelAppSdkError('X-NF-App-Call token does not match the request body', { status: 403, code: 'NIGHTFUEL_APP_CALL_TOKEN_SCOPE_MISMATCH' });
322
+ }
323
+ return claims;
324
+ }
325
+
326
+ module.exports = {
327
+ NightfuelAppSdkError,
328
+ createNightfuelAppSdk,
329
+ terminalRunStatus,
330
+ verifyNightfuelCall,
331
+ _test: {
332
+ bindingId,
333
+ httpsOrigin,
334
+ idempotencyKey,
335
+ mutationIdempotencyKey,
336
+ parseJwtExpiry,
337
+ parseSseFrame,
338
+ },
339
+ };
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@nightfuel-ai/app-sdk",
3
+ "version": "0.1.0",
4
+ "description": "Server-only NightFUEL SDK for a hosted App workload identity.",
5
+ "main": "index.js",
6
+ "types": "index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./index.d.ts",
10
+ "default": "./index.js"
11
+ },
12
+ "./package.json": "./package.json"
13
+ },
14
+ "files": [
15
+ "index.js",
16
+ "index.d.ts",
17
+ "stream.js",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "engines": {
22
+ "node": ">=18"
23
+ },
24
+ "license": "MIT",
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/KrozraM/NightFUEL.git",
31
+ "directory": "Backend/nightfuel_api/hosting/app-sdk"
32
+ },
33
+ "keywords": [
34
+ "nightfuel",
35
+ "apps",
36
+ "workload-identity",
37
+ "workflows",
38
+ "sdk"
39
+ ]
40
+ }
package/stream.js ADDED
@@ -0,0 +1,437 @@
1
+ 'use strict';
2
+
3
+ // This file is also vendored byte-for-byte into hosting/app-sdk/stream.js.
4
+ // The two packages are published independently, so a runtime relative import
5
+ // would break one of the standalone artifacts. tests/sdk_stream_recovery.test
6
+ // enforces parity between the two copies.
7
+
8
+ const DEFAULT_MAX_RECONNECTS = 4;
9
+ const DEFAULT_RECONNECT_BASE_DELAY_MS = 250;
10
+ const DEFAULT_IDLE_TIMEOUT_MS = 90_000;
11
+ const MAX_RECONNECT_DELAY_MS = 30_000;
12
+ const MAX_FRAME_BYTES = 8 * 1024 * 1024;
13
+ const RETRYABLE_HTTP_STATUSES = new Set([429, 502, 503, 504]);
14
+ const TERMINAL_RUN_EVENT_TYPES = new Set(['run.completed', 'run.failed', 'run.cancelled']);
15
+
16
+ function parseSseFrame(frame) {
17
+ const lines = String(frame || '').replace(/\r/g, '').split('\n');
18
+ let event = 'message';
19
+ let id = '';
20
+ const dataLines = [];
21
+ for (const line of lines) {
22
+ if (!line || line.startsWith(':')) continue;
23
+ const separator = line.indexOf(':');
24
+ const field = separator < 0 ? line : line.slice(0, separator);
25
+ const value = (separator < 0 ? '' : line.slice(separator + 1)).replace(/^ /, '');
26
+ if (field === 'event') event = value || event;
27
+ else if (field === 'id') id = value;
28
+ else if (field === 'data') dataLines.push(value);
29
+ }
30
+ const rawData = dataLines.join('\n');
31
+ let data = rawData;
32
+ if (rawData) {
33
+ try { data = JSON.parse(rawData); } catch { /* preserve non-JSON control events */ }
34
+ }
35
+ return { event, id, data };
36
+ }
37
+
38
+ function integerOption(value, fallback, name, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) {
39
+ if (value === undefined || value === null) return fallback;
40
+ const number = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value;
41
+ if (!Number.isSafeInteger(number) || number < min || number > max) {
42
+ const error = new TypeError(`${name} must be an integer from ${min} to ${max}`);
43
+ error.code = 'NIGHTFUEL_STREAM_OPTIONS_INVALID';
44
+ throw error;
45
+ }
46
+ return number;
47
+ }
48
+
49
+ function withDetails(error, details = {}) {
50
+ if (!error || typeof error !== 'object') return error;
51
+ if (details.cause !== undefined && error.cause === undefined) error.cause = details.cause;
52
+ if (details.afterSeq !== undefined) error.afterSeq = details.afterSeq;
53
+ if (details.reconnects !== undefined) error.reconnects = details.reconnects;
54
+ if (details.payload !== undefined && error.payload === undefined) error.payload = details.payload;
55
+ return error;
56
+ }
57
+
58
+ function defaultError(message, details = {}) {
59
+ const error = new Error(message);
60
+ error.name = 'NightfuelStreamError';
61
+ error.status = Number(details.status || 0);
62
+ error.code = String(details.code || 'NIGHTFUEL_STREAM_FAILED');
63
+ return withDetails(error, details);
64
+ }
65
+
66
+ function makeError(createError, message, details = {}) {
67
+ return withDetails((createError || defaultError)(message, details), details);
68
+ }
69
+
70
+ function abortableSleep(ms, signal) {
71
+ signal?.throwIfAborted();
72
+ return new Promise((resolve, reject) => {
73
+ const onAbort = () => {
74
+ clearTimeout(timer);
75
+ signal.removeEventListener('abort', onAbort);
76
+ reject(signal.reason);
77
+ };
78
+ const timer = setTimeout(() => {
79
+ signal?.removeEventListener('abort', onAbort);
80
+ resolve();
81
+ }, ms);
82
+ timer.unref?.();
83
+ signal?.addEventListener('abort', onAbort, { once: true });
84
+ });
85
+ }
86
+
87
+ function reconnectDelay(error, reconnects, baseDelayMs) {
88
+ if (Number.isFinite(error?.retryAfterMs) && error.retryAfterMs >= 0) {
89
+ return Math.min(error.retryAfterMs, MAX_RECONNECT_DELAY_MS);
90
+ }
91
+ const exponential = Math.min(baseDelayMs * (2 ** Math.max(0, reconnects - 1)), MAX_RECONNECT_DELAY_MS);
92
+ const jitter = baseDelayMs > 0 ? Math.floor(Math.random() * baseDelayMs) : 0;
93
+ return Math.min(exponential + jitter, MAX_RECONNECT_DELAY_MS);
94
+ }
95
+
96
+ function retryAfterMs(response) {
97
+ const raw = response?.headers?.get?.('retry-after');
98
+ if (raw == null || raw.trim() === '') return null;
99
+ const seconds = Number(raw);
100
+ return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : null;
101
+ }
102
+
103
+ async function errorPayload(response) {
104
+ try {
105
+ const text = await response.text();
106
+ if (!text) return null;
107
+ try { return JSON.parse(text); } catch { return { raw: text }; }
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ function responseError(createError, response, payload, afterSeq, reconnects) {
114
+ const status = Number(response?.status || 0);
115
+ const auth = status === 401 || status === 403;
116
+ const error = makeError(
117
+ createError,
118
+ payload?.error || `NightFUEL event stream request failed (${status || 'unknown status'})`,
119
+ {
120
+ status,
121
+ code: payload?.code || (auth ? 'NIGHTFUEL_STREAM_AUTH_FAILED' : 'NIGHTFUEL_STREAM_FAILED'),
122
+ payload,
123
+ afterSeq,
124
+ reconnects,
125
+ }
126
+ );
127
+ error.retryable = RETRYABLE_HTTP_STATUSES.has(status);
128
+ error.retryAfterMs = retryAfterMs(response);
129
+ return error;
130
+ }
131
+
132
+ function protocolError(createError, message, afterSeq, reconnects, payload = null) {
133
+ const error = makeError(createError, message, {
134
+ code: 'NIGHTFUEL_STREAM_PROTOCOL_ERROR', payload, afterSeq, reconnects,
135
+ });
136
+ error.retryable = false;
137
+ return error;
138
+ }
139
+
140
+ function recoverableError(createError, code, message, afterSeq, reconnects, cause = null, payload = null) {
141
+ const error = makeError(createError, message, {
142
+ code, cause, payload, afterSeq, reconnects,
143
+ });
144
+ error.retryable = true;
145
+ return error;
146
+ }
147
+
148
+ function eventSequence(frame, { runId, afterSeq, reconnects, createError }) {
149
+ const data = frame?.data;
150
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
151
+ throw protocolError(createError, 'agent-run:event data must be a JSON object', afterSeq, reconnects, data);
152
+ }
153
+ const seq = data.seq;
154
+ if (!Number.isSafeInteger(seq) || seq <= 0 || frame.id !== String(seq)) {
155
+ throw protocolError(
156
+ createError,
157
+ 'agent-run:event must carry the same positive integer in SSE id and data.seq',
158
+ afterSeq,
159
+ reconnects,
160
+ data
161
+ );
162
+ }
163
+ if (typeof data.type !== 'string' || !data.type.trim()) {
164
+ throw protocolError(createError, 'agent-run:event data.type is required', afterSeq, reconnects, data);
165
+ }
166
+ const eventRunId = String(data.run_id || '').trim();
167
+ if (eventRunId !== runId) {
168
+ throw protocolError(createError, 'agent-run:event belongs to another run', afterSeq, reconnects, data);
169
+ }
170
+ return seq;
171
+ }
172
+
173
+ function attemptController(parentSignal) {
174
+ parentSignal?.throwIfAborted();
175
+ const controller = new AbortController();
176
+ const onAbort = () => controller.abort(parentSignal.reason);
177
+ parentSignal?.addEventListener('abort', onAbort, { once: true });
178
+ return {
179
+ controller,
180
+ cleanup() { parentSignal?.removeEventListener('abort', onAbort); },
181
+ };
182
+ }
183
+
184
+ function boundedStep(operation, { signal, idleTimeoutMs, onIdle }) {
185
+ signal.throwIfAborted();
186
+ return new Promise((resolve, reject) => {
187
+ let settled = false;
188
+ const finish = (fn, value) => {
189
+ if (settled) return;
190
+ settled = true;
191
+ clearTimeout(timer);
192
+ signal.removeEventListener('abort', onAbort);
193
+ fn(value);
194
+ };
195
+ const onAbort = () => finish(reject, signal.reason);
196
+ const timer = setTimeout(() => finish(reject, onIdle()), idleTimeoutMs);
197
+ timer.unref?.();
198
+ signal.addEventListener('abort', onAbort, { once: true });
199
+ Promise.resolve().then(operation).then(
200
+ (value) => finish(resolve, value),
201
+ (error) => finish(reject, error)
202
+ );
203
+ });
204
+ }
205
+
206
+ function discardTransport(opened, iterator) {
207
+ // An async generator may be stuck in next(), in which case return() also
208
+ // waits forever. Abort the transport separately and never await its teardown.
209
+ try { Promise.resolve(iterator?.return?.()).catch(() => {}); } catch { /* closed */ }
210
+ try { opened?.cleanup?.(); } catch { /* already discarded */ }
211
+ }
212
+
213
+ function shouldRetry(error) {
214
+ if (!error || typeof error !== 'object') return true;
215
+ if (error.retryable === false) return false;
216
+ if ([401, 403, 404].includes(Number(error.status || 0))) return false;
217
+ if (error.code === 'NIGHTFUEL_STREAM_PROTOCOL_ERROR') return false;
218
+ return true;
219
+ }
220
+
221
+ function terminalRunStatus(frame) {
222
+ if (frame?.event !== 'agent-run:event') return null;
223
+ const type = frame?.data?.type;
224
+ if (type === 'run.completed') return 'completed';
225
+ if (type === 'run.failed') return 'failed';
226
+ if (type === 'run.cancelled') return 'cancelled';
227
+ return null;
228
+ }
229
+
230
+ /**
231
+ * Reconnects an SSE GET from the last event the consumer confirmed.
232
+ *
233
+ * Confirmation happens after `yield`: the cursor advances only when the
234
+ * consumer asks this generator for its next value. If consumer code throws or
235
+ * returns while handling an event, that event remains unconfirmed and is safe
236
+ * to replay from the caller's persisted cursor.
237
+ */
238
+ async function* recoverableSseStream(options = {}) {
239
+ if (typeof options.open !== 'function') throw new TypeError('open must be a function');
240
+ const runId = String(options.runId || '').trim();
241
+ if (!runId) throw new TypeError('runId is required');
242
+ const createError = options.createError || defaultError;
243
+ let confirmedSeq = integerOption(options.afterSeq, 0, 'afterSeq');
244
+ const maxReconnects = integerOption(options.maxReconnects, DEFAULT_MAX_RECONNECTS, 'maxReconnects', { max: 100 });
245
+ const reconnectBaseDelayMs = integerOption(
246
+ options.reconnectBaseDelayMs,
247
+ DEFAULT_RECONNECT_BASE_DELAY_MS,
248
+ 'reconnectBaseDelayMs',
249
+ { max: MAX_RECONNECT_DELAY_MS }
250
+ );
251
+ const idleTimeoutMs = integerOption(
252
+ options.idleTimeoutMs,
253
+ DEFAULT_IDLE_TIMEOUT_MS,
254
+ 'idleTimeoutMs',
255
+ { min: 1, max: 86_400_000 }
256
+ );
257
+ const callerSignal = options.signal;
258
+ let reconnects = 0;
259
+ let lastError = null;
260
+
261
+ while (true) {
262
+ callerSignal?.throwIfAborted();
263
+ const attempt = attemptController(callerSignal);
264
+ let opened = null;
265
+ let iterator = null;
266
+ const deadline = {
267
+ signal: attempt.controller.signal,
268
+ idleTimeoutMs,
269
+ onIdle: () => {
270
+ const idle = recoverableError(createError, 'NIGHTFUEL_STREAM_IDLE_TIMEOUT',
271
+ `NightFUEL event stream was idle for ${idleTimeoutMs}ms`, confirmedSeq, reconnects);
272
+ attempt.controller.abort(idle);
273
+ return idle;
274
+ },
275
+ };
276
+ try {
277
+ opened = await boundedStep(async () => {
278
+ const connection = await options.open({
279
+ afterSeq: confirmedSeq,
280
+ initial: reconnects === 0 && confirmedSeq === Number(options.afterSeq || 0),
281
+ reconnects,
282
+ signal: attempt.controller.signal,
283
+ });
284
+ if (attempt.controller.signal.aborted) {
285
+ const body = (connection?.response || connection)?.body;
286
+ discardTransport(connection, body?.[Symbol.asyncIterator]?.());
287
+ attempt.controller.signal.throwIfAborted();
288
+ }
289
+ return connection;
290
+ }, deadline);
291
+ attempt.controller.signal.throwIfAborted();
292
+ const response = opened?.response || opened;
293
+ if (!response || typeof response.ok !== 'boolean') {
294
+ throw protocolError(createError, 'The event stream returned no HTTP response', confirmedSeq, reconnects);
295
+ }
296
+ if (!response.ok) {
297
+ // Even a broken error body must retain its HTTP authorization status.
298
+ let payload = null;
299
+ try { payload = await boundedStep(() => errorPayload(response), deadline); } catch { /* use HTTP status */ }
300
+ throw responseError(createError, response, payload, confirmedSeq, reconnects);
301
+ }
302
+ if (!response.body || typeof response.body[Symbol.asyncIterator] !== 'function') {
303
+ throw protocolError(createError, 'The event stream has no async response body', confirmedSeq, reconnects);
304
+ }
305
+
306
+ iterator = response.body[Symbol.asyncIterator]();
307
+ const decoder = new TextDecoder();
308
+ let buffer = '';
309
+ while (true) {
310
+ const chunk = await boundedStep(() => iterator.next(), deadline);
311
+ if (chunk.done) {
312
+ buffer += decoder.decode();
313
+ if (buffer.trim()) {
314
+ throw recoverableError(
315
+ createError,
316
+ 'NIGHTFUEL_STREAM_TRUNCATED',
317
+ 'NightFUEL event stream ended in the middle of an SSE frame',
318
+ confirmedSeq,
319
+ reconnects
320
+ );
321
+ }
322
+ throw recoverableError(
323
+ createError,
324
+ 'NIGHTFUEL_STREAM_DROPPED',
325
+ 'NightFUEL event stream closed before a terminal run event',
326
+ confirmedSeq,
327
+ reconnects
328
+ );
329
+ }
330
+
331
+ buffer += typeof chunk.value === 'string'
332
+ ? chunk.value
333
+ : decoder.decode(chunk.value, { stream: true });
334
+ let boundary;
335
+ while ((boundary = /\r?\n\r?\n/.exec(buffer))) {
336
+ attempt.controller.signal.throwIfAborted();
337
+ const rawFrame = buffer.slice(0, boundary.index);
338
+ buffer = buffer.slice(boundary.index + boundary[0].length);
339
+ if (Buffer.byteLength(rawFrame, 'utf8') > MAX_FRAME_BYTES) {
340
+ throw protocolError(createError, 'NightFUEL stream frame exceeds 8 MiB', confirmedSeq, reconnects);
341
+ }
342
+ if (!rawFrame || rawFrame.split(/\r?\n/).every((line) => !line || line.startsWith(':'))) continue;
343
+ const frame = parseSseFrame(rawFrame);
344
+
345
+ if (frame.event === 'agent-run:error') {
346
+ const error = recoverableError(
347
+ createError,
348
+ frame.data?.code || 'NIGHTFUEL_STREAM_SERVER_ERROR',
349
+ frame.data?.error || 'NightFUEL event stream failed after opening',
350
+ confirmedSeq,
351
+ reconnects,
352
+ null,
353
+ frame.data
354
+ );
355
+ if (frame.data?.recoverable === false) error.retryable = false;
356
+ throw error;
357
+ }
358
+ if (frame.event === 'agent-run:dropped') {
359
+ yield frame;
360
+ throw recoverableError(
361
+ createError,
362
+ 'NIGHTFUEL_STREAM_DROPPED',
363
+ 'NightFUEL dropped a slow event subscriber; resuming from the confirmed cursor',
364
+ confirmedSeq,
365
+ reconnects,
366
+ null,
367
+ frame.data
368
+ );
369
+ }
370
+ if (frame.event !== 'agent-run:event') {
371
+ yield frame;
372
+ continue;
373
+ }
374
+
375
+ const seq = eventSequence(frame, { runId, afterSeq: confirmedSeq, reconnects, createError });
376
+ if (seq <= confirmedSeq) continue;
377
+ if (seq !== confirmedSeq + 1) {
378
+ throw recoverableError(
379
+ createError,
380
+ 'NIGHTFUEL_STREAM_GAP',
381
+ `NightFUEL event stream expected seq ${confirmedSeq + 1} but received ${seq}`,
382
+ confirmedSeq,
383
+ reconnects,
384
+ null,
385
+ frame.data
386
+ );
387
+ }
388
+
389
+ const terminal = TERMINAL_RUN_EVENT_TYPES.has(frame.data.type);
390
+ yield frame;
391
+ // Reaching this line is the acknowledgement boundary: the consumer
392
+ // asked for another event after receiving this one.
393
+ confirmedSeq = seq;
394
+ reconnects = 0;
395
+ if (terminal) return;
396
+ }
397
+ if (Buffer.byteLength(buffer, 'utf8') > MAX_FRAME_BYTES) {
398
+ throw protocolError(createError, 'NightFUEL stream frame exceeds 8 MiB', confirmedSeq, reconnects);
399
+ }
400
+ }
401
+ } catch (error) {
402
+ callerSignal?.throwIfAborted();
403
+ const classified = shouldRetry(error)
404
+ && !error?.code
405
+ && !Number(error?.status || 0)
406
+ ? recoverableError(
407
+ createError,
408
+ 'NIGHTFUEL_STREAM_DROPPED',
409
+ 'NightFUEL event stream transport disconnected',
410
+ confirmedSeq,
411
+ reconnects,
412
+ error
413
+ )
414
+ : error;
415
+ lastError = classified;
416
+ if (!shouldRetry(classified) || reconnects >= maxReconnects) {
417
+ throw withDetails(classified, { afterSeq: confirmedSeq, reconnects });
418
+ }
419
+ reconnects += 1;
420
+ } finally {
421
+ if (!attempt.controller.signal.aborted) attempt.controller.abort();
422
+ discardTransport(opened, iterator);
423
+ attempt.cleanup();
424
+ }
425
+
426
+ await abortableSleep(reconnectDelay(lastError, reconnects, reconnectBaseDelayMs), callerSignal);
427
+ }
428
+ }
429
+
430
+ module.exports = {
431
+ DEFAULT_IDLE_TIMEOUT_MS,
432
+ DEFAULT_MAX_RECONNECTS,
433
+ DEFAULT_RECONNECT_BASE_DELAY_MS,
434
+ parseSseFrame,
435
+ recoverableSseStream,
436
+ terminalRunStatus,
437
+ };