@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 +22 -0
- package/dist/index.d.ts +15 -2
- package/dist/index.js +59 -16
- package/dist/runtime.d.ts +7 -1
- package/dist/runtime.js +40 -1
- package/dist/user-role.d.ts +2 -0
- package/dist/user-role.js +3 -0
- 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
|
@@ -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
|
-
|
|
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
|
|
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 :
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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);
|