@isomorph.ai/app-sdk 1.8.0 → 1.10.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
@@ -1,3 +1,5 @@
1
+ import { type IsomorphUserRole } from './user-role.js';
2
+ export type { IsomorphUserRole } from './user-role.js';
1
3
  export type IsomorphErrorCategory = 'AUTH_REQUIRED' | 'FORBIDDEN' | 'NOT_FOUND' | 'CONFLICT' | 'VALIDATION_FAILED' | 'RATE_LIMITED' | 'UNAVAILABLE' | 'INTERNAL';
2
4
  export declare class IsomorphError extends Error {
3
5
  readonly category: IsomorphErrorCategory;
@@ -18,6 +20,14 @@ export interface IsomorphUser {
18
20
  * enforced before your code runs.
19
21
  */
20
22
  groups: string[];
23
+ /**
24
+ * What the person may do on the company's app platform: a `'builder'`
25
+ * builds and deploys apps, a `'member'` uses them. Show builders what only
26
+ * they need (a debug panel, a "deploy" hint); never gate data on it — that
27
+ * is a row rule's job, where `current_setting('harbour.user_role', true)`
28
+ * carries the same value.
29
+ */
30
+ role: IsomorphUserRole;
21
31
  appUser?: unknown;
22
32
  }
23
33
  export interface IsomorphResult<T> {
@@ -441,7 +451,7 @@ declare class Transport {
441
451
  private readonly identity;
442
452
  renewal: IdentityRenewal | undefined;
443
453
  constructor(options: ClientOptions, onUnauthorized?: (() => void) | undefined);
444
- json<T>(path: string, init?: RequestInit): Promise<T>;
454
+ json<T>(path: string, init?: RequestInit, onProgress?: (step: never) => void): Promise<T>;
445
455
  private request;
446
456
  }
447
457
  export declare class QueryBuilder<T = Record<string, unknown>, Result = T[]> implements PromiseLike<IsomorphResult<Result>> {
@@ -532,7 +542,10 @@ export declare function createClient(options?: ClientOptions): {
532
542
  channel: (name: string) => RealtimeChannelBuilder;
533
543
  };
534
544
  actions: {
535
- invoke: <T = unknown>(name: string, input: unknown) => Promise<T>;
545
+ /** `onProgress` receives each `ctx.progress(step)` the action reports while it runs. */
546
+ invoke: <T = unknown, P = string>(name: string, input: unknown, options?: {
547
+ onProgress?: (step: P) => void;
548
+ }) => Promise<T>;
536
549
  };
537
550
  jobs: {
538
551
  enqueue: (name: string, input?: unknown) => Promise<{
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { validateIntegrationExecuteInput } from './integration-execute-input-rules.js';
2
2
  import { userGroups } from './user-groups.js';
3
+ import { userRole } from './user-role.js';
3
4
  export class IsomorphError extends Error {
4
5
  category;
5
6
  requestId;
@@ -184,18 +185,21 @@ class Transport {
184
185
  this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
185
186
  this.identity = options.identity;
186
187
  }
187
- json(path, init = {}) {
188
- return this.request(path, init, renewableOnUnauthorized(path) || (this.separateApiHost && path === IDENTITY_PATH));
188
+ json(path, init = {}, onProgress) {
189
+ return this.request(path, init, renewableOnUnauthorized(path) || (this.separateApiHost && path === IDENTITY_PATH), onProgress);
189
190
  }
190
- async request(path, init, renewOnUnauthorized) {
191
+ async request(path, init, renewOnUnauthorized, onProgress) {
191
192
  const sentAt = this.renewal?.renewals() ?? 0;
192
193
  // (headers with an undefined value are removed so a multipart body can
193
194
  // clear the JSON default and let the browser set its own boundary)
194
195
  const response = await this.fetch(`${this.baseUrl}${path}`, {
195
196
  credentials: 'same-origin',
196
197
  ...init,
197
- headers: mergeHeaders({ 'content-type': 'application/json' }, runnerIdentityHeaders(this.identity), init.headers),
198
+ headers: mergeHeaders({ 'content-type': 'application/json' }, runnerIdentityHeaders(this.identity), init.headers, onProgress ? { accept: PROGRESS_MEDIA_TYPE } : undefined),
198
199
  });
200
+ if (onProgress && response.ok && response.body && response.headers.get('content-type')?.startsWith(PROGRESS_MEDIA_TYPE)) {
201
+ return readProgress(response, onProgress);
202
+ }
199
203
  const body = await response.json().catch(() => undefined);
200
204
  if (!response.ok) {
201
205
  if (response.status === 401) {
@@ -211,7 +215,7 @@ class Transport {
211
215
  if (renewed && path === IDENTITY_PATH)
212
216
  return renewed;
213
217
  if (renewed !== false)
214
- return this.request(path, init, false);
218
+ return this.request(path, init, false, onProgress);
215
219
  }
216
220
  // Not before the resend: a page listening to identity.onChange would
217
221
  // otherwise see the user flicker to null while the edge was healing.
@@ -222,6 +226,44 @@ class Transport {
222
226
  return (body && Object.prototype.hasOwnProperty.call(body, 'data') ? body.data : body);
223
227
  }
224
228
  }
229
+ // An action invoked with onProgress asks for its progress stream: one JSON
230
+ // line per ctx.progress step, then the answer — `{data}` or `{error, status}`,
231
+ // the refusal a plain call would have thrown. A runtime that does not stream
232
+ // answers plain JSON and the call reads it as before.
233
+ const PROGRESS_MEDIA_TYPE = 'application/x-ndjson';
234
+ async function readProgress(response, onProgress) {
235
+ const requestId = response.headers.get('x-request-id') ?? undefined;
236
+ const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
237
+ let pending = '';
238
+ try {
239
+ for (;;) {
240
+ const { done, value } = await reader.read();
241
+ const lines = (pending + (value ?? '')).split('\n');
242
+ pending = done ? '' : lines.pop();
243
+ for (const line of lines.filter((text) => text.trim())) {
244
+ let event;
245
+ try {
246
+ event = JSON.parse(line);
247
+ }
248
+ catch {
249
+ throw new IsomorphError('INTERNAL', 'The action answered with an unreadable progress line.', requestId);
250
+ }
251
+ if ('progress' in event) {
252
+ onProgress(event.progress);
253
+ continue;
254
+ }
255
+ if (event.error)
256
+ throw new IsomorphError(event.error.category ?? 'INTERNAL', event.error.message ?? 'The action failed.', requestId, event.error.details, event.status);
257
+ return event.data;
258
+ }
259
+ if (done)
260
+ throw new IsomorphError('UNAVAILABLE', 'The action stopped before it answered.', requestId);
261
+ }
262
+ }
263
+ finally {
264
+ void reader.cancel().catch(() => undefined);
265
+ }
266
+ }
225
267
  function applicationBaseUrl() {
226
268
  if (typeof location === 'undefined') {
227
269
  // No browser: this module is running under the retained-check runner in
@@ -376,8 +418,9 @@ export function createClient(options = {}) {
376
418
  };
377
419
  // The gateway's answer becomes an IsomorphUser in place: the same object
378
420
  // must reach every caller (the API-host refresh below relies on it).
379
- const withGroups = (user) => {
421
+ const normalizeUser = (user) => {
380
422
  user.groups = userGroups(user.groups);
423
+ user.role = userRole(user.role);
381
424
  return user;
382
425
  };
383
426
  const transport = new Transport(options, () => notifyIdentity(null));
@@ -388,7 +431,7 @@ export function createClient(options = {}) {
388
431
  refreshesStarted += 1;
389
432
  refreshInFlight = transport.json(IDENTITY_PATH)
390
433
  .then((answer) => {
391
- const user = answer === null ? null : withGroups(answer);
434
+ const user = answer === null ? null : normalizeUser(answer);
392
435
  // A user answered by the API host's context refresh was announced by
393
436
  // that refresh already (it is the same object): once is enough.
394
437
  if (user === null || user !== currentUser)
@@ -439,7 +482,7 @@ export function createClient(options = {}) {
439
482
  const user = (await response.json())?.data;
440
483
  if (typeof user?.id !== 'string')
441
484
  return false;
442
- notifyIdentity(withGroups(user), true);
485
+ notifyIdentity(normalizeUser(user), true);
443
486
  return user;
444
487
  })
445
488
  .catch(() => false)
@@ -638,24 +681,24 @@ export function createClient(options = {}) {
638
681
  channel: (name) => new RealtimeChannelBuilder(name, connectRealtime),
639
682
  },
640
683
  actions: {
641
- invoke: (name, input) => {
684
+ /** `onProgress` receives each `ctx.progress(step)` the action reports while it runs. */
685
+ invoke: (name, input, options = {}) => {
686
+ const path = `/_harbour/actions/${encodeURIComponent(name)}`;
642
687
  // supabase-js accepts FormData/Blob bodies and sends them as
643
688
  // multipart; JSON.stringify(FormData) silently becomes "{}", which
644
689
  // turned a verbatim-ported multipart action into a guaranteed 500.
645
690
  // Pass binary bodies through and let the browser set the boundary.
646
691
  if (typeof FormData !== 'undefined' && input instanceof FormData) {
647
- return transport.json(`/_harbour/actions/${encodeURIComponent(name)}`, {
692
+ return transport.json(path, {
648
693
  method: 'POST', body: input, headers: { 'content-type': undefined },
649
- });
694
+ }, options.onProgress);
650
695
  }
651
696
  if (typeof Blob !== 'undefined' && input instanceof Blob) {
652
- return transport.json(`/_harbour/actions/${encodeURIComponent(name)}`, {
697
+ return transport.json(path, {
653
698
  method: 'POST', body: input, headers: { 'content-type': input.type || 'application/octet-stream' },
654
- });
699
+ }, options.onProgress);
655
700
  }
656
- return transport.json(`/_harbour/actions/${encodeURIComponent(name)}`, {
657
- method: 'POST', body: JSON.stringify(input),
658
- });
701
+ return transport.json(path, { method: 'POST', body: JSON.stringify(input) }, options.onProgress);
659
702
  },
660
703
  },
661
704
  jobs: {
package/dist/runtime.d.ts CHANGED
@@ -10,10 +10,16 @@ export declare const GET_RUN_ACTION = "isomorph-jobs-get";
10
10
  export declare const CANCEL_RUN_ACTION = "isomorph-jobs-cancel";
11
11
  export interface ActionContext {
12
12
  signal: AbortSignal;
13
- user: Pick<IsomorphUser, 'id' | 'email' | 'groups'>;
13
+ user: Pick<IsomorphUser, 'id' | 'email' | 'groups' | 'role'>;
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
@@ -57,6 +57,7 @@ import { join, resolve } from 'node:path';
57
57
  import { pathToFileURL } from 'node:url';
58
58
  import { createClient, IsomorphError } from './index.js';
59
59
  import { userGroupsFromHeader } from './user-groups.js';
60
+ import { userRole } from './user-role.js';
60
61
  const actionIdentity = new AsyncLocalStorage();
61
62
  const identityKey = Symbol.for('isomorph.actionIdentity');
62
63
  globalThis[identityKey] = () => actionIdentity.getStore();
@@ -81,6 +82,12 @@ const DEFAULT_MAX_ATTEMPTS = 3;
81
82
  // waits this long for the gateway's data path, probing every retry interval.
82
83
  const DATA_PATH_WAIT_MS = 90_000;
83
84
  const DATA_PATH_RETRY_MS = 3_000;
85
+ // An action's progress stream: asked for with this Accept, one JSON line per
86
+ // ctx.progress call, then the answer as the last line. Bounded so a loop
87
+ // cannot flood the page or crowd the result out of the gateway's 4 MiB cap.
88
+ const PROGRESS_MEDIA_TYPE = 'application/x-ndjson';
89
+ const MAX_PROGRESS_EVENTS = 256;
90
+ const MAX_PROGRESS_LINE_BYTES = 1 << 10;
84
91
  /** Every `actions/<name>.ts` and `jobs/<name>.ts` the app carries, by logical name. */
85
92
  export async function discover(root) {
86
93
  const list = async (dir) => {
@@ -147,7 +154,14 @@ function loadHandler(root, dir, name) {
147
154
  return loading;
148
155
  }
149
156
  // ---- HTTP helpers ------------------------------------------------------------
157
+ // Once an action has streamed progress the status line is gone, so its
158
+ // answer — result or refusal — is the stream's last line, the refusal
159
+ // carrying the status it would have had.
150
160
  function answer(response, status, body, requestId) {
161
+ if (response.headersSent) {
162
+ response.end(`${JSON.stringify(status >= 400 ? { ...body, status } : body)}\n`);
163
+ return;
164
+ }
151
165
  const encoded = JSON.stringify(body);
152
166
  response.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(encoded), ...(requestId ? { 'x-request-id': requestId } : {}) });
153
167
  response.end(encoded);
@@ -432,6 +446,7 @@ export class Runtime {
432
446
  }
433
447
  this.running.add(key);
434
448
  try {
449
+ await this.prepareAI();
435
450
  const handler = await loadHandler(this.options.root, JOBS_DIR, name);
436
451
  await handler({ scheduledAt: at.toISOString(), progress: async () => undefined, cancelled: async () => false });
437
452
  this.log(`jobs/${name}.ts ran for ${at.toISOString()}`);
@@ -466,6 +481,7 @@ export class Runtime {
466
481
  return true;
467
482
  }
468
483
  try {
484
+ await this.prepareAI();
469
485
  const handler = await loadHandler(this.options.root, JOBS_DIR, run.job);
470
486
  const result = await handler({
471
487
  scheduledAt: now.toISOString(), runId: run.id, input: run.input,
@@ -500,8 +516,31 @@ export class Runtime {
500
516
  id: header(request, 'X-Harbour-Verified-User-ID'),
501
517
  email: header(request, 'X-Harbour-Verified-User-Email'),
502
518
  groups: userGroupsFromHeader(header(request, 'X-Harbour-Verified-User-Groups')),
519
+ role: userRole(header(request, 'X-Harbour-Verified-User-Role')),
520
+ };
521
+ const streams = header(request, 'accept').toLowerCase().includes(PROGRESS_MEDIA_TYPE);
522
+ let steps = 0;
523
+ const progress = (step) => {
524
+ if (!streams || steps >= MAX_PROGRESS_EVENTS || response.writableEnded)
525
+ return;
526
+ let encoded;
527
+ try {
528
+ encoded = JSON.stringify(step);
529
+ }
530
+ catch {
531
+ return;
532
+ }
533
+ if (encoded === undefined)
534
+ return;
535
+ const line = `{"progress":${encoded}}\n`;
536
+ if (Buffer.byteLength(line) > MAX_PROGRESS_LINE_BYTES)
537
+ return;
538
+ steps += 1;
539
+ if (!response.headersSent)
540
+ response.writeHead(200, { 'content-type': PROGRESS_MEDIA_TYPE, ...(requestId ? { 'x-request-id': requestId } : {}) });
541
+ response.write(line);
503
542
  };
504
- const context = { signal: controller.signal, user, tenant, app, requestId: requestId ?? '' };
543
+ const context = { signal: controller.signal, user, tenant, app, requestId: requestId ?? '', progress };
505
544
  const raw = await readBody(request);
506
545
  if (raw === undefined)
507
546
  return refuse(response, 413, 'VALIDATION_FAILED', 'the request body is larger than 1 MiB', requestId);
@@ -0,0 +1,2 @@
1
+ export type IsomorphUserRole = 'builder' | 'member';
2
+ export declare function userRole(value: unknown): IsomorphUserRole;
@@ -0,0 +1,3 @@
1
+ export function userRole(value) {
2
+ return value === 'builder' ? 'builder' : 'member';
3
+ }
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.10.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",