@nightfuel-ai/app-sdk 0.1.0 → 0.1.2
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 +1 -1
- package/README.md +20 -3
- package/index.d.ts +10 -3
- package/index.js +5 -5
- package/package.json +2 -2
- package/stream.js +10 -10
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @nightfuel-ai/app-sdk
|
|
2
2
|
|
|
3
|
-
`@nightfuel-ai/app-sdk` is for the **server process of a
|
|
4
|
-
It uses the App's Cloud Run workload identity to reach the private
|
|
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
5
|
App gateway. It has no browser build and must never be placed in client code.
|
|
6
6
|
|
|
7
7
|
```bash
|
|
@@ -49,6 +49,23 @@ for await (const event of nightfuel.workflows.stream('notify-low-stock', run.run
|
|
|
49
49
|
}
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
+
## Workflow input
|
|
53
|
+
|
|
54
|
+
`workflows.run(bindingId, input, { idempotencyKey, customFields })` sends the
|
|
55
|
+
same run input as `workflow_run` in the Nightfuel SDK. `input` is a JSON object
|
|
56
|
+
that reaches the bound workflow's entry node:
|
|
57
|
+
|
|
58
|
+
- a **Webhook** entry receives it as its request body (`payload.payload.body`),
|
|
59
|
+
checked against the entry's JSON Schema;
|
|
60
|
+
- a **Request Harness** entry receives it as its input: an App sends `text`
|
|
61
|
+
only, and `customFields` fills the custom fields the entry declares.
|
|
62
|
+
|
|
63
|
+
Input that does not match the entry is refused with `400` before the run is
|
|
64
|
+
accepted (`WEBHOOK_BODY_SCHEMA_MISMATCH`, `HARNESS_CUSTOM_FIELDS_INVALID`,
|
|
65
|
+
`WORKFLOW_INPUT_INVALID`). A run accepted with `202` that cannot start (no
|
|
66
|
+
credits, or a workflow that runs with its own published identity) ends its
|
|
67
|
+
stream with `run.failed` and `payload.errorCode`.
|
|
68
|
+
|
|
52
69
|
## Runtime environment
|
|
53
70
|
|
|
54
71
|
A Cloud App receives these variables from the platform; the SDK reads them
|
|
@@ -78,7 +95,7 @@ its machine-readable stream code and `afterSeq`. Persist `data.seq` after the
|
|
|
78
95
|
App has committed its own effect when process-restart recovery matters; SSE is
|
|
79
96
|
at-least-once, not exactly-once.
|
|
80
97
|
|
|
81
|
-
## Calls from
|
|
98
|
+
## Calls from Nightfuel into the App
|
|
82
99
|
|
|
83
100
|
The Apphook workflow node signs each request it sends to the App in the
|
|
84
101
|
`X-NF-App-Call` header. `verifyNightfuelCall` checks the signature, expiry,
|
package/index.d.ts
CHANGED
|
@@ -161,10 +161,17 @@ export interface NightfuelDatabaseMutationInput {
|
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
export interface NightfuelWorkflowClient {
|
|
164
|
+
/**
|
|
165
|
+
* Starts the bound workflow. `input` reaches its entry node: a Request
|
|
166
|
+
* Harness entry reads it as its input (an App sends `text` only), a Webhook
|
|
167
|
+
* entry as its request body, checked against the entry's JSON Schema.
|
|
168
|
+
* `customFields` fills the Request Harness entry's declared custom fields.
|
|
169
|
+
* Input outside that contract is refused with 400 before the run starts.
|
|
170
|
+
*/
|
|
164
171
|
run(
|
|
165
172
|
bindingId: string,
|
|
166
173
|
input: Record<string, unknown>,
|
|
167
|
-
options: { idempotencyKey: string }
|
|
174
|
+
options: { idempotencyKey: string; customFields?: Record<string, unknown> }
|
|
168
175
|
): Promise<WorkflowRunAccepted>;
|
|
169
176
|
stream<T = NightfuelKnownWorkflowEventData | NightfuelWorkflowEventData>(
|
|
170
177
|
bindingId: string,
|
|
@@ -182,7 +189,7 @@ export interface NightfuelAppSdk {
|
|
|
182
189
|
|
|
183
190
|
export function createNightfuelAppSdk(options?: NightfuelAppSdkOptions): NightfuelAppSdk;
|
|
184
191
|
|
|
185
|
-
/** The claims
|
|
192
|
+
/** The claims Nightfuel signs into an Apphook request. */
|
|
186
193
|
export interface NightfuelCallClaims {
|
|
187
194
|
v: 1;
|
|
188
195
|
appId: string;
|
|
@@ -199,7 +206,7 @@ export interface NightfuelCallClaims {
|
|
|
199
206
|
}
|
|
200
207
|
|
|
201
208
|
/**
|
|
202
|
-
* Verifies a call
|
|
209
|
+
* Verifies a call Nightfuel made to this App and returns its claims.
|
|
203
210
|
* `rawBody` is the JSON body, or the multipart `payload` field, exactly as
|
|
204
211
|
* received -- parse it only after this returns. Pure: no network, no platform
|
|
205
212
|
* identity, so an App can prove it rejects a forged call in a unit test.
|
package/index.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
//
|
|
5
5
|
// This package intentionally has no browser build. It authenticates with the
|
|
6
6
|
// Google metadata server available to the App's Cloud Run service account and
|
|
7
|
-
// calls only pre-bound
|
|
7
|
+
// calls only pre-bound Nightfuel App runtime endpoints.
|
|
8
8
|
|
|
9
9
|
const crypto = require('crypto');
|
|
10
10
|
const { parseSseFrame, recoverableSseStream, terminalRunStatus } = require('./stream');
|
|
@@ -168,7 +168,7 @@ function createNightfuelAppSdk(options = {}) {
|
|
|
168
168
|
if (!response.ok && !allowErrorResponse) {
|
|
169
169
|
const payload = await responsePayload(response);
|
|
170
170
|
throw new NightfuelAppSdkError(
|
|
171
|
-
payload?.error || `
|
|
171
|
+
payload?.error || `Nightfuel App gateway request failed (${response.status})`,
|
|
172
172
|
{ status: response.status, code: payload?.code || 'NIGHTFUEL_APP_SDK_GATEWAY_ERROR', payload }
|
|
173
173
|
);
|
|
174
174
|
}
|
|
@@ -207,7 +207,7 @@ function createNightfuelAppSdk(options = {}) {
|
|
|
207
207
|
const key = idempotencyKey(options.idempotencyKey);
|
|
208
208
|
return postJson(
|
|
209
209
|
`/internal/hosting/apps/${appId}/workflows/${encodeURIComponent(resolvedBindingId)}/runs`,
|
|
210
|
-
{ input },
|
|
210
|
+
{ input, ...(options.customFields === undefined ? {} : { customFields: options.customFields }) },
|
|
211
211
|
{ 'Idempotency-Key': key }
|
|
212
212
|
);
|
|
213
213
|
},
|
|
@@ -249,7 +249,7 @@ function createNightfuelAppSdk(options = {}) {
|
|
|
249
249
|
});
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
-
// ── Calls FROM
|
|
252
|
+
// ── Calls FROM Nightfuel (the Call App workflow node) ─────────────────────
|
|
253
253
|
//
|
|
254
254
|
// The platform signs every call with the App's NF_APP_CALL_SECRET:
|
|
255
255
|
// X-NF-App-Call: <base64url(claims)>.<base64url(HMAC-SHA256(secret, claims))>
|
|
@@ -278,7 +278,7 @@ function headerValue(headers, name) {
|
|
|
278
278
|
}
|
|
279
279
|
|
|
280
280
|
/**
|
|
281
|
-
* Verifies a call made by
|
|
281
|
+
* Verifies a call made by Nightfuel to this App. `rawBody` is the JSON body
|
|
282
282
|
* (or the multipart `payload` field) exactly as received. Returns the claims.
|
|
283
283
|
*/
|
|
284
284
|
function verifyNightfuelCall({ headers, method, path, rawBody, secret, appId, now = Date.now() } = {}) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nightfuel-ai/app-sdk",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Server-only
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Server-only Nightfuel SDK for a hosted App workload identity.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
7
7
|
"exports": {
|
package/stream.js
CHANGED
|
@@ -115,7 +115,7 @@ function responseError(createError, response, payload, afterSeq, reconnects) {
|
|
|
115
115
|
const auth = status === 401 || status === 403;
|
|
116
116
|
const error = makeError(
|
|
117
117
|
createError,
|
|
118
|
-
payload?.error || `
|
|
118
|
+
payload?.error || `Nightfuel event stream request failed (${status || 'unknown status'})`,
|
|
119
119
|
{
|
|
120
120
|
status,
|
|
121
121
|
code: payload?.code || (auth ? 'NIGHTFUEL_STREAM_AUTH_FAILED' : 'NIGHTFUEL_STREAM_FAILED'),
|
|
@@ -268,7 +268,7 @@ async function* recoverableSseStream(options = {}) {
|
|
|
268
268
|
idleTimeoutMs,
|
|
269
269
|
onIdle: () => {
|
|
270
270
|
const idle = recoverableError(createError, 'NIGHTFUEL_STREAM_IDLE_TIMEOUT',
|
|
271
|
-
`
|
|
271
|
+
`Nightfuel event stream was idle for ${idleTimeoutMs}ms`, confirmedSeq, reconnects);
|
|
272
272
|
attempt.controller.abort(idle);
|
|
273
273
|
return idle;
|
|
274
274
|
},
|
|
@@ -314,7 +314,7 @@ async function* recoverableSseStream(options = {}) {
|
|
|
314
314
|
throw recoverableError(
|
|
315
315
|
createError,
|
|
316
316
|
'NIGHTFUEL_STREAM_TRUNCATED',
|
|
317
|
-
'
|
|
317
|
+
'Nightfuel event stream ended in the middle of an SSE frame',
|
|
318
318
|
confirmedSeq,
|
|
319
319
|
reconnects
|
|
320
320
|
);
|
|
@@ -322,7 +322,7 @@ async function* recoverableSseStream(options = {}) {
|
|
|
322
322
|
throw recoverableError(
|
|
323
323
|
createError,
|
|
324
324
|
'NIGHTFUEL_STREAM_DROPPED',
|
|
325
|
-
'
|
|
325
|
+
'Nightfuel event stream closed before a terminal run event',
|
|
326
326
|
confirmedSeq,
|
|
327
327
|
reconnects
|
|
328
328
|
);
|
|
@@ -337,7 +337,7 @@ async function* recoverableSseStream(options = {}) {
|
|
|
337
337
|
const rawFrame = buffer.slice(0, boundary.index);
|
|
338
338
|
buffer = buffer.slice(boundary.index + boundary[0].length);
|
|
339
339
|
if (Buffer.byteLength(rawFrame, 'utf8') > MAX_FRAME_BYTES) {
|
|
340
|
-
throw protocolError(createError, '
|
|
340
|
+
throw protocolError(createError, 'Nightfuel stream frame exceeds 8 MiB', confirmedSeq, reconnects);
|
|
341
341
|
}
|
|
342
342
|
if (!rawFrame || rawFrame.split(/\r?\n/).every((line) => !line || line.startsWith(':'))) continue;
|
|
343
343
|
const frame = parseSseFrame(rawFrame);
|
|
@@ -346,7 +346,7 @@ async function* recoverableSseStream(options = {}) {
|
|
|
346
346
|
const error = recoverableError(
|
|
347
347
|
createError,
|
|
348
348
|
frame.data?.code || 'NIGHTFUEL_STREAM_SERVER_ERROR',
|
|
349
|
-
frame.data?.error || '
|
|
349
|
+
frame.data?.error || 'Nightfuel event stream failed after opening',
|
|
350
350
|
confirmedSeq,
|
|
351
351
|
reconnects,
|
|
352
352
|
null,
|
|
@@ -360,7 +360,7 @@ async function* recoverableSseStream(options = {}) {
|
|
|
360
360
|
throw recoverableError(
|
|
361
361
|
createError,
|
|
362
362
|
'NIGHTFUEL_STREAM_DROPPED',
|
|
363
|
-
'
|
|
363
|
+
'Nightfuel dropped a slow event subscriber; resuming from the confirmed cursor',
|
|
364
364
|
confirmedSeq,
|
|
365
365
|
reconnects,
|
|
366
366
|
null,
|
|
@@ -378,7 +378,7 @@ async function* recoverableSseStream(options = {}) {
|
|
|
378
378
|
throw recoverableError(
|
|
379
379
|
createError,
|
|
380
380
|
'NIGHTFUEL_STREAM_GAP',
|
|
381
|
-
`
|
|
381
|
+
`Nightfuel event stream expected seq ${confirmedSeq + 1} but received ${seq}`,
|
|
382
382
|
confirmedSeq,
|
|
383
383
|
reconnects,
|
|
384
384
|
null,
|
|
@@ -395,7 +395,7 @@ async function* recoverableSseStream(options = {}) {
|
|
|
395
395
|
if (terminal) return;
|
|
396
396
|
}
|
|
397
397
|
if (Buffer.byteLength(buffer, 'utf8') > MAX_FRAME_BYTES) {
|
|
398
|
-
throw protocolError(createError, '
|
|
398
|
+
throw protocolError(createError, 'Nightfuel stream frame exceeds 8 MiB', confirmedSeq, reconnects);
|
|
399
399
|
}
|
|
400
400
|
}
|
|
401
401
|
} catch (error) {
|
|
@@ -406,7 +406,7 @@ async function* recoverableSseStream(options = {}) {
|
|
|
406
406
|
? recoverableError(
|
|
407
407
|
createError,
|
|
408
408
|
'NIGHTFUEL_STREAM_DROPPED',
|
|
409
|
-
'
|
|
409
|
+
'Nightfuel event stream transport disconnected',
|
|
410
410
|
confirmedSeq,
|
|
411
411
|
reconnects,
|
|
412
412
|
error
|