@isomorph.ai/app-sdk 1.3.0 → 1.4.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/dist/runtime-hooks.d.ts +13 -0
- package/dist/runtime-hooks.js +33 -0
- package/dist/runtime.d.ts +19 -1
- package/dist/runtime.js +62 -8
- package/package.json +1 -1
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
type ResolveContext = {
|
|
2
|
+
parentURL?: string;
|
|
3
|
+
conditions: string[];
|
|
4
|
+
importAttributes: Record<string, string>;
|
|
5
|
+
};
|
|
6
|
+
type ResolveResult = {
|
|
7
|
+
url: string;
|
|
8
|
+
format?: string | null;
|
|
9
|
+
shortCircuit?: boolean;
|
|
10
|
+
};
|
|
11
|
+
type NextResolve = (specifier: string, context: ResolveContext) => Promise<ResolveResult> | ResolveResult;
|
|
12
|
+
export declare function resolve(specifier: string, context: ResolveContext, nextResolve: NextResolve): Promise<ResolveResult>;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Module resolution for the kit runtime (registered by runtime.ts through
|
|
2
|
+
// node:module's register, so it runs on Node's loader thread). Node's type
|
|
3
|
+
// stripping loads a `.ts` file but resolves nothing for it: a relative import
|
|
4
|
+
// written the way the browser code writes it — `../src/isomorph.client`, no
|
|
5
|
+
// extension — fails with "Cannot find module". The first hosted job that
|
|
6
|
+
// imported the app's client died exactly there (fourier smoke app, preview
|
|
7
|
+
// 000006, 2026-09-21). An extensionless relative specifier is tried as the
|
|
8
|
+
// `.ts` file, then as a folder's `index.ts`; anything else, and anything with
|
|
9
|
+
// an extension, is left to Node.
|
|
10
|
+
import { access } from 'node:fs/promises';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
const candidates = ['.ts', '.mts', '/index.ts'];
|
|
13
|
+
async function exists(url) {
|
|
14
|
+
try {
|
|
15
|
+
await access(fileURLToPath(url));
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export async function resolve(specifier, context, nextResolve) {
|
|
23
|
+
const relative = specifier.startsWith('./') || specifier.startsWith('../');
|
|
24
|
+
const extensionless = !/\.[A-Za-z0-9]+$/.test(specifier);
|
|
25
|
+
if (relative && extensionless && context.parentURL?.startsWith('file:')) {
|
|
26
|
+
const base = new URL(specifier, context.parentURL).href;
|
|
27
|
+
for (const suffix of candidates) {
|
|
28
|
+
if (await exists(base + suffix))
|
|
29
|
+
return nextResolve(base + suffix, context);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return nextResolve(specifier, context);
|
|
33
|
+
}
|
package/dist/runtime.d.ts
CHANGED
|
@@ -56,11 +56,18 @@ export declare class Runs {
|
|
|
56
56
|
cancelled?: boolean;
|
|
57
57
|
}): Promise<void>;
|
|
58
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* What the process runs beside the action server:
|
|
61
|
+
* full the in-process cron scheduler and the queue loop (the default)
|
|
62
|
+
* actions the queue loop only; the platform's CronJobs run the schedules
|
|
63
|
+
* gate nothing: actions only, for the kit gate
|
|
64
|
+
*/
|
|
65
|
+
export type RuntimeMode = 'full' | 'actions' | 'gate';
|
|
59
66
|
export interface RuntimeOptions {
|
|
60
67
|
root: string;
|
|
61
68
|
port: number;
|
|
62
69
|
gatewayUrl: string;
|
|
63
|
-
mode:
|
|
70
|
+
mode: RuntimeMode;
|
|
64
71
|
tokenFile?: string;
|
|
65
72
|
log?: (line: string) => void;
|
|
66
73
|
}
|
|
@@ -75,10 +82,19 @@ export declare class Runtime {
|
|
|
75
82
|
readonly server: import("http").Server<typeof IncomingMessage, typeof ServerResponse>;
|
|
76
83
|
constructor(options: RuntimeOptions);
|
|
77
84
|
start(): Promise<number>;
|
|
85
|
+
/**
|
|
86
|
+
* `--run-job <name>`: one scheduled run of one job, as a CronJob pod runs it.
|
|
87
|
+
* The run is the scheduler's own (`runScheduled`, the current minute as its
|
|
88
|
+
* tick), so it is loaded, run and recorded exactly as an in-process tick
|
|
89
|
+
* would have been. False when the app has no such job or the handler threw;
|
|
90
|
+
* the log names which.
|
|
91
|
+
*/
|
|
92
|
+
runJobOnce(name: string, now?: Date): Promise<boolean>;
|
|
78
93
|
stop(): Promise<void>;
|
|
79
94
|
private every;
|
|
80
95
|
private refreshToken;
|
|
81
96
|
private scheduleTick;
|
|
97
|
+
/** One scheduled run; true when the handler returned, false when it threw or the last tick's run is still going. */
|
|
82
98
|
private runScheduled;
|
|
83
99
|
/** One pass of the queue loop: reclaim expired leases, claim one run, run it. */
|
|
84
100
|
work(): Promise<void>;
|
|
@@ -87,3 +103,5 @@ export declare class Runtime {
|
|
|
87
103
|
}
|
|
88
104
|
/** The runtime's settings from the environment; exported for the entry point and its test. */
|
|
89
105
|
export declare function optionsFromEnvironment(env?: NodeJS.ProcessEnv): RuntimeOptions;
|
|
106
|
+
/** The job named by `--run-job <name>` or `--run-job=<name>`; undefined when the runtime is a server. */
|
|
107
|
+
export declare function runJobFromArguments(argv?: string[]): string | undefined;
|
package/dist/runtime.js
CHANGED
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
// database or a company system directly: every read and write goes through
|
|
11
11
|
// the App Gateway as the workload identity, the same door the browser uses.
|
|
12
12
|
//
|
|
13
|
+
// Started with `--run-job <name>` it is a CronJob pod instead of a server: it
|
|
14
|
+
// runs that one `jobs/<name>.ts` once for the current minute, the way the
|
|
15
|
+
// in-process scheduler would have, and exits 0 when the handler returned, 1
|
|
16
|
+
// when it threw or the app has no such job. No port is opened, no scheduler
|
|
17
|
+
// or queue loop starts.
|
|
18
|
+
//
|
|
13
19
|
// Environment:
|
|
14
20
|
// PORT the action server's port (default 8080)
|
|
15
21
|
// ISOMORPH_GATEWAY_URL the gateway the SDK calls; HARBOUR_GATEWAY_URL
|
|
@@ -17,9 +23,17 @@
|
|
|
17
23
|
// ISOMORPH_WORKLOAD_TOKEN the workload's bearer; HARBOUR_WORKLOAD_TOKEN_FILE
|
|
18
24
|
// names a projected token file re-read every minute
|
|
19
25
|
// ISOMORPH_RUNTIME_MODE=gate serve actions only: no scheduler, no queue loop
|
|
26
|
+
// ISOMORPH_JOBS_SCHEDULER=platform
|
|
27
|
+
// the platform runs the cron (one CronJob pod per
|
|
28
|
+
// scheduled job, each `--run-job <name>`): serve
|
|
29
|
+
// actions and work the queue, never tick the
|
|
30
|
+
// in-process scheduler. Set by Provision on the
|
|
31
|
+
// hosted kit-runtime; `isomorph dev` leaves it
|
|
32
|
+
// unset and keeps cron in-process
|
|
20
33
|
// ISOMORPH_APP_ROOT the app root (default: the working directory)
|
|
21
34
|
import { createServer } from 'node:http';
|
|
22
35
|
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
36
|
+
import { register } from 'node:module';
|
|
23
37
|
import { join, resolve } from 'node:path';
|
|
24
38
|
import { pathToFileURL } from 'node:url';
|
|
25
39
|
import { createClient, IsomorphError } from './index.js';
|
|
@@ -208,14 +222,28 @@ export class Runtime {
|
|
|
208
222
|
this.log(`serving ${this.inventory.actions.length} action(s) and ${this.inventory.jobs.length} job(s) on port ${port} (${this.options.mode})`);
|
|
209
223
|
if (this.options.tokenFile)
|
|
210
224
|
this.every(TOKEN_REFRESH_MS, () => this.refreshToken());
|
|
211
|
-
if (this.options.mode === 'full')
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
this.every(QUEUE_POLL_MS, () => this.work());
|
|
216
|
-
}
|
|
225
|
+
if (this.options.mode === 'full' && this.inventory.jobs.some((job) => job.schedule))
|
|
226
|
+
this.scheduleTick();
|
|
227
|
+
if (this.options.mode !== 'gate' && this.inventory.jobs.length > 0 && this.options.gatewayUrl)
|
|
228
|
+
this.every(QUEUE_POLL_MS, () => this.work());
|
|
217
229
|
return port;
|
|
218
230
|
}
|
|
231
|
+
/**
|
|
232
|
+
* `--run-job <name>`: one scheduled run of one job, as a CronJob pod runs it.
|
|
233
|
+
* The run is the scheduler's own (`runScheduled`, the current minute as its
|
|
234
|
+
* tick), so it is loaded, run and recorded exactly as an in-process tick
|
|
235
|
+
* would have been. False when the app has no such job or the handler threw;
|
|
236
|
+
* the log names which.
|
|
237
|
+
*/
|
|
238
|
+
async runJobOnce(name, now = new Date()) {
|
|
239
|
+
this.inventory = await discover(this.options.root);
|
|
240
|
+
await this.refreshToken();
|
|
241
|
+
if (!this.inventory.jobs.some((job) => job.name === name)) {
|
|
242
|
+
this.log(`this app has no ${JOBS_DIR}/${name}.ts`);
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
return this.runScheduled(name, new Date(Math.floor(now.getTime() / 60_000) * 60_000));
|
|
246
|
+
}
|
|
219
247
|
async stop() {
|
|
220
248
|
this.stopped = true;
|
|
221
249
|
for (const timer of this.timers)
|
|
@@ -266,20 +294,23 @@ export class Runtime {
|
|
|
266
294
|
this.scheduleTick();
|
|
267
295
|
}, Math.max(0, next - now)));
|
|
268
296
|
}
|
|
297
|
+
/** One scheduled run; true when the handler returned, false when it threw or the last tick's run is still going. */
|
|
269
298
|
async runScheduled(name, at) {
|
|
270
299
|
const key = `schedule:${name}`;
|
|
271
300
|
if (this.running.has(key)) {
|
|
272
301
|
this.log(`jobs/${name}.ts is still running; the ${at.toISOString()} tick is skipped`);
|
|
273
|
-
return;
|
|
302
|
+
return false;
|
|
274
303
|
}
|
|
275
304
|
this.running.add(key);
|
|
276
305
|
try {
|
|
277
306
|
const handler = await loadHandler(this.options.root, JOBS_DIR, name);
|
|
278
307
|
await handler({ scheduledAt: at.toISOString(), progress: async () => undefined, cancelled: async () => false });
|
|
279
308
|
this.log(`jobs/${name}.ts ran for ${at.toISOString()}`);
|
|
309
|
+
return true;
|
|
280
310
|
}
|
|
281
311
|
catch (error) {
|
|
282
312
|
this.log(`jobs/${name}.ts failed for ${at.toISOString()}: ${error instanceof Error ? error.message : String(error)}`);
|
|
313
|
+
return false;
|
|
283
314
|
}
|
|
284
315
|
finally {
|
|
285
316
|
this.running.delete(key);
|
|
@@ -393,12 +424,27 @@ export function optionsFromEnvironment(env = process.env) {
|
|
|
393
424
|
root: resolve(env.ISOMORPH_APP_ROOT || process.cwd()),
|
|
394
425
|
port: Number(env.PORT || 8080),
|
|
395
426
|
gatewayUrl: (env.ISOMORPH_GATEWAY_URL || env.HARBOUR_GATEWAY_URL || '').replace(/\/$/, ''),
|
|
396
|
-
mode: env.ISOMORPH_RUNTIME_MODE === 'gate' ? 'gate' : 'full',
|
|
427
|
+
mode: env.ISOMORPH_RUNTIME_MODE === 'gate' ? 'gate' : env.ISOMORPH_JOBS_SCHEDULER === 'platform' ? 'actions' : 'full',
|
|
397
428
|
tokenFile: env.HARBOUR_WORKLOAD_TOKEN_FILE || undefined,
|
|
398
429
|
};
|
|
399
430
|
}
|
|
431
|
+
/** The job named by `--run-job <name>` or `--run-job=<name>`; undefined when the runtime is a server. */
|
|
432
|
+
export function runJobFromArguments(argv = process.argv.slice(2)) {
|
|
433
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
434
|
+
const argument = argv[i];
|
|
435
|
+
if (argument === '--run-job')
|
|
436
|
+
return argv[i + 1] ?? '';
|
|
437
|
+
if (argument.startsWith('--run-job='))
|
|
438
|
+
return argument.slice('--run-job='.length);
|
|
439
|
+
}
|
|
440
|
+
return undefined;
|
|
441
|
+
}
|
|
400
442
|
const entry = process.argv[1] ? pathToFileURL(process.argv[1]).href : '';
|
|
401
443
|
if (import.meta.url === entry) {
|
|
444
|
+
// Extensionless relative imports in the app's server code resolve to
|
|
445
|
+
// their .ts files (runtime-hooks.ts); registered before any app module
|
|
446
|
+
// is imported.
|
|
447
|
+
register('./runtime-hooks.js', import.meta.url);
|
|
402
448
|
const options = optionsFromEnvironment();
|
|
403
449
|
if (options.gatewayUrl && !process.env.ISOMORPH_GATEWAY_URL)
|
|
404
450
|
process.env.ISOMORPH_GATEWAY_URL = options.gatewayUrl;
|
|
@@ -407,6 +453,14 @@ if (import.meta.url === entry) {
|
|
|
407
453
|
process.exit(2);
|
|
408
454
|
}
|
|
409
455
|
const runtime = new Runtime(options);
|
|
456
|
+
const job = runJobFromArguments();
|
|
457
|
+
if (job !== undefined) {
|
|
458
|
+
if (!job) {
|
|
459
|
+
console.error('isomorph runtime: --run-job needs a job name');
|
|
460
|
+
process.exit(2);
|
|
461
|
+
}
|
|
462
|
+
process.exit((await runtime.runJobOnce(job)) ? 0 : 1);
|
|
463
|
+
}
|
|
410
464
|
const shutdown = () => { void runtime.stop().then(() => process.exit(0)); };
|
|
411
465
|
process.on('SIGTERM', shutdown);
|
|
412
466
|
process.on('SIGINT', shutdown);
|