@isomorph.ai/app-sdk 1.22.0 → 1.24.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
@@ -456,6 +456,10 @@ The action still has the 300 s action budget. (Unreleased)
456
456
 
457
457
  ## Running on Lambda
458
458
 
459
- On a serverless cell Harbour runs an app's server code as Lambda functions; nothing in the app changes. `ISOMORPH_GATEWAY_IDENTITY=sts-session` replaces `HARBOUR_WORKLOAD_TOKEN_FILE`: the runtime's bearer for the gateway is a one-hour session it gets by posting a presigned STS `GetCallerIdentity`, signed with the function's credentials (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`, `AWS_REGION`), to `<gateway>/_harbour/internal/session`. A cluster runtime leaves it unset.
459
+ On a serverless cell Harbour runs an app's server code as Lambda functions, from a `runtime.zip` Kit Build makes; nothing in the app changes. The Web Function starts `dist/lambda-entry.js` (the zip's `index.js` is `import "@isomorph.ai/app-sdk/dist/lambda-entry.js"`), which serves actions on the port in `AWS_LAMBDA_HTTP_ENDPOINT` and stops on SIGTERM. The Event Function's handler is `node_modules/@isomorph.ai/app-sdk/dist/lambda-events-entry.handler`: `{"job": "<name>"}` runs that job once, as `--run-job <name>` does, and SQS records work queued runs until none are left.
460
460
 
461
- (1.22.0; data plane ADR 0065)
461
+ `ISOMORPH_GATEWAY_IDENTITY=sts-session` replaces `HARBOUR_WORKLOAD_TOKEN_FILE`: the runtime's bearer for the gateway is a one-hour session it gets by posting a presigned STS `GetCallerIdentity`, signed with the function's credentials (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`, `AWS_REGION`), to `<gateway>/_harbour/internal/session`. A cluster runtime leaves it unset.
462
+
463
+ `ISOMORPH_JOBS_QUEUE_URL`: `isomorph.jobs.enqueue` writes the run, then sends one message to this SQS queue; the runtime never works the queue itself.
464
+
465
+ (1.24.0; data plane ADR 0065)
package/dist/aws.d.ts CHANGED
@@ -11,3 +11,8 @@ export declare function awsEnvironment(env?: NodeJS.ProcessEnv): AwsEnvironment;
11
11
  * alone (ADR 0068), so a signature over any other header could never verify.
12
12
  */
13
13
  export declare function presignCallerIdentity(aws: AwsEnvironment, signingDate?: Date): Promise<string>;
14
+ /** SendMessage to a queue, over the JSON protocol at the queue URL's own host. */
15
+ export declare function sendQueueMessage(aws: AwsEnvironment, queueUrl: string, body: string, { fetch: fetcher, signingDate }?: {
16
+ fetch?: typeof fetch;
17
+ signingDate?: Date;
18
+ }): Promise<void>;
package/dist/aws.js CHANGED
@@ -32,3 +32,20 @@ export async function presignCallerIdentity(aws, signingDate = new Date()) {
32
32
  query.set(key, value);
33
33
  return `https://${hostname}/?${query.toString()}`;
34
34
  }
35
+ /** SendMessage to a queue, over the JSON protocol at the queue URL's own host. */
36
+ export async function sendQueueMessage(aws, queueUrl, body, { fetch: fetcher = fetch, signingDate = new Date() } = {}) {
37
+ const url = new URL(queueUrl);
38
+ const payload = JSON.stringify({ QueueUrl: queueUrl, MessageBody: body });
39
+ const request = new HttpRequest({
40
+ method: 'POST', protocol: url.protocol, hostname: url.hostname, ...(url.port ? { port: Number(url.port) } : {}), path: '/',
41
+ headers: { host: url.host, 'content-type': 'application/x-amz-json-1.0', 'x-amz-target': 'AmazonSQS.SendMessage' },
42
+ body: payload,
43
+ });
44
+ // fetch sets Host itself, from the same URL.
45
+ const { host: _host, ...headers } = (await signer(aws, 'sqs').sign(request, { signingDate })).headers;
46
+ const response = await fetcher(`${url.protocol}//${url.host}/`, { method: 'POST', headers, body: payload, signal: AbortSignal.timeout(10_000) });
47
+ if (!response.ok) {
48
+ const answer = await response.json().catch(() => undefined);
49
+ throw new Error(`SQS refused the message (${response.status}${answer?.__type ? ` ${answer.__type}` : ''}${answer?.message ? `: ${answer.message}` : ''})`);
50
+ }
51
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,10 @@
1
+ // The kit runtime as a Lambda Web Function (ADR 0065). The app's runtime.zip
2
+ // has a one-line index.js, `import "@isomorph.ai/app-sdk/dist/lambda-entry.js"`,
3
+ // and the function starts it: the runtime serves on the port Lambda names in
4
+ // AWS_LAMBDA_HTTP_ENDPOINT (host:port) and stops on SIGTERM, as it does in a pod.
5
+ import { main } from './runtime.js';
6
+ const endpoint = process.env.AWS_LAMBDA_HTTP_ENDPOINT;
7
+ if (endpoint && !process.env.PORT)
8
+ process.env.PORT = endpoint.slice(endpoint.lastIndexOf(':') + 1);
9
+ // Lambda's own arguments are not the runtime's: this entry always serves.
10
+ await main([]);
@@ -0,0 +1 @@
1
+ export declare function handler(event: unknown): Promise<void>;
@@ -0,0 +1,30 @@
1
+ // The kit runtime as the cell's Event Function (ADR 0065): the same runtime.zip
2
+ // with the handler node_modules/@isomorph.ai/app-sdk/dist/lambda-events-entry.handler.
3
+ // One function per app serves both kinds of event:
4
+ //
5
+ // {"job": "<name>"} a Scheduler schedule's input: one scheduled run of
6
+ // jobs/<name>.ts, exactly as `--run-job <name>` runs it
7
+ // {"Records": [...]} SQS messages from an enqueue: the queue is worked
8
+ // until a pass finds nothing
9
+ //
10
+ // An invocation that did not do its work throws, so Lambda counts the error
11
+ // and SQS delivers the message again.
12
+ import { register } from 'node:module';
13
+ import { processOptions, Runtime } from './runtime.js';
14
+ register('./runtime-hooks.js', import.meta.url);
15
+ let runtime;
16
+ function eventRuntime() {
17
+ runtime ??= new Runtime(processOptions());
18
+ return runtime;
19
+ }
20
+ export async function handler(event) {
21
+ const { job, Records: records } = (event && typeof event === 'object' ? event : {});
22
+ if (typeof job === 'string' && job) {
23
+ if (!(await eventRuntime().runJobOnce(job)))
24
+ throw new Error(`jobs/${job}.ts did not run to completion; the log above names why`);
25
+ return;
26
+ }
27
+ if (Array.isArray(records))
28
+ return eventRuntime().drainQueue();
29
+ throw new Error('the event is neither a scheduled job ({"job": "<name>"}) nor queue messages');
30
+ }
package/dist/runtime.d.ts CHANGED
@@ -87,6 +87,8 @@ export interface RuntimeOptions {
87
87
  localGateway?: Omit<LocalGatewayOptions, 'log'>;
88
88
  /** 'sts-session' when the workload's bearer is a gateway session for the function role (ISOMORPH_GATEWAY_IDENTITY). */
89
89
  gatewayIdentity?: 'sts-session';
90
+ /** The SQS queue an enqueue sends its run to (ISOMORPH_JOBS_QUEUE_URL); the queue loop is off when set. */
91
+ queueUrl?: string;
90
92
  }
91
93
  export declare class Runtime {
92
94
  private readonly options;
@@ -104,8 +106,18 @@ export declare class Runtime {
104
106
  readonly server: import("http").Server<typeof IncomingMessage, typeof ServerResponse>;
105
107
  constructor(options: RuntimeOptions);
106
108
  start(): Promise<number>;
107
- /** Whether this process works the queue at all: not the gate, a runs table, an app with jobs, a gateway to reach. */
109
+ /** Whether this process works the queue at all: not the gate, no queue elsewhere, and there is a queue. */
108
110
  private worksQueue;
111
+ /** A runs table, an app with jobs, a gateway to reach. */
112
+ private hasQueue;
113
+ /**
114
+ * The Event Function's answer to a queue message: wait for the data path,
115
+ * then work passes until one finds nothing. A message stands for "the queue
116
+ * has work", not for its run: a pass claims the oldest run, and a failed
117
+ * run's retry needs a pass after it. A pass that throws fails the
118
+ * invocation, so SQS delivers the message again.
119
+ */
120
+ drainQueue(): Promise<void>;
109
121
  /**
110
122
  * Starts the queue loop unless it is already live. The first pass runs at
111
123
  * once; each further pass follows QUEUE_POLL_MS after one that found work
@@ -146,3 +158,7 @@ export declare class Runtime {
146
158
  export declare function optionsFromEnvironment(env?: NodeJS.ProcessEnv): RuntimeOptions;
147
159
  /** The job named by `--run-job <name>` or `--run-job=<name>`; undefined when the runtime is a server. */
148
160
  export declare function runJobFromArguments(argv?: string[]): string | undefined;
161
+ /** The options of a runtime process; the app's own SDK calls go to the gateway the runtime was given. */
162
+ export declare function processOptions(): RuntimeOptions;
163
+ /** The runtime as a process: this module's main, and the Web Function's (lambda-entry.ts). */
164
+ export declare function main(argv?: string[]): Promise<void>;
package/dist/runtime.js CHANGED
@@ -53,6 +53,9 @@
53
53
  // the workload's bearer is a gateway session the
54
54
  // runtime obtains with its function role (a
55
55
  // serverless cell, ADR 0065), not a token file
56
+ // ISOMORPH_JOBS_QUEUE_URL an enqueue sends the run to this SQS queue and
57
+ // the queue loop never runs here: the cell's
58
+ // Event Function works it (lambda-events-entry.ts)
56
59
  import { AsyncLocalStorage } from 'node:async_hooks';
57
60
  import { createServer } from 'node:http';
58
61
  import { readFile, readdir, stat } from 'node:fs/promises';
@@ -82,8 +85,9 @@ const LEASE_MS = 5 * 60_000;
82
85
  const QUEUE_POLL_MS = 2_000;
83
86
  const TOKEN_REFRESH_MS = 60_000;
84
87
  // A gateway session lasts an hour. An action runs at most 300 s, so a request
85
- // takes a new one in the session's last 10 minutes; a scheduled run may take
86
- // up to 900 s on Lambda and takes one in the last 15.
88
+ // takes a new one in the session's last 10 minutes; an Event Function
89
+ // invocation (a scheduled run, a queue drain) may take up to 900 s and takes
90
+ // one in the last 15.
87
91
  const SESSION_MIN_VALIDITY_MS = 10 * 60_000;
88
92
  const JOB_SESSION_MIN_VALIDITY_MS = 15 * 60_000;
89
93
  const DEFAULT_MAX_ATTEMPTS = 3;
@@ -293,9 +297,32 @@ export class Runtime {
293
297
  this.wakeQueue();
294
298
  return port;
295
299
  }
296
- /** Whether this process works the queue at all: not the gate, a runs table, an app with jobs, a gateway to reach. */
300
+ /** Whether this process works the queue at all: not the gate, no queue elsewhere, and there is a queue. */
297
301
  worksQueue() {
298
- return this.options.mode !== 'gate' && this.options.jobRuns !== false && this.inventory.jobs.length > 0 && Boolean(this.options.gatewayUrl);
302
+ return this.options.mode !== 'gate' && !this.options.queueUrl && this.hasQueue();
303
+ }
304
+ /** A runs table, an app with jobs, a gateway to reach. */
305
+ hasQueue() {
306
+ return this.options.jobRuns !== false && this.inventory.jobs.length > 0 && Boolean(this.options.gatewayUrl);
307
+ }
308
+ /**
309
+ * The Event Function's answer to a queue message: wait for the data path,
310
+ * then work passes until one finds nothing. A message stands for "the queue
311
+ * has work", not for its run: a pass claims the oldest run, and a failed
312
+ * run's retry needs a pass after it. A pass that throws fails the
313
+ * invocation, so SQS delivers the message again.
314
+ */
315
+ async drainQueue() {
316
+ this.inventory = await discover(this.options.root);
317
+ await this.refreshToken(JOB_SESSION_MIN_VALIDITY_MS);
318
+ if (!this.hasQueue()) {
319
+ this.log('a queue message arrived but this app has no runs to work');
320
+ return;
321
+ }
322
+ if (!(await this.awaitDataPath()))
323
+ throw new Error('the data path did not answer; the message is retried');
324
+ while (!this.stopped && await this.work())
325
+ await new Promise((wake) => setTimeout(wake, this.options.queuePollMs ?? QUEUE_POLL_MS));
299
326
  }
300
327
  /**
301
328
  * Starts the queue loop unless it is already live. The first pass runs at
@@ -662,7 +689,13 @@ export class Runtime {
662
689
  if (!owner)
663
690
  throw new IsomorphError('AUTH_REQUIRED', 'a run is started by a signed-in person', undefined, undefined, 401);
664
691
  const queued = await this.runs.enqueue(job, body.input, owner);
665
- this.wakeQueue();
692
+ // Awaited before the enqueue answers: a Web Function may be frozen as soon as it has replied.
693
+ if (this.options.queueUrl) {
694
+ const { awsEnvironment, sendQueueMessage } = await import('./aws.js');
695
+ await sendQueueMessage(awsEnvironment(), this.options.queueUrl, JSON.stringify({ runId: queued.id }));
696
+ }
697
+ else
698
+ this.wakeQueue();
666
699
  return queued;
667
700
  }
668
701
  case GET_RUN_ACTION: {
@@ -692,6 +725,7 @@ export function optionsFromEnvironment(env = process.env) {
692
725
  tokenFile: env.HARBOUR_WORKLOAD_TOKEN_FILE || undefined,
693
726
  jobRuns: env.ISOMORPH_JOB_RUNS !== 'none',
694
727
  ...(env.ISOMORPH_GATEWAY_IDENTITY === 'sts-session' ? { gatewayIdentity: 'sts-session' } : {}),
728
+ ...(env.ISOMORPH_JOBS_QUEUE_URL ? { queueUrl: env.ISOMORPH_JOBS_QUEUE_URL } : {}),
695
729
  ...(env.ISOMORPH_LOCAL_GATEWAY_BINARY && env.ISOMORPH_LOCAL_GATEWAY_CONFIG_FILE
696
730
  ? { localGateway: { binary: env.ISOMORPH_LOCAL_GATEWAY_BINARY, configFile: env.ISOMORPH_LOCAL_GATEWAY_CONFIG_FILE, url: (env.ISOMORPH_LOCAL_GATEWAY_URL || '').replace(/\/$/, '') } }
697
731
  : {}),
@@ -708,33 +742,26 @@ export function runJobFromArguments(argv = process.argv.slice(2)) {
708
742
  }
709
743
  return undefined;
710
744
  }
711
- // Node resolves this module's URL through any symlink in its path, while
712
- // process.argv[1] is the path the starter typed. On macOS /tmp and /var are
713
- // symlinks, so a gate or `isomorph dev` started from an app root under them
714
- // handed a path whose URL never matched, and the runtime loaded as a library
715
- // and exited 0 without a word (the gate reported "exited before it
716
- // answered", 2026-10-06). Compare the real path, as Node did.
717
- const entryPath = process.argv[1] ? (() => { try {
718
- return realpathSync(process.argv[1]);
745
+ /** The options of a runtime process; the app's own SDK calls go to the gateway the runtime was given. */
746
+ export function processOptions() {
747
+ const options = optionsFromEnvironment();
748
+ if (options.gatewayUrl && !process.env.ISOMORPH_GATEWAY_URL)
749
+ process.env.ISOMORPH_GATEWAY_URL = options.gatewayUrl;
750
+ return options;
719
751
  }
720
- catch {
721
- return process.argv[1];
722
- } })() : '';
723
- const entry = entryPath ? pathToFileURL(entryPath).href : '';
724
- if (import.meta.url === entry) {
752
+ /** The runtime as a process: this module's main, and the Web Function's (lambda-entry.ts). */
753
+ export async function main(argv = process.argv.slice(2)) {
725
754
  // Extensionless relative imports in the app's server code resolve to
726
755
  // their .ts files (runtime-hooks.ts); registered before any app module
727
756
  // is imported.
728
757
  register('./runtime-hooks.js', import.meta.url);
729
- const options = optionsFromEnvironment();
730
- if (options.gatewayUrl && !process.env.ISOMORPH_GATEWAY_URL)
731
- process.env.ISOMORPH_GATEWAY_URL = options.gatewayUrl;
758
+ const options = processOptions();
732
759
  if (!(await stat(options.root).catch(() => undefined))?.isDirectory()) {
733
760
  console.error(`isomorph runtime: ${options.root} is not a directory`);
734
761
  process.exit(2);
735
762
  }
736
763
  const runtime = new Runtime(options);
737
- const job = runJobFromArguments();
764
+ const job = runJobFromArguments(argv);
738
765
  if (job !== undefined) {
739
766
  if (!job) {
740
767
  console.error('isomorph runtime: --run-job needs a job name');
@@ -747,3 +774,18 @@ if (import.meta.url === entry) {
747
774
  process.on('SIGINT', shutdown);
748
775
  await runtime.start();
749
776
  }
777
+ // Node resolves this module's URL through any symlink in its path, while
778
+ // process.argv[1] is the path the starter typed. On macOS /tmp and /var are
779
+ // symlinks, so a gate or `isomorph dev` started from an app root under them
780
+ // handed a path whose URL never matched, and the runtime loaded as a library
781
+ // and exited 0 without a word (the gate reported "exited before it
782
+ // answered", 2026-10-06). Compare the real path, as Node did.
783
+ const entryPath = process.argv[1] ? (() => { try {
784
+ return realpathSync(process.argv[1]);
785
+ }
786
+ catch {
787
+ return process.argv[1];
788
+ } })() : '';
789
+ const entry = entryPath ? pathToFileURL(entryPath).href : '';
790
+ if (import.meta.url === entry)
791
+ await main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isomorph.ai/app-sdk",
3
- "version": "1.22.0",
3
+ "version": "1.24.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",