@isomorph.ai/app-sdk 1.8.0 → 1.9.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/README.md +22 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +54 -13
- package/dist/runtime.d.ts +6 -0
- package/dist/runtime.js +38 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -200,3 +200,25 @@ export default async function approve(input: { id: string }, ctx: { user: { grou
|
|
|
200
200
|
```
|
|
201
201
|
|
|
202
202
|
(1.6.0)
|
|
203
|
+
|
|
204
|
+
## Live progress from an action
|
|
205
|
+
|
|
206
|
+
An action reports steps with `ctx.progress(step)` (a string or a small JSON
|
|
207
|
+
object); a page that passes `onProgress` gets each one as it happens, then the
|
|
208
|
+
result or the error exactly as a plain `invoke` gives it. No table, no polling.
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
// actions/research.ts
|
|
212
|
+
export default async function research(input: { url: string }, ctx: { progress(step: string): void }) {
|
|
213
|
+
ctx.progress(`Opened ${input.url}`);
|
|
214
|
+
// ...
|
|
215
|
+
return result;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// page
|
|
219
|
+
await isomorph.actions.invoke('research', input, { onProgress: (step) => setSteps((steps) => [...steps, step]) });
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Without `onProgress` the call is unchanged and `ctx.progress` does nothing. At
|
|
223
|
+
most 256 steps of up to 1 KiB each reach the page; further steps are dropped.
|
|
224
|
+
The action still has the 300 s action budget. (Unreleased)
|
package/dist/index.d.ts
CHANGED
|
@@ -441,7 +441,7 @@ declare class Transport {
|
|
|
441
441
|
private readonly identity;
|
|
442
442
|
renewal: IdentityRenewal | undefined;
|
|
443
443
|
constructor(options: ClientOptions, onUnauthorized?: (() => void) | undefined);
|
|
444
|
-
json<T>(path: string, init?: RequestInit): Promise<T>;
|
|
444
|
+
json<T>(path: string, init?: RequestInit, onProgress?: (step: never) => void): Promise<T>;
|
|
445
445
|
private request;
|
|
446
446
|
}
|
|
447
447
|
export declare class QueryBuilder<T = Record<string, unknown>, Result = T[]> implements PromiseLike<IsomorphResult<Result>> {
|
|
@@ -532,7 +532,10 @@ export declare function createClient(options?: ClientOptions): {
|
|
|
532
532
|
channel: (name: string) => RealtimeChannelBuilder;
|
|
533
533
|
};
|
|
534
534
|
actions: {
|
|
535
|
-
|
|
535
|
+
/** `onProgress` receives each `ctx.progress(step)` the action reports while it runs. */
|
|
536
|
+
invoke: <T = unknown, P = string>(name: string, input: unknown, options?: {
|
|
537
|
+
onProgress?: (step: P) => void;
|
|
538
|
+
}) => Promise<T>;
|
|
536
539
|
};
|
|
537
540
|
jobs: {
|
|
538
541
|
enqueue: (name: string, input?: unknown) => Promise<{
|
package/dist/index.js
CHANGED
|
@@ -184,18 +184,21 @@ class Transport {
|
|
|
184
184
|
this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
185
185
|
this.identity = options.identity;
|
|
186
186
|
}
|
|
187
|
-
json(path, init = {}) {
|
|
188
|
-
return this.request(path, init, renewableOnUnauthorized(path) || (this.separateApiHost && path === IDENTITY_PATH));
|
|
187
|
+
json(path, init = {}, onProgress) {
|
|
188
|
+
return this.request(path, init, renewableOnUnauthorized(path) || (this.separateApiHost && path === IDENTITY_PATH), onProgress);
|
|
189
189
|
}
|
|
190
|
-
async request(path, init, renewOnUnauthorized) {
|
|
190
|
+
async request(path, init, renewOnUnauthorized, onProgress) {
|
|
191
191
|
const sentAt = this.renewal?.renewals() ?? 0;
|
|
192
192
|
// (headers with an undefined value are removed so a multipart body can
|
|
193
193
|
// clear the JSON default and let the browser set its own boundary)
|
|
194
194
|
const response = await this.fetch(`${this.baseUrl}${path}`, {
|
|
195
195
|
credentials: 'same-origin',
|
|
196
196
|
...init,
|
|
197
|
-
headers: mergeHeaders({ 'content-type': 'application/json' }, runnerIdentityHeaders(this.identity), init.headers),
|
|
197
|
+
headers: mergeHeaders({ 'content-type': 'application/json' }, runnerIdentityHeaders(this.identity), init.headers, onProgress ? { accept: PROGRESS_MEDIA_TYPE } : undefined),
|
|
198
198
|
});
|
|
199
|
+
if (onProgress && response.ok && response.body && response.headers.get('content-type')?.startsWith(PROGRESS_MEDIA_TYPE)) {
|
|
200
|
+
return readProgress(response, onProgress);
|
|
201
|
+
}
|
|
199
202
|
const body = await response.json().catch(() => undefined);
|
|
200
203
|
if (!response.ok) {
|
|
201
204
|
if (response.status === 401) {
|
|
@@ -211,7 +214,7 @@ class Transport {
|
|
|
211
214
|
if (renewed && path === IDENTITY_PATH)
|
|
212
215
|
return renewed;
|
|
213
216
|
if (renewed !== false)
|
|
214
|
-
return this.request(path, init, false);
|
|
217
|
+
return this.request(path, init, false, onProgress);
|
|
215
218
|
}
|
|
216
219
|
// Not before the resend: a page listening to identity.onChange would
|
|
217
220
|
// otherwise see the user flicker to null while the edge was healing.
|
|
@@ -222,6 +225,44 @@ class Transport {
|
|
|
222
225
|
return (body && Object.prototype.hasOwnProperty.call(body, 'data') ? body.data : body);
|
|
223
226
|
}
|
|
224
227
|
}
|
|
228
|
+
// An action invoked with onProgress asks for its progress stream: one JSON
|
|
229
|
+
// line per ctx.progress step, then the answer — `{data}` or `{error, status}`,
|
|
230
|
+
// the refusal a plain call would have thrown. A runtime that does not stream
|
|
231
|
+
// answers plain JSON and the call reads it as before.
|
|
232
|
+
const PROGRESS_MEDIA_TYPE = 'application/x-ndjson';
|
|
233
|
+
async function readProgress(response, onProgress) {
|
|
234
|
+
const requestId = response.headers.get('x-request-id') ?? undefined;
|
|
235
|
+
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
|
|
236
|
+
let pending = '';
|
|
237
|
+
try {
|
|
238
|
+
for (;;) {
|
|
239
|
+
const { done, value } = await reader.read();
|
|
240
|
+
const lines = (pending + (value ?? '')).split('\n');
|
|
241
|
+
pending = done ? '' : lines.pop();
|
|
242
|
+
for (const line of lines.filter((text) => text.trim())) {
|
|
243
|
+
let event;
|
|
244
|
+
try {
|
|
245
|
+
event = JSON.parse(line);
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
throw new IsomorphError('INTERNAL', 'The action answered with an unreadable progress line.', requestId);
|
|
249
|
+
}
|
|
250
|
+
if ('progress' in event) {
|
|
251
|
+
onProgress(event.progress);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (event.error)
|
|
255
|
+
throw new IsomorphError(event.error.category ?? 'INTERNAL', event.error.message ?? 'The action failed.', requestId, event.error.details, event.status);
|
|
256
|
+
return event.data;
|
|
257
|
+
}
|
|
258
|
+
if (done)
|
|
259
|
+
throw new IsomorphError('UNAVAILABLE', 'The action stopped before it answered.', requestId);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
void reader.cancel().catch(() => undefined);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
225
266
|
function applicationBaseUrl() {
|
|
226
267
|
if (typeof location === 'undefined') {
|
|
227
268
|
// No browser: this module is running under the retained-check runner in
|
|
@@ -638,24 +679,24 @@ export function createClient(options = {}) {
|
|
|
638
679
|
channel: (name) => new RealtimeChannelBuilder(name, connectRealtime),
|
|
639
680
|
},
|
|
640
681
|
actions: {
|
|
641
|
-
|
|
682
|
+
/** `onProgress` receives each `ctx.progress(step)` the action reports while it runs. */
|
|
683
|
+
invoke: (name, input, options = {}) => {
|
|
684
|
+
const path = `/_harbour/actions/${encodeURIComponent(name)}`;
|
|
642
685
|
// supabase-js accepts FormData/Blob bodies and sends them as
|
|
643
686
|
// multipart; JSON.stringify(FormData) silently becomes "{}", which
|
|
644
687
|
// turned a verbatim-ported multipart action into a guaranteed 500.
|
|
645
688
|
// Pass binary bodies through and let the browser set the boundary.
|
|
646
689
|
if (typeof FormData !== 'undefined' && input instanceof FormData) {
|
|
647
|
-
return transport.json(
|
|
690
|
+
return transport.json(path, {
|
|
648
691
|
method: 'POST', body: input, headers: { 'content-type': undefined },
|
|
649
|
-
});
|
|
692
|
+
}, options.onProgress);
|
|
650
693
|
}
|
|
651
694
|
if (typeof Blob !== 'undefined' && input instanceof Blob) {
|
|
652
|
-
return transport.json(
|
|
695
|
+
return transport.json(path, {
|
|
653
696
|
method: 'POST', body: input, headers: { 'content-type': input.type || 'application/octet-stream' },
|
|
654
|
-
});
|
|
697
|
+
}, options.onProgress);
|
|
655
698
|
}
|
|
656
|
-
return transport.json(
|
|
657
|
-
method: 'POST', body: JSON.stringify(input),
|
|
658
|
-
});
|
|
699
|
+
return transport.json(path, { method: 'POST', body: JSON.stringify(input) }, options.onProgress);
|
|
659
700
|
},
|
|
660
701
|
},
|
|
661
702
|
jobs: {
|
package/dist/runtime.d.ts
CHANGED
|
@@ -14,6 +14,12 @@ export interface ActionContext {
|
|
|
14
14
|
tenant: string;
|
|
15
15
|
app: string;
|
|
16
16
|
requestId: string;
|
|
17
|
+
/**
|
|
18
|
+
* Reports a step to a page that invoked the action with `onProgress`; a
|
|
19
|
+
* no-op otherwise. A string or a small JSON object; after 256 steps, or a
|
|
20
|
+
* step over 1 KiB, further calls are dropped.
|
|
21
|
+
*/
|
|
22
|
+
progress(step: string | Record<string, unknown>): void;
|
|
17
23
|
}
|
|
18
24
|
export interface JobContext {
|
|
19
25
|
scheduledAt: string;
|
package/dist/runtime.js
CHANGED
|
@@ -81,6 +81,12 @@ const DEFAULT_MAX_ATTEMPTS = 3;
|
|
|
81
81
|
// waits this long for the gateway's data path, probing every retry interval.
|
|
82
82
|
const DATA_PATH_WAIT_MS = 90_000;
|
|
83
83
|
const DATA_PATH_RETRY_MS = 3_000;
|
|
84
|
+
// An action's progress stream: asked for with this Accept, one JSON line per
|
|
85
|
+
// ctx.progress call, then the answer as the last line. Bounded so a loop
|
|
86
|
+
// cannot flood the page or crowd the result out of the gateway's 4 MiB cap.
|
|
87
|
+
const PROGRESS_MEDIA_TYPE = 'application/x-ndjson';
|
|
88
|
+
const MAX_PROGRESS_EVENTS = 256;
|
|
89
|
+
const MAX_PROGRESS_LINE_BYTES = 1 << 10;
|
|
84
90
|
/** Every `actions/<name>.ts` and `jobs/<name>.ts` the app carries, by logical name. */
|
|
85
91
|
export async function discover(root) {
|
|
86
92
|
const list = async (dir) => {
|
|
@@ -147,7 +153,14 @@ function loadHandler(root, dir, name) {
|
|
|
147
153
|
return loading;
|
|
148
154
|
}
|
|
149
155
|
// ---- HTTP helpers ------------------------------------------------------------
|
|
156
|
+
// Once an action has streamed progress the status line is gone, so its
|
|
157
|
+
// answer — result or refusal — is the stream's last line, the refusal
|
|
158
|
+
// carrying the status it would have had.
|
|
150
159
|
function answer(response, status, body, requestId) {
|
|
160
|
+
if (response.headersSent) {
|
|
161
|
+
response.end(`${JSON.stringify(status >= 400 ? { ...body, status } : body)}\n`);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
151
164
|
const encoded = JSON.stringify(body);
|
|
152
165
|
response.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(encoded), ...(requestId ? { 'x-request-id': requestId } : {}) });
|
|
153
166
|
response.end(encoded);
|
|
@@ -432,6 +445,7 @@ export class Runtime {
|
|
|
432
445
|
}
|
|
433
446
|
this.running.add(key);
|
|
434
447
|
try {
|
|
448
|
+
await this.prepareAI();
|
|
435
449
|
const handler = await loadHandler(this.options.root, JOBS_DIR, name);
|
|
436
450
|
await handler({ scheduledAt: at.toISOString(), progress: async () => undefined, cancelled: async () => false });
|
|
437
451
|
this.log(`jobs/${name}.ts ran for ${at.toISOString()}`);
|
|
@@ -466,6 +480,7 @@ export class Runtime {
|
|
|
466
480
|
return true;
|
|
467
481
|
}
|
|
468
482
|
try {
|
|
483
|
+
await this.prepareAI();
|
|
469
484
|
const handler = await loadHandler(this.options.root, JOBS_DIR, run.job);
|
|
470
485
|
const result = await handler({
|
|
471
486
|
scheduledAt: now.toISOString(), runId: run.id, input: run.input,
|
|
@@ -501,7 +516,29 @@ export class Runtime {
|
|
|
501
516
|
email: header(request, 'X-Harbour-Verified-User-Email'),
|
|
502
517
|
groups: userGroupsFromHeader(header(request, 'X-Harbour-Verified-User-Groups')),
|
|
503
518
|
};
|
|
504
|
-
const
|
|
519
|
+
const streams = header(request, 'accept').toLowerCase().includes(PROGRESS_MEDIA_TYPE);
|
|
520
|
+
let steps = 0;
|
|
521
|
+
const progress = (step) => {
|
|
522
|
+
if (!streams || steps >= MAX_PROGRESS_EVENTS || response.writableEnded)
|
|
523
|
+
return;
|
|
524
|
+
let encoded;
|
|
525
|
+
try {
|
|
526
|
+
encoded = JSON.stringify(step);
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (encoded === undefined)
|
|
532
|
+
return;
|
|
533
|
+
const line = `{"progress":${encoded}}\n`;
|
|
534
|
+
if (Buffer.byteLength(line) > MAX_PROGRESS_LINE_BYTES)
|
|
535
|
+
return;
|
|
536
|
+
steps += 1;
|
|
537
|
+
if (!response.headersSent)
|
|
538
|
+
response.writeHead(200, { 'content-type': PROGRESS_MEDIA_TYPE, ...(requestId ? { 'x-request-id': requestId } : {}) });
|
|
539
|
+
response.write(line);
|
|
540
|
+
};
|
|
541
|
+
const context = { signal: controller.signal, user, tenant, app, requestId: requestId ?? '', progress };
|
|
505
542
|
const raw = await readBody(request);
|
|
506
543
|
if (raw === undefined)
|
|
507
544
|
return refuse(response, 413, 'VALIDATION_FAILED', 'the request body is larger than 1 MiB', requestId);
|