@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 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
- invoke: <T = unknown>(name: string, input: unknown) => Promise<T>;
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
- invoke: (name, input) => {
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(`/_harbour/actions/${encodeURIComponent(name)}`, {
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(`/_harbour/actions/${encodeURIComponent(name)}`, {
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(`/_harbour/actions/${encodeURIComponent(name)}`, {
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 context = { signal: controller.signal, user, tenant, app, requestId: requestId ?? '' };
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isomorph.ai/app-sdk",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",