@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 +21 -0
- package/README.md +118 -0
- package/index.d.ts +220 -0
- package/index.js +339 -0
- package/package.json +40 -0
- package/stream.js +437 -0
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
|
+
};
|