@isomorph.ai/app-sdk 1.21.1 → 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
@@ -453,3 +453,13 @@ await isomorph.actions.invoke('research', input, { onProgress: (step) => setStep
453
453
  Without `onProgress` the call is unchanged and `ctx.progress` does nothing. At
454
454
  most 256 steps of up to 1 KiB each reach the page; further steps are dropped.
455
455
  The action still has the 300 s action budget. (Unreleased)
456
+
457
+ ## Running on Lambda
458
+
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
+
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 ADDED
@@ -0,0 +1,18 @@
1
+ export interface AwsEnvironment {
2
+ accessKeyId: string;
3
+ secretAccessKey: string;
4
+ sessionToken?: string;
5
+ region: string;
6
+ }
7
+ export declare function awsEnvironment(env?: NodeJS.ProcessEnv): AwsEnvironment;
8
+ /**
9
+ * A presigned GET of STS GetCallerIdentity on the region's dual-stack
10
+ * endpoint. Host is the only signed header: the gateway forwards the query
11
+ * alone (ADR 0068), so a signature over any other header could never verify.
12
+ */
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 ADDED
@@ -0,0 +1,51 @@
1
+ // The AWS calls a serverless runtime makes with its function role, signed
2
+ // here rather than through an AWS SDK client (ADR 0065). Credentials and the
3
+ // region come from the Lambda environment on every call.
4
+ import { Sha256 } from '@aws-crypto/sha256-js';
5
+ import { HttpRequest } from '@smithy/protocol-http';
6
+ import { SignatureV4 } from '@smithy/signature-v4';
7
+ export function awsEnvironment(env = process.env) {
8
+ const accessKeyId = env.AWS_ACCESS_KEY_ID;
9
+ const secretAccessKey = env.AWS_SECRET_ACCESS_KEY;
10
+ const region = env.AWS_REGION || env.AWS_DEFAULT_REGION;
11
+ if (!accessKeyId || !secretAccessKey)
12
+ throw new Error('the function has no AWS credentials in its environment (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)');
13
+ if (!region)
14
+ throw new Error('the function has no AWS region in its environment (AWS_REGION)');
15
+ return { accessKeyId, secretAccessKey, ...(env.AWS_SESSION_TOKEN ? { sessionToken: env.AWS_SESSION_TOKEN } : {}), region };
16
+ }
17
+ function signer(aws, service) {
18
+ return new SignatureV4({ service, region: aws.region, sha256: Sha256, credentials: { accessKeyId: aws.accessKeyId, secretAccessKey: aws.secretAccessKey, ...(aws.sessionToken ? { sessionToken: aws.sessionToken } : {}) } });
19
+ }
20
+ /**
21
+ * A presigned GET of STS GetCallerIdentity on the region's dual-stack
22
+ * endpoint. Host is the only signed header: the gateway forwards the query
23
+ * alone (ADR 0068), so a signature over any other header could never verify.
24
+ */
25
+ export async function presignCallerIdentity(aws, signingDate = new Date()) {
26
+ const hostname = `sts.${aws.region}.api.aws`;
27
+ const request = new HttpRequest({ method: 'GET', protocol: 'https:', hostname, path: '/', query: { Action: 'GetCallerIdentity', Version: '2011-06-15' }, headers: { host: hostname } });
28
+ const signed = await signer(aws, 'sts').presign(request, { expiresIn: 300, signingDate });
29
+ const query = new URLSearchParams();
30
+ for (const [key, value] of Object.entries(signed.query ?? {}))
31
+ if (typeof value === 'string')
32
+ query.set(key, value);
33
+ return `https://${hostname}/?${query.toString()}`;
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,22 @@
1
+ export declare const SESSION_PATH = "/_harbour/internal/session";
2
+ export interface GatewaySessionOptions {
3
+ gatewayUrl: string;
4
+ env?: NodeJS.ProcessEnv;
5
+ fetch?: typeof fetch;
6
+ /** The pauses between attempts the gateway or STS did not answer; the tests shorten them. */
7
+ retryDelaysMs?: number[];
8
+ now?: () => number;
9
+ }
10
+ export declare class GatewaySession {
11
+ private readonly options;
12
+ private current;
13
+ private pending;
14
+ constructor(options: GatewaySessionOptions);
15
+ /**
16
+ * The cached token while it stays valid for at least `minValidityMs`, else a
17
+ * new one. Concurrent callers share one exchange.
18
+ */
19
+ token(minValidityMs: number): Promise<string>;
20
+ private now;
21
+ private exchange;
22
+ }
@@ -0,0 +1,65 @@
1
+ // The runtime's gateway identity on a serverless cell, where there is no
2
+ // projected ServiceAccount token to read (ISOMORPH_GATEWAY_IDENTITY=sts-session,
3
+ // ADR 0065). The runtime proves its function role with a presigned STS
4
+ // GetCallerIdentity and the gateway answers with a one-hour bearer for the app
5
+ // whose declaration names that role (the gateway's ADR 0068).
6
+ import { setTimeout as sleep } from 'node:timers/promises';
7
+ import { awsEnvironment, presignCallerIdentity } from './aws.js';
8
+ export const SESSION_PATH = '/_harbour/internal/session';
9
+ // The gateway answers 503 when STS did not, with no Retry-After; an ALB in
10
+ // front answers 502 or 504 while the gateway task restarts.
11
+ const RETRY_STATUSES = new Set([502, 503, 504]);
12
+ const RETRY_DELAYS_MS = [250, 500, 1_000, 2_000, 4_000];
13
+ export class GatewaySession {
14
+ options;
15
+ current;
16
+ pending;
17
+ constructor(options) {
18
+ this.options = options;
19
+ }
20
+ /**
21
+ * The cached token while it stays valid for at least `minValidityMs`, else a
22
+ * new one. Concurrent callers share one exchange.
23
+ */
24
+ async token(minValidityMs) {
25
+ if (this.current && this.current.expiresAt - this.now() >= minValidityMs)
26
+ return this.current.token;
27
+ this.pending ??= this.exchange().finally(() => { this.pending = undefined; });
28
+ return this.pending;
29
+ }
30
+ now() { return (this.options.now ?? Date.now)(); }
31
+ async exchange() {
32
+ const delays = this.options.retryDelaysMs ?? RETRY_DELAYS_MS;
33
+ let refusedOnce = false;
34
+ for (let attempt = 0;; attempt += 1) {
35
+ // Presigned per attempt: the gateway refuses one signed more than five minutes ago.
36
+ const presignedUrl = await presignCallerIdentity(awsEnvironment(this.options.env));
37
+ let response;
38
+ let failure;
39
+ try {
40
+ response = await (this.options.fetch ?? fetch)(`${this.options.gatewayUrl}${SESSION_PATH}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ presignedUrl }), signal: AbortSignal.timeout(10_000) });
41
+ failure = `the gateway answered ${response.status}`;
42
+ }
43
+ catch (error) {
44
+ failure = `the gateway did not answer: ${error instanceof Error ? error.message : String(error)}`;
45
+ }
46
+ if (response?.ok) {
47
+ const body = await response.json();
48
+ const expiresAt = typeof body.data?.expiresAt === 'string' ? Date.parse(body.data.expiresAt) : NaN;
49
+ if (typeof body.data?.token !== 'string' || !body.data.token || Number.isNaN(expiresAt))
50
+ throw new Error('the gateway session answer has no token and expiry');
51
+ this.current = { token: body.data.token, expiresAt };
52
+ return this.current.token;
53
+ }
54
+ // A refusal gets one more exchange with a new presign; the second is final.
55
+ if (response?.status === 401 && !refusedOnce) {
56
+ refusedOnce = true;
57
+ continue;
58
+ }
59
+ const retryable = response === undefined || RETRY_STATUSES.has(response.status);
60
+ if (!retryable || attempt >= delays.length)
61
+ throw new Error(`the gateway session was not issued: ${failure}`);
62
+ await sleep(delays[attempt]);
63
+ }
64
+ }
65
+ }
@@ -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
@@ -85,6 +85,10 @@ export interface RuntimeOptions {
85
85
  queuePollMs?: number;
86
86
  /** Set when the runtime itself runs the App Gateway for its trusted actions (the Lambda option). */
87
87
  localGateway?: Omit<LocalGatewayOptions, 'log'>;
88
+ /** 'sts-session' when the workload's bearer is a gateway session for the function role (ISOMORPH_GATEWAY_IDENTITY). */
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;
88
92
  }
89
93
  export declare class Runtime {
90
94
  private readonly options;
@@ -97,12 +101,23 @@ export declare class Runtime {
97
101
  private queueLive;
98
102
  private queueWake;
99
103
  private readonly runs;
104
+ private session;
100
105
  private readonly log;
101
106
  readonly server: import("http").Server<typeof IncomingMessage, typeof ServerResponse>;
102
107
  constructor(options: RuntimeOptions);
103
108
  start(): Promise<number>;
104
- /** 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. */
105
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>;
106
121
  /**
107
122
  * Starts the queue loop unless it is already live. The first pass runs at
108
123
  * once; each further pass follows QUEUE_POLL_MS after one that found work
@@ -143,3 +158,7 @@ export declare class Runtime {
143
158
  export declare function optionsFromEnvironment(env?: NodeJS.ProcessEnv): RuntimeOptions;
144
159
  /** The job named by `--run-job <name>` or `--run-job=<name>`; undefined when the runtime is a server. */
145
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
@@ -49,6 +49,13 @@
49
49
  // runs without waiting on the data path. Set by
50
50
  // Provision; the table exists otherwise
51
51
  // ISOMORPH_APP_ROOT the app root (default: the working directory)
52
+ // ISOMORPH_GATEWAY_IDENTITY=sts-session
53
+ // the workload's bearer is a gateway session the
54
+ // runtime obtains with its function role (a
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)
52
59
  import { AsyncLocalStorage } from 'node:async_hooks';
53
60
  import { createServer } from 'node:http';
54
61
  import { readFile, readdir, stat } from 'node:fs/promises';
@@ -77,6 +84,12 @@ const MAX_BODY_BYTES = 1 << 20;
77
84
  const LEASE_MS = 5 * 60_000;
78
85
  const QUEUE_POLL_MS = 2_000;
79
86
  const TOKEN_REFRESH_MS = 60_000;
87
+ // A gateway session lasts an hour. An action runs at most 300 s, so a request
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.
91
+ const SESSION_MIN_VALIDITY_MS = 10 * 60_000;
92
+ const JOB_SESSION_MIN_VALIDITY_MS = 15 * 60_000;
80
93
  const DEFAULT_MAX_ATTEMPTS = 3;
81
94
  // A CronJob pod may start while the app's database is still waking: --run-job
82
95
  // waits this long for the gateway's data path, probing every retry interval.
@@ -248,6 +261,9 @@ export class Runtime {
248
261
  queueLive = false;
249
262
  queueWake = false;
250
263
  runs;
264
+ // Built on first use: the AWS signing modules load only on a serverless
265
+ // cell, so a cluster runtime never imports them.
266
+ session;
251
267
  log;
252
268
  server = createServer((request, response) => { void this.serve(request, response); });
253
269
  constructor(options) {
@@ -257,7 +273,12 @@ export class Runtime {
257
273
  }
258
274
  async start() {
259
275
  this.inventory = await discover(this.options.root);
260
- await this.refreshToken();
276
+ // A Web Function must listen within 10 s of starting, so the session is
277
+ // only begun here; the first request that needs it waits for it.
278
+ if (this.options.gatewayIdentity)
279
+ void this.refreshToken().catch((error) => this.log(`gateway session: ${error instanceof Error ? error.message : String(error)}`));
280
+ else
281
+ await this.refreshToken();
261
282
  if (this.options.localGateway && this.inventory.trusted.length > 0) {
262
283
  this.localGateway = new LocalGateway({ ...this.options.localGateway, log: this.log });
263
284
  await this.localGateway.start();
@@ -276,9 +297,32 @@ export class Runtime {
276
297
  this.wakeQueue();
277
298
  return port;
278
299
  }
279
- /** 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. */
280
301
  worksQueue() {
281
- 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));
282
326
  }
283
327
  /**
284
328
  * Starts the queue loop unless it is already live. The first pass runs at
@@ -328,7 +372,7 @@ export class Runtime {
328
372
  */
329
373
  async runJobOnce(name, now = new Date()) {
330
374
  this.inventory = await discover(this.options.root);
331
- await this.refreshToken();
375
+ await this.refreshToken(JOB_SESSION_MIN_VALIDITY_MS);
332
376
  if (!this.inventory.jobs.some((job) => job.name === name)) {
333
377
  this.log(`this app has no ${JOBS_DIR}/${name}.ts`);
334
378
  return false;
@@ -401,7 +445,12 @@ export class Runtime {
401
445
  })().catch(error => { this.aiBinding = undefined; throw error; });
402
446
  await this.aiBinding;
403
447
  }
404
- async refreshToken() {
448
+ async refreshToken(minValidityMs = SESSION_MIN_VALIDITY_MS) {
449
+ if (this.options.gatewayIdentity) {
450
+ this.session ??= import('./gateway-session.js').then(({ GatewaySession }) => new GatewaySession({ gatewayUrl: this.options.gatewayUrl }));
451
+ process.env.ISOMORPH_WORKLOAD_TOKEN = await (await this.session).token(minValidityMs);
452
+ return;
453
+ }
405
454
  if (!this.options.tokenFile)
406
455
  return;
407
456
  const token = (await readFile(this.options.tokenFile, 'utf8')).trim();
@@ -553,6 +602,12 @@ export class Runtime {
553
602
  return refuse(response, 400, 'VALIDATION_FAILED', 'the request body is not JSON', requestId);
554
603
  }
555
604
  }
605
+ // Only the runtime's own calls, a trusted action's and the AI binding
606
+ // send the bearer; an action acting as its caller does not wait for it.
607
+ // Without a session such a call fails with AUTH_REQUIRED, which the builder reads.
608
+ const sendsBearer = name.startsWith(RESERVED_PREFIX) ? name !== PING_ACTION : this.inventory.trusted.includes(name) || Boolean(process.env.ISOMORPH_AI_VERSION_ID);
609
+ if (this.options.gatewayIdentity && sendsBearer)
610
+ await this.refreshToken().catch((error) => this.log(`gateway session: ${error instanceof Error ? error.message : String(error)}`));
556
611
  try {
557
612
  if (name.startsWith(RESERVED_PREFIX))
558
613
  return answer(response, 200, { data: await this.reserved(name, input, context) }, requestId);
@@ -634,7 +689,13 @@ export class Runtime {
634
689
  if (!owner)
635
690
  throw new IsomorphError('AUTH_REQUIRED', 'a run is started by a signed-in person', undefined, undefined, 401);
636
691
  const queued = await this.runs.enqueue(job, body.input, owner);
637
- 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();
638
699
  return queued;
639
700
  }
640
701
  case GET_RUN_ACTION: {
@@ -663,6 +724,8 @@ export function optionsFromEnvironment(env = process.env) {
663
724
  mode: env.ISOMORPH_RUNTIME_MODE === 'gate' ? 'gate' : env.ISOMORPH_JOBS_SCHEDULER === 'platform' ? 'actions' : 'full',
664
725
  tokenFile: env.HARBOUR_WORKLOAD_TOKEN_FILE || undefined,
665
726
  jobRuns: env.ISOMORPH_JOB_RUNS !== 'none',
727
+ ...(env.ISOMORPH_GATEWAY_IDENTITY === 'sts-session' ? { gatewayIdentity: 'sts-session' } : {}),
728
+ ...(env.ISOMORPH_JOBS_QUEUE_URL ? { queueUrl: env.ISOMORPH_JOBS_QUEUE_URL } : {}),
666
729
  ...(env.ISOMORPH_LOCAL_GATEWAY_BINARY && env.ISOMORPH_LOCAL_GATEWAY_CONFIG_FILE
667
730
  ? { localGateway: { binary: env.ISOMORPH_LOCAL_GATEWAY_BINARY, configFile: env.ISOMORPH_LOCAL_GATEWAY_CONFIG_FILE, url: (env.ISOMORPH_LOCAL_GATEWAY_URL || '').replace(/\/$/, '') } }
668
731
  : {}),
@@ -679,33 +742,26 @@ export function runJobFromArguments(argv = process.argv.slice(2)) {
679
742
  }
680
743
  return undefined;
681
744
  }
682
- // Node resolves this module's URL through any symlink in its path, while
683
- // process.argv[1] is the path the starter typed. On macOS /tmp and /var are
684
- // symlinks, so a gate or `isomorph dev` started from an app root under them
685
- // handed a path whose URL never matched, and the runtime loaded as a library
686
- // and exited 0 without a word (the gate reported "exited before it
687
- // answered", 2026-10-06). Compare the real path, as Node did.
688
- const entryPath = process.argv[1] ? (() => { try {
689
- 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;
690
751
  }
691
- catch {
692
- return process.argv[1];
693
- } })() : '';
694
- const entry = entryPath ? pathToFileURL(entryPath).href : '';
695
- 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)) {
696
754
  // Extensionless relative imports in the app's server code resolve to
697
755
  // their .ts files (runtime-hooks.ts); registered before any app module
698
756
  // is imported.
699
757
  register('./runtime-hooks.js', import.meta.url);
700
- const options = optionsFromEnvironment();
701
- if (options.gatewayUrl && !process.env.ISOMORPH_GATEWAY_URL)
702
- process.env.ISOMORPH_GATEWAY_URL = options.gatewayUrl;
758
+ const options = processOptions();
703
759
  if (!(await stat(options.root).catch(() => undefined))?.isDirectory()) {
704
760
  console.error(`isomorph runtime: ${options.root} is not a directory`);
705
761
  process.exit(2);
706
762
  }
707
763
  const runtime = new Runtime(options);
708
- const job = runJobFromArguments();
764
+ const job = runJobFromArguments(argv);
709
765
  if (job !== undefined) {
710
766
  if (!job) {
711
767
  console.error('isomorph runtime: --run-job needs a job name');
@@ -718,3 +774,18 @@ if (import.meta.url === entry) {
718
774
  process.on('SIGINT', shutdown);
719
775
  await runtime.start();
720
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.21.1",
3
+ "version": "1.24.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -38,6 +38,9 @@
38
38
  }
39
39
  },
40
40
  "dependencies": {
41
+ "@aws-crypto/sha256-js": "5.2.0",
42
+ "@smithy/protocol-http": "5.6.2",
43
+ "@smithy/signature-v4": "5.7.4",
41
44
  "acorn": "8.18.0"
42
45
  }
43
46
  }