@geekmidas/cli 10.0.0-alpha.24 → 10.0.0-alpha.25
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/bin/adjacent-tsconfig.mjs +46 -8
- package/bin/gkm.mjs +2 -2
- package/dist/{config-DdKkoUE1.mjs → config-DBGpRTUy.mjs} +2 -2
- package/dist/{config-DdKkoUE1.mjs.map → config-DBGpRTUy.mjs.map} +1 -1
- package/dist/{config-DSXI480r.cjs → config-Dn9DW2Fz.cjs} +2 -2
- package/dist/{config-DSXI480r.cjs.map → config-Dn9DW2Fz.cjs.map} +1 -1
- package/dist/config.cjs +1 -1
- package/dist/config.mjs +1 -1
- package/dist/{credentials-D9TKJJZc.mjs → credentials-BpoOUa74.mjs} +17 -6
- package/dist/credentials-BpoOUa74.mjs.map +1 -0
- package/dist/{credentials-COrSpZfF.cjs → credentials-Dbyccl3A.cjs} +17 -6
- package/dist/credentials-Dbyccl3A.cjs.map +1 -0
- package/dist/deploy/sniffer-routes-worker.cjs +1 -1
- package/dist/deploy/sniffer-routes-worker.mjs +1 -1
- package/dist/{discover-CK-n3w6b.mjs → discover-0fhCbnk0.mjs} +3 -2
- package/dist/{discover-CK-n3w6b.mjs.map → discover-0fhCbnk0.mjs.map} +1 -1
- package/dist/{discover-BERGFg8v.cjs → discover-B_HtYJCr.cjs} +3 -2
- package/dist/{discover-BERGFg8v.cjs.map → discover-B_HtYJCr.cjs.map} +1 -1
- package/dist/{github-B4OQQZBV.mjs → github-BOaa3kQR.mjs} +3 -3
- package/dist/{github-B4OQQZBV.mjs.map → github-BOaa3kQR.mjs.map} +1 -1
- package/dist/{github-BpsnSDw2.cjs → github-CUjW3G91.cjs} +3 -3
- package/dist/{github-BpsnSDw2.cjs.map → github-CUjW3G91.cjs.map} +1 -1
- package/dist/index.cjs +1873 -1825
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +1873 -1825
- package/dist/index.mjs.map +1 -1
- package/dist/{openapi-DeMqoLuJ.cjs → openapi-BAKQLho-.cjs} +4 -4
- package/dist/{openapi-DeMqoLuJ.cjs.map → openapi-BAKQLho-.cjs.map} +1 -1
- package/dist/{openapi-Co2Tv7zh.mjs → openapi-DHQlstv6.mjs} +4 -4
- package/dist/{openapi-Co2Tv7zh.mjs.map → openapi-DHQlstv6.mjs.map} +1 -1
- package/dist/openapi.cjs +1 -1
- package/dist/openapi.mjs +1 -1
- package/dist/reconcile/public.cjs +1 -1
- package/dist/reconcile/public.d.cts +7 -0
- package/dist/reconcile/public.d.cts.map +1 -1
- package/dist/reconcile/public.d.mts +7 -0
- package/dist/reconcile/public.d.mts.map +1 -1
- package/dist/reconcile/public.mjs +1 -1
- package/dist/{transfer-Bk5YlRyB.mjs → transfer-B88fTvnh.mjs} +2 -2
- package/dist/{transfer-Bk5YlRyB.mjs.map → transfer-B88fTvnh.mjs.map} +1 -1
- package/dist/{transfer-BZbyaV3I.cjs → transfer-D59M4aaR.cjs} +2 -2
- package/dist/{transfer-BZbyaV3I.cjs.map → transfer-D59M4aaR.cjs.map} +1 -1
- package/package.json +17 -12
- package/dist/credentials-COrSpZfF.cjs.map +0 -1
- package/dist/credentials-D9TKJJZc.mjs.map +0 -1
package/dist/index.cjs
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env -S npx tsx
|
|
2
2
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
3
|
const require_rolldown_runtime = require("./rolldown-runtime-C0BPl7ul.cjs");
|
|
4
|
-
const require_discover = require("./discover-
|
|
5
|
-
const require_config = require("./config-
|
|
4
|
+
const require_discover = require("./discover-B_HtYJCr.cjs");
|
|
5
|
+
const require_config = require("./config-Dn9DW2Fz.cjs");
|
|
6
6
|
const require_workspace = require("./workspace-CRD9YinV.cjs");
|
|
7
|
-
const require_credentials = require("./credentials-
|
|
7
|
+
const require_credentials = require("./credentials-Dbyccl3A.cjs");
|
|
8
8
|
const require_generator = require("./generator-Wdfp0jCS.cjs");
|
|
9
9
|
const require_storage = require("./storage-DgypfSD6.cjs");
|
|
10
|
-
const require_openapi = require("./openapi-
|
|
10
|
+
const require_openapi = require("./openapi-BAKQLho-.cjs");
|
|
11
11
|
const require_emit = require("./emit-BdoPFjZf.cjs");
|
|
12
12
|
const require_reconcile = require("./reconcile-hx_vBvux.cjs");
|
|
13
|
-
const require_transfer = require("./transfer-
|
|
13
|
+
const require_transfer = require("./transfer-D59M4aaR.cjs");
|
|
14
14
|
let commander = require("commander");
|
|
15
15
|
let node_process = require("node:process");
|
|
16
16
|
let node_readline_promises = require("node:readline/promises");
|
|
@@ -31,12 +31,12 @@ let _geekmidas_db_pg_roles = require("@geekmidas/db/pg/roles");
|
|
|
31
31
|
let pg = require("pg");
|
|
32
32
|
let node_util = require("node:util");
|
|
33
33
|
let _geekmidas_cache_postgres = require("@geekmidas/cache/postgres");
|
|
34
|
+
let node_url = require("node:url");
|
|
34
35
|
let _geekmidas_constructs_crons = require("@geekmidas/constructs/crons");
|
|
35
36
|
let _geekmidas_constructs_functions = require("@geekmidas/constructs/functions");
|
|
36
37
|
let _geekmidas_constructs_queue = require("@geekmidas/constructs/queue");
|
|
37
38
|
let _geekmidas_constructs_subscribers = require("@geekmidas/constructs/subscribers");
|
|
38
39
|
let _geekmidas_constructs_topic = require("@geekmidas/constructs/topic");
|
|
39
|
-
let node_url = require("node:url");
|
|
40
40
|
let node_dns_promises = require("node:dns/promises");
|
|
41
41
|
let node_module = require("node:module");
|
|
42
42
|
let prompts = require("prompts");
|
|
@@ -44,7 +44,7 @@ prompts = require_rolldown_runtime.__toESM(prompts, 1);
|
|
|
44
44
|
let _geekmidas_envkit = require("@geekmidas/envkit");
|
|
45
45
|
let _geekmidas_services = require("@geekmidas/services");
|
|
46
46
|
//#region package.json
|
|
47
|
-
var version = "10.0.0-alpha.
|
|
47
|
+
var version = "10.0.0-alpha.25";
|
|
48
48
|
//#endregion
|
|
49
49
|
//#region src/auth/credentials.ts
|
|
50
50
|
/**
|
|
@@ -329,76 +329,6 @@ function maskToken(token) {
|
|
|
329
329
|
return `${token.slice(0, 4)}...${token.slice(-4)}`;
|
|
330
330
|
}
|
|
331
331
|
//#endregion
|
|
332
|
-
//#region src/build/owners.ts
|
|
333
|
-
/**
|
|
334
|
-
* What a generated entry imports its runtime from, read off the discovered
|
|
335
|
-
* constructs: the surface it serves, every construct a runnable can be built
|
|
336
|
-
* from, and the database Studio browses.
|
|
337
|
-
*
|
|
338
|
-
* `gkm build` and `gkm dev` both write entries that import the surface for its
|
|
339
|
-
* logger and environment parser, so both ask here. A dev server that skipped it
|
|
340
|
-
* had nothing to import them from.
|
|
341
|
-
*/
|
|
342
|
-
function ownersContext(options) {
|
|
343
|
-
const { declared, sources, workspaceRoot, appRoot, studio } = options;
|
|
344
|
-
const surfaces = restApis(declared);
|
|
345
|
-
const primary = servedSurface(declared, workspaceRoot, appRoot) ?? surfaces.find((d) => d.endpoints.length === 0) ?? surfaces[0];
|
|
346
|
-
const browsable = Object.entries(declared).find(([, d]) => d.kind === "database");
|
|
347
|
-
const browsableSource = browsable ? sources[browsable[0]] : void 0;
|
|
348
|
-
const owners = {};
|
|
349
|
-
for (const [id, declaration] of Object.entries(declared)) {
|
|
350
|
-
if (declaration.kind !== "rest-api" && declaration.kind !== "worker") continue;
|
|
351
|
-
const source = sources[id];
|
|
352
|
-
if (!source) continue;
|
|
353
|
-
owners[id] = {
|
|
354
|
-
specifier: source.file,
|
|
355
|
-
exportName: source.exportName
|
|
356
|
-
};
|
|
357
|
-
}
|
|
358
|
-
const primarySource = primary ? sources[primary.id] : void 0;
|
|
359
|
-
return {
|
|
360
|
-
owners,
|
|
361
|
-
...primary ? { surface: {
|
|
362
|
-
id: primary.id,
|
|
363
|
-
trustedOriginsKey: (0, _geekmidas_manifest.provideKey)(primary.id, "trustedOrigins"),
|
|
364
|
-
...primary.cors ? { cors: primary.cors } : {},
|
|
365
|
-
...primarySource ? { module: {
|
|
366
|
-
specifier: primarySource.file,
|
|
367
|
-
exportName: primarySource.exportName
|
|
368
|
-
} } : {}
|
|
369
|
-
} } : {},
|
|
370
|
-
studio: studio && browsableSource ? {
|
|
371
|
-
...studio,
|
|
372
|
-
database: {
|
|
373
|
-
specifier: browsableSource.file,
|
|
374
|
-
exportName: browsableSource.exportName
|
|
375
|
-
}
|
|
376
|
-
} : studio
|
|
377
|
-
};
|
|
378
|
-
}
|
|
379
|
-
/**
|
|
380
|
-
* The endpoints one surface serves.
|
|
381
|
-
*
|
|
382
|
-
* One glob finds every surface's endpoints, wherever their files live. A build
|
|
383
|
-
* keeps the ones built from the surface it serves; one built from no surface at
|
|
384
|
-
* all has nowhere else to go.
|
|
385
|
-
*/
|
|
386
|
-
function servedBy(endpoints, surface) {
|
|
387
|
-
if (!surface) return endpoints;
|
|
388
|
-
return endpoints.filter(({ construct }) => !construct.surface || construct.surface.id === surface.id);
|
|
389
|
-
}
|
|
390
|
-
function restApis(declared) {
|
|
391
|
-
return Object.values(declared).filter((d) => d.kind === "rest-api");
|
|
392
|
-
}
|
|
393
|
-
/**
|
|
394
|
-
* The surface whose declared `path` is the directory being built — asked of
|
|
395
|
-
* every surface, because a workspace has several and each app's build serves
|
|
396
|
-
* exactly one.
|
|
397
|
-
*/
|
|
398
|
-
function servedSurface(declared, workspaceRoot, appRoot) {
|
|
399
|
-
return restApis(declared).find((d) => (0, node_path.resolve)(workspaceRoot, d.path) === (0, node_path.resolve)(appRoot));
|
|
400
|
-
}
|
|
401
|
-
//#endregion
|
|
402
332
|
//#region src/build/providerResolver.ts
|
|
403
333
|
/**
|
|
404
334
|
* Resolves provider configuration from the new simplified system
|
|
@@ -469,1399 +399,970 @@ function isEnabled(config) {
|
|
|
469
399
|
return config.enabled !== false;
|
|
470
400
|
}
|
|
471
401
|
//#endregion
|
|
472
|
-
//#region src/
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
402
|
+
//#region src/workspace/client-generator.ts
|
|
403
|
+
/**
|
|
404
|
+
* Normalize routes to an array of patterns.
|
|
405
|
+
* Handles string, string[], and PartitionedRoutes (extracts paths).
|
|
406
|
+
* @internal Exported for use in dev command
|
|
407
|
+
*/
|
|
408
|
+
function normalizeRoutes(routes) {
|
|
409
|
+
if (!routes) return [];
|
|
410
|
+
if (require_discover.isPartitionedRoutes(routes)) return Array.isArray(routes.paths) ? routes.paths : [routes.paths];
|
|
411
|
+
return Array.isArray(routes) ? routes : [routes];
|
|
412
|
+
}
|
|
413
|
+
//#endregion
|
|
414
|
+
//#region src/exec/index.ts
|
|
415
|
+
const logger$12 = console;
|
|
416
|
+
/** `gkm exec` was given nothing to run. */
|
|
417
|
+
var NoCommandSpecified = class extends Error {
|
|
418
|
+
constructor() {
|
|
419
|
+
super("No command specified. Usage: gkm exec -- <command>");
|
|
420
|
+
this.name = "NoCommandSpecified";
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
/**
|
|
424
|
+
* Run a command with secrets injected into Credentials.
|
|
425
|
+
* Uses Node's --import flag to preload a script that populates Credentials
|
|
426
|
+
* before the command loads any modules that depend on them.
|
|
427
|
+
*
|
|
428
|
+
* @example
|
|
429
|
+
* ```bash
|
|
430
|
+
* gkm exec -- npx @better-auth/cli migrate
|
|
431
|
+
* gkm exec -- npx prisma migrate dev
|
|
432
|
+
* ```
|
|
433
|
+
*/
|
|
434
|
+
async function execCommand(commandArgs, options = {}) {
|
|
435
|
+
const cwd = options.cwd ?? process.cwd();
|
|
436
|
+
if (!commandArgs[0]) throw new NoCommandSpecified();
|
|
437
|
+
const defaultEnv = require_credentials.loadEnvFiles(".env");
|
|
438
|
+
if (defaultEnv.loaded.length > 0) logger$12.log(`📦 Loaded env: ${defaultEnv.loaded.join(", ")}`);
|
|
439
|
+
const { credentials, secretsJsonPath, appName } = await require_credentials.prepareEntryCredentials({
|
|
440
|
+
cwd,
|
|
441
|
+
resolveDockerPorts: "readonly"
|
|
442
|
+
});
|
|
443
|
+
if (appName) logger$12.log(`📦 App: ${appName}`);
|
|
444
|
+
const secretCount = Object.keys(credentials).filter((k) => k !== "PORT").length;
|
|
445
|
+
if (secretCount > 0) logger$12.log(`🔐 Loaded ${secretCount} secret(s)`);
|
|
446
|
+
const preloadDir = (0, node_path.join)(cwd, ".gkm");
|
|
447
|
+
await (0, node_fs_promises.mkdir)(preloadDir, { recursive: true });
|
|
448
|
+
const preloadPath = (0, node_path.join)(preloadDir, "credentials-preload.mjs");
|
|
449
|
+
await require_credentials.createCredentialsPreload(preloadPath, secretsJsonPath);
|
|
450
|
+
const [cmd, ...args] = commandArgs;
|
|
451
|
+
logger$12.log(`🚀 Running: ${[cmd, ...args].join(" ")}`);
|
|
452
|
+
const nodeOptions = [(process.env.NODE_OPTIONS ?? "").replace(/--import[= ]\S+/g, "").trim(), `--import=${preloadPath}`].filter(Boolean).join(" ");
|
|
453
|
+
const child = (0, node_child_process.spawn)(cmd, args, {
|
|
454
|
+
cwd,
|
|
455
|
+
stdio: "inherit",
|
|
456
|
+
env: {
|
|
457
|
+
...process.env,
|
|
458
|
+
...credentials,
|
|
459
|
+
NODE_OPTIONS: nodeOptions
|
|
498
460
|
}
|
|
499
|
-
|
|
461
|
+
});
|
|
462
|
+
const exitCode = await new Promise((resolve) => {
|
|
463
|
+
child.on("close", (code) => resolve(code ?? 0));
|
|
464
|
+
child.on("error", (error) => {
|
|
465
|
+
logger$12.error(`Failed to run command: ${error.message}`);
|
|
466
|
+
resolve(1);
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
if (exitCode !== 0) process.exit(exitCode);
|
|
470
|
+
}
|
|
471
|
+
//#endregion
|
|
472
|
+
//#region src/dev/index.ts
|
|
473
|
+
const logger$11 = console;
|
|
474
|
+
/**
|
|
475
|
+
* Normalize telescope configuration
|
|
476
|
+
* @internal Exported for testing
|
|
477
|
+
*/
|
|
478
|
+
function normalizeTelescopeConfig(config) {
|
|
479
|
+
if (config === false) return;
|
|
480
|
+
if (typeof config === "string") {
|
|
481
|
+
const { path: telescopePath, importPattern: telescopeImportPattern } = require_config.parseModuleConfig(config, "telescope");
|
|
482
|
+
return {
|
|
483
|
+
enabled: true,
|
|
484
|
+
telescopePath,
|
|
485
|
+
telescopeImportPattern,
|
|
486
|
+
path: "/__telescope",
|
|
487
|
+
ignore: [],
|
|
488
|
+
recordBody: true,
|
|
489
|
+
maxEntries: 1e3,
|
|
490
|
+
websocket: true
|
|
491
|
+
};
|
|
500
492
|
}
|
|
501
|
-
|
|
502
|
-
|
|
493
|
+
if (!(config === true || config === void 0 || config.enabled !== false)) return;
|
|
494
|
+
const telescopeConfig = typeof config === "object" ? config : {};
|
|
495
|
+
return {
|
|
496
|
+
enabled: true,
|
|
497
|
+
path: telescopeConfig.path ?? "/__telescope",
|
|
498
|
+
ignore: telescopeConfig.ignore ?? [],
|
|
499
|
+
recordBody: telescopeConfig.recordBody ?? true,
|
|
500
|
+
maxEntries: telescopeConfig.maxEntries ?? 1e3,
|
|
501
|
+
websocket: telescopeConfig.websocket ?? true
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Normalize studio configuration
|
|
506
|
+
* @internal Exported for testing
|
|
507
|
+
*/
|
|
508
|
+
function normalizeStudioConfig(config) {
|
|
509
|
+
if (config === false) return;
|
|
510
|
+
if (typeof config === "string") {
|
|
511
|
+
const { path: studioPath, importPattern: studioImportPattern } = require_config.parseModuleConfig(config, "studio");
|
|
512
|
+
return {
|
|
513
|
+
enabled: true,
|
|
514
|
+
studioPath,
|
|
515
|
+
studioImportPattern,
|
|
516
|
+
path: "/__studio",
|
|
517
|
+
schema: "public"
|
|
518
|
+
};
|
|
503
519
|
}
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
* uses pg-boss, whose schedules live in the database — so a deployment
|
|
512
|
-
* running four replicas fires each job once, which is the thing a timer in
|
|
513
|
-
* every process gets wrong and does not report. With no database it falls
|
|
514
|
-
* back to timers and says plainly that it is doing so.
|
|
515
|
-
*/
|
|
516
|
-
async generateServerCronsFile(outputDir, crons) {
|
|
517
|
-
await (0, node_fs_promises.mkdir)(outputDir, { recursive: true });
|
|
518
|
-
const cronsPath = (0, node_path.join)(outputDir, "crons.ts");
|
|
519
|
-
const importsByFile = /* @__PURE__ */ new Map();
|
|
520
|
-
for (const { path, key } of crons) {
|
|
521
|
-
const importPath = (0, node_path.relative)((0, node_path.dirname)(cronsPath), path.relative).replace(/\.ts$/, ".js");
|
|
522
|
-
if (!importsByFile.has(importPath)) importsByFile.set(importPath, []);
|
|
523
|
-
importsByFile.get(importPath)?.push(key);
|
|
524
|
-
}
|
|
525
|
-
const content = `/**
|
|
526
|
-
* Generated cron setup.
|
|
527
|
-
*
|
|
528
|
-
* Schedules every cron this app declares, in the process that serves it.
|
|
529
|
-
*
|
|
530
|
-
* The schedule lives in Postgres, through pg-boss, in the database the worker
|
|
531
|
-
* named with \`.database(db)\`. That is what makes running several replicas
|
|
532
|
-
* safe: each job fires once, where a timer in every process fires it once per
|
|
533
|
-
* replica and reports nothing.
|
|
534
|
-
*
|
|
535
|
-
* No connection string appears here. The construct that owns the database is
|
|
536
|
-
* the only thing that knows its key, and it is resolved through service
|
|
537
|
-
* discovery like any other dependency.
|
|
538
|
-
*/
|
|
539
|
-
import type { EnvironmentParser } from '@geekmidas/envkit';
|
|
540
|
-
import type { Logger } from '@geekmidas/logger';
|
|
541
|
-
import { toCronExpression } from '@geekmidas/constructs/crons';
|
|
542
|
-
import { ServiceDiscovery } from '@geekmidas/services';
|
|
543
|
-
${Array.from(importsByFile.entries()).map(([importPath, exports]) => `import { ${exports.join(", ")} } from '${importPath}';`).join("\n")}
|
|
544
|
-
|
|
545
|
-
const crons = [
|
|
546
|
-
${crons.map(({ key }) => ` { name: '${key}', cron: ${key} }`).join(",\n")}
|
|
547
|
-
];
|
|
548
|
-
|
|
549
|
-
type Stoppable = { stop: () => Promise<void> | void };
|
|
550
|
-
const running: Stoppable[] = [];
|
|
551
|
-
|
|
552
|
-
export async function setupCrons(
|
|
553
|
-
envParser: EnvironmentParser<any>,
|
|
554
|
-
logger: Logger,
|
|
555
|
-
): Promise<void> {
|
|
556
|
-
if (crons.length === 0) {
|
|
557
|
-
return;
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
const serviceDiscovery = ServiceDiscovery.getInstance(envParser);
|
|
561
|
-
|
|
562
|
-
// Resolved once per cron rather than per firing: registering services on
|
|
563
|
-
// every tick would reconnect a database every minute.
|
|
564
|
-
const prepared = [];
|
|
565
|
-
for (const { name, cron } of crons) {
|
|
566
|
-
try {
|
|
567
|
-
const schedule = toCronExpression(cron.schedule);
|
|
568
|
-
const services = cron.services.length > 0
|
|
569
|
-
? await serviceDiscovery.register(cron.services)
|
|
570
|
-
: {};
|
|
571
|
-
|
|
572
|
-
prepared.push({ name, cron, schedule, services });
|
|
573
|
-
} catch (error) {
|
|
574
|
-
// An unrepresentable schedule is a build-time mistake, and failing the
|
|
575
|
-
// process for it would take the HTTP server down with it.
|
|
576
|
-
logger.error({ error, cron: name }, 'Cron has no runnable schedule, skipping');
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
const run = async (entry: (typeof prepared)[number]) => {
|
|
581
|
-
try {
|
|
582
|
-
await entry.cron.handler({
|
|
583
|
-
input: undefined,
|
|
584
|
-
services: entry.services,
|
|
585
|
-
logger: entry.cron.logger,
|
|
586
|
-
});
|
|
587
|
-
} catch (error) {
|
|
588
|
-
logger.error({ error, cron: entry.name }, 'Cron failed');
|
|
589
|
-
}
|
|
590
|
-
};
|
|
591
|
-
|
|
592
|
-
// Declared once on the worker, carried onto every cron it built — so this
|
|
593
|
-
// reads it from any of them rather than from config or the environment.
|
|
594
|
-
const scheduleStore = prepared.find(({ cron }) => cron.scheduleStore)?.cron
|
|
595
|
-
.scheduleStore;
|
|
596
|
-
|
|
597
|
-
if (!scheduleStore) {
|
|
598
|
-
// Declared, not discovered. A worker says where its schedules live with
|
|
599
|
-
// \`.database(db)\`; inferring one from whatever database happened to be
|
|
600
|
-
// around would move the schedules the day an app declared a second.
|
|
601
|
-
logger.error(
|
|
602
|
-
{ crons: prepared.map(({ name }) => name) },
|
|
603
|
-
'These crons have nowhere to keep their schedule. A server fires its own ' +
|
|
604
|
-
'crons, and a process scheduling in memory fires each job once per ' +
|
|
605
|
-
'replica — so the schedule lives in Postgres. Say where with ' +
|
|
606
|
-
'\`new Worker(…).database(db)\`.',
|
|
607
|
-
);
|
|
608
|
-
return;
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
const [store] = await serviceDiscovery
|
|
612
|
-
.register([scheduleStore])
|
|
613
|
-
.then((s) => Object.values(s));
|
|
614
|
-
|
|
615
|
-
const { PgBoss } = await import('pg-boss');
|
|
616
|
-
// The pool the construct already opened — no connection string is read here,
|
|
617
|
-
// and none is named anywhere outside the construct that owns it.
|
|
618
|
-
const boss = new PgBoss({ db: store as never });
|
|
619
|
-
await boss.start();
|
|
620
|
-
|
|
621
|
-
// What this app declares now. Anything else under the prefix belonged to a
|
|
622
|
-
// cron since renamed or deleted, and would keep firing at a handler that has
|
|
623
|
-
// gone.
|
|
624
|
-
//
|
|
625
|
-
// The prefix is \`cron.\` rather than \`cron:\`: pg-boss accepts
|
|
626
|
-
// alphanumerics, underscores, hyphens, periods and slashes in a queue name,
|
|
627
|
-
// and rejects a colon outright.
|
|
628
|
-
const declared = new Set(prepared.map(({ name }) => \`cron.\${name}\`));
|
|
629
|
-
for (const existing of await boss.getSchedules()) {
|
|
630
|
-
if (existing.name.startsWith('cron.') && !declared.has(existing.name)) {
|
|
631
|
-
await boss.unschedule(existing.name);
|
|
632
|
-
logger.info({ cron: existing.name }, 'Removed a schedule nothing declares');
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
for (const entry of prepared) {
|
|
637
|
-
const queue = \`cron.\${entry.name}\`;
|
|
638
|
-
await boss.createQueue(queue);
|
|
639
|
-
// UTC, so this agrees with the schedule a deploy target would have made.
|
|
640
|
-
await boss.schedule(queue, entry.schedule, undefined, { tz: 'UTC' });
|
|
641
|
-
await boss.work(queue, () => run(entry));
|
|
642
|
-
|
|
643
|
-
logger.info({ cron: entry.name, schedule: entry.schedule }, 'Cron scheduled');
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
running.push({ stop: () => boss.stop() });
|
|
647
|
-
logger.info({ count: prepared.length }, 'Crons scheduled');
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
export async function stopCrons(): Promise<void> {
|
|
651
|
-
await Promise.all(running.map((r) => r.stop()));
|
|
652
|
-
running.length = 0;
|
|
520
|
+
if (!(config === true || config === void 0 || config.enabled !== false)) return;
|
|
521
|
+
const studioConfig = typeof config === "object" ? config : {};
|
|
522
|
+
return {
|
|
523
|
+
enabled: true,
|
|
524
|
+
path: studioConfig.path ?? "/__studio",
|
|
525
|
+
schema: studioConfig.schema ?? "public"
|
|
526
|
+
};
|
|
653
527
|
}
|
|
654
|
-
`;
|
|
655
|
-
await (0, node_fs_promises.writeFile)(cronsPath, content);
|
|
656
|
-
return cronsPath;
|
|
657
|
-
}
|
|
658
|
-
async generateCronHandler(outputDir, sourceFile, exportName, context, owner) {
|
|
659
|
-
const handlerFileName = `${exportName}.ts`;
|
|
660
|
-
const handlerPath = (0, node_path.join)(outputDir, handlerFileName);
|
|
661
|
-
const importPath = (0, node_path.relative)((0, node_path.dirname)(handlerPath), sourceFile).replace(/\.ts$/, ".js");
|
|
662
|
-
const runtime = require_openapi.runtimeFor(context, (0, node_path.dirname)(handlerPath), owner);
|
|
663
|
-
const content = `import { AWSScheduledFunction } from '@geekmidas/constructs/aws';
|
|
664
|
-
import { ${exportName} } from '${importPath}';
|
|
665
|
-
${runtime.imports}
|
|
666
|
-
${runtime.bindings}
|
|
667
|
-
|
|
668
|
-
const adapter = new AWSScheduledFunction(envParser, ${exportName});
|
|
669
|
-
|
|
670
|
-
export const handler = adapter.handler;
|
|
671
|
-
`;
|
|
672
|
-
await (0, node_fs_promises.writeFile)(handlerPath, content);
|
|
673
|
-
return handlerPath;
|
|
674
|
-
}
|
|
675
|
-
};
|
|
676
|
-
//#endregion
|
|
677
|
-
//#region src/generators/drivers.ts
|
|
678
|
-
/**
|
|
679
|
-
* Driver registration for generated entry points.
|
|
680
|
-
*
|
|
681
|
-
* A construct declares that it needs object storage and is handed one URL; the
|
|
682
|
-
* scheme in that URL picks the driver that builds the client. Which drivers
|
|
683
|
-
* exist is therefore the *entry point's* decision, not the construct's and not
|
|
684
|
-
* the application's — an app that never talks to S3 should never resolve the AWS
|
|
685
|
-
* SDK, and application code that registers a driver has named a provider in the
|
|
686
|
-
* one layer this design keeps provider-free.
|
|
687
|
-
*
|
|
688
|
-
* So the generated entries register: `gkm dev`'s server for local development,
|
|
689
|
-
* and each Lambda handler for its own target. Registration is an explicit call
|
|
690
|
-
* rather than an import side effect, because a side-effecting module is exactly
|
|
691
|
-
* what a bundler is entitled to drop.
|
|
692
|
-
*/
|
|
693
|
-
/** The `s3://` driver, which serves MinIO locally and S3 deployed. */
|
|
694
|
-
const S3 = {
|
|
695
|
-
imports: `import { registerStorageDriver } from '@geekmidas/storage';\nimport { s3Driver } from '@geekmidas/storage/aws';`,
|
|
696
|
-
setup: "registerStorageDriver(s3Driver);"
|
|
697
|
-
};
|
|
698
|
-
const NONE = {
|
|
699
|
-
imports: "",
|
|
700
|
-
setup: ""
|
|
701
|
-
};
|
|
702
528
|
/**
|
|
703
|
-
*
|
|
704
|
-
*
|
|
705
|
-
* Keyed off the *backend* rather than off a dependency, because unlike storage
|
|
706
|
-
* the backend already says which driver is right: a project caching in Postgres
|
|
707
|
-
* resolves `pg` and never `ioredis`, and one caching in Upstash resolves
|
|
708
|
-
* neither. That is the whole reason the drivers live behind separate subpaths.
|
|
709
|
-
*
|
|
710
|
-
* Both HTTP schemes are registered for Upstash, because the local proxy speaks
|
|
711
|
-
* `http://` and Upstash speaks `https://`, and an entry that registered one
|
|
712
|
-
* would work in exactly one of the two places.
|
|
529
|
+
* Normalize hooks configuration
|
|
530
|
+
* @internal Exported for testing
|
|
713
531
|
*/
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
elasticache: {
|
|
720
|
-
imports: `import { registerCacheDriver } from '@geekmidas/cache';\nimport { redisCacheDriver, redissCacheDriver } from '@geekmidas/cache/redis';`,
|
|
721
|
-
setup: "registerCacheDriver(redisCacheDriver);\nregisterCacheDriver(redissCacheDriver);"
|
|
722
|
-
},
|
|
723
|
-
db: {
|
|
724
|
-
imports: `import { registerCacheDriver } from '@geekmidas/cache';\nimport { postgresCacheDriver } from '@geekmidas/cache/postgres';`,
|
|
725
|
-
setup: "registerCacheDriver(postgresCacheDriver);"
|
|
726
|
-
}
|
|
727
|
-
};
|
|
532
|
+
function normalizeHooksConfig(config, cwd = process.cwd()) {
|
|
533
|
+
if (!config?.server) return;
|
|
534
|
+
const serverPath = config.server.endsWith(".ts") ? config.server : `${config.server}.ts`;
|
|
535
|
+
return { serverHooksPath: (0, node_path.resolve)(cwd, serverPath) };
|
|
536
|
+
}
|
|
728
537
|
/**
|
|
729
|
-
*
|
|
730
|
-
*
|
|
731
|
-
* Merged here rather than threaded separately, because a generated file has one
|
|
732
|
-
* import block and one setup block whatever fills them.
|
|
538
|
+
* Normalize production configuration
|
|
539
|
+
* @internal Exported for testing
|
|
733
540
|
*/
|
|
734
|
-
function
|
|
735
|
-
|
|
736
|
-
const
|
|
737
|
-
if (parts.length === 0) return NONE;
|
|
541
|
+
function normalizeProductionConfig(cliProduction, configProduction) {
|
|
542
|
+
if (!cliProduction) return;
|
|
543
|
+
const config = configProduction ?? {};
|
|
738
544
|
return {
|
|
739
|
-
|
|
740
|
-
|
|
545
|
+
enabled: true,
|
|
546
|
+
bundle: config.bundle ?? true,
|
|
547
|
+
minify: config.minify ?? true,
|
|
548
|
+
healthCheck: config.healthCheck ?? "/health",
|
|
549
|
+
gracefulShutdown: config.gracefulShutdown ?? true,
|
|
550
|
+
external: config.external ?? [],
|
|
551
|
+
subscribers: config.subscribers ?? "exclude",
|
|
552
|
+
openapi: config.openapi ?? false,
|
|
553
|
+
optimizedHandlers: config.optimizedHandlers ?? true
|
|
741
554
|
};
|
|
742
555
|
}
|
|
743
556
|
/**
|
|
744
|
-
*
|
|
745
|
-
*
|
|
746
|
-
* The config answers this for a cache that named nowhere, and the *declaration*
|
|
747
|
-
* answers it for one that named a database — `orders.cache('Sessions')` is a
|
|
748
|
-
* statement about the application, so a deployment cannot move that cache and
|
|
749
|
-
* the entry cannot register a driver for somewhere else.
|
|
750
|
-
*
|
|
751
|
-
* Reading only the config is how an entry ends up registering the Upstash
|
|
752
|
-
* driver for a URL the target composed as `postgres://`, which fails at the
|
|
753
|
-
* first request with `UnregisteredCacheScheme` and a stack that points at the
|
|
754
|
-
* cache rather than at the config that disagreed.
|
|
755
|
-
*/
|
|
756
|
-
function cacheBackendsIn(manifest, configured) {
|
|
757
|
-
const caches = Object.values(manifest).filter((d) => d.kind === "cache");
|
|
758
|
-
return [...new Set(caches.map((c) => c.of ? "db" : configured))];
|
|
759
|
-
}
|
|
760
|
-
/**
|
|
761
|
-
* The drivers an app's entry points should register.
|
|
762
|
-
*
|
|
763
|
-
* Keyed off the dependency rather than off a declared bucket, because the entry
|
|
764
|
-
* is generated before discovery has run on the watcher's rebuild path — and an
|
|
765
|
-
* app that installed `@geekmidas/storage` has already paid for the SDK it would
|
|
766
|
-
* otherwise resolve lazily.
|
|
557
|
+
* Get production config from GkmConfig
|
|
558
|
+
* @internal
|
|
767
559
|
*/
|
|
768
|
-
function
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
function dependsOnStorage(appRoot) {
|
|
772
|
-
const manifest = (0, node_path.join)(appRoot, "package.json");
|
|
773
|
-
if (!(0, node_fs.existsSync)(manifest)) return false;
|
|
774
|
-
try {
|
|
775
|
-
const pkg = JSON.parse((0, node_fs.readFileSync)(manifest, "utf8"));
|
|
776
|
-
return Boolean(pkg.dependencies?.["@geekmidas/storage"] ?? pkg.devDependencies?.["@geekmidas/storage"]);
|
|
777
|
-
} catch {
|
|
778
|
-
return false;
|
|
779
|
-
}
|
|
560
|
+
function getProductionConfigFromGkm(config) {
|
|
561
|
+
const serverConfig = config.providers?.server;
|
|
562
|
+
if (typeof serverConfig === "object") return serverConfig.production;
|
|
780
563
|
}
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
await
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
564
|
+
async function devCommand(options) {
|
|
565
|
+
if (options.entry) return entryDevCommand(options);
|
|
566
|
+
const defaultEnv = require_credentials.loadEnvFiles(".env");
|
|
567
|
+
if (defaultEnv.loaded.length > 0) logger$11.log(`📦 Loaded env: ${defaultEnv.loaded.join(", ")}`);
|
|
568
|
+
const appName = require_config.getAppNameFromCwd();
|
|
569
|
+
let config;
|
|
570
|
+
let appRoot = process.cwd();
|
|
571
|
+
let secretsRoot = process.cwd();
|
|
572
|
+
let workspaceAppName;
|
|
573
|
+
let workspaceAppPort;
|
|
574
|
+
let workspace;
|
|
575
|
+
if (appName) try {
|
|
576
|
+
const appConfig = await require_config.loadAppConfig();
|
|
577
|
+
config = appConfig.gkmConfig;
|
|
578
|
+
appRoot = appConfig.appRoot;
|
|
579
|
+
secretsRoot = appConfig.workspaceRoot;
|
|
580
|
+
workspaceAppName = appConfig.appName;
|
|
581
|
+
workspaceAppPort = appConfig.app.port;
|
|
582
|
+
workspace = appConfig.workspace;
|
|
583
|
+
logger$11.log(`📦 Running app: ${appConfig.appName} on port ${workspaceAppPort}`);
|
|
584
|
+
if (appConfig.app.entry) {
|
|
585
|
+
logger$11.log(`📄 Using entry point: ${appConfig.app.entry}`);
|
|
586
|
+
return entryDevCommand({
|
|
587
|
+
...options,
|
|
588
|
+
entry: appConfig.app.entry,
|
|
589
|
+
port: workspaceAppPort,
|
|
590
|
+
portExplicit: true
|
|
803
591
|
});
|
|
804
|
-
logger.log(`Generated function handler: ${key}`);
|
|
805
592
|
}
|
|
806
|
-
|
|
593
|
+
} catch {
|
|
594
|
+
const loadedConfig = await require_config.loadWorkspaceConfig();
|
|
595
|
+
if (loadedConfig.type === "workspace") {
|
|
596
|
+
logger$11.log("📦 Detected workspace configuration");
|
|
597
|
+
return workspaceDevCommand(loadedConfig.workspace, options);
|
|
598
|
+
}
|
|
599
|
+
config = loadedConfig.raw;
|
|
807
600
|
}
|
|
808
|
-
|
|
809
|
-
const
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
${runtime.bindings}
|
|
817
|
-
|
|
818
|
-
const adapter = new AWSLambdaFunction(envParser, ${exportName});
|
|
819
|
-
|
|
820
|
-
export const handler = adapter.handler;
|
|
821
|
-
`;
|
|
822
|
-
await (0, node_fs_promises.writeFile)(handlerPath, content);
|
|
823
|
-
return handlerPath;
|
|
601
|
+
else {
|
|
602
|
+
const loadedConfig = await require_config.loadWorkspaceConfig();
|
|
603
|
+
if (loadedConfig.type === "workspace") {
|
|
604
|
+
logger$11.log("📦 Detected workspace configuration");
|
|
605
|
+
return workspaceDevCommand(loadedConfig.workspace, options);
|
|
606
|
+
}
|
|
607
|
+
config = loadedConfig.raw;
|
|
608
|
+
workspace = loadedConfig.workspace;
|
|
824
609
|
}
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
* Generates the runtime for `q` queue workers.
|
|
830
|
-
*
|
|
831
|
-
* - **server** (`gkm dev`): a single `queues.ts` exposing `setupQueues()` that
|
|
832
|
-
* runs an in-process pg-boss poller alongside the Hono server — no SQS/Lambda.
|
|
833
|
-
* Each queue subscribes by its **name** on the shared event connection.
|
|
834
|
-
* - **aws-lambda**: one handler file per queue wrapping `AWSLambdaQueue`, backed
|
|
835
|
-
* by an SQS event-source mapping.
|
|
836
|
-
*/
|
|
837
|
-
var QueueGenerator = class extends require_discover.ConstructGenerator {
|
|
838
|
-
isConstruct(value) {
|
|
839
|
-
return _geekmidas_constructs_queue.Queue.isQueue(value);
|
|
610
|
+
if (config.env) {
|
|
611
|
+
const { loaded, missing } = require_credentials.loadEnvFiles(config.env, appRoot);
|
|
612
|
+
if (loaded.length > 0) logger$11.log(`📦 Loaded env: ${loaded.join(", ")}`);
|
|
613
|
+
if (missing.length > 0) logger$11.warn(`⚠️ Missing env files: ${missing.join(", ")}`);
|
|
840
614
|
}
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
615
|
+
const resolved = resolveProviders(config, { provider: "server" });
|
|
616
|
+
logger$11.log("🚀 Starting development server...");
|
|
617
|
+
logger$11.log(`Loading constructs from: ${config.constructs}`);
|
|
618
|
+
const telescope = normalizeTelescopeConfig(config.telescope);
|
|
619
|
+
if (telescope) logger$11.log(`🔭 Telescope enabled at ${telescope.path}`);
|
|
620
|
+
const studio = normalizeStudioConfig(config.studio);
|
|
621
|
+
if (studio) logger$11.log(`🗄️ Studio enabled at ${studio.path}`);
|
|
622
|
+
const hooks = normalizeHooksConfig(config.hooks, appRoot);
|
|
623
|
+
if (hooks) logger$11.log(`🪝 Server hooks enabled from ${config.hooks?.server}`);
|
|
624
|
+
const enableOpenApi = require_openapi.resolveOpenApiConfig(config).enabled || resolved.enableOpenApi;
|
|
625
|
+
if (enableOpenApi) logger$11.log(`📄 OpenAPI output: ${require_openapi.OPENAPI_OUTPUT_PATH}`);
|
|
626
|
+
const build = (bustCache = false) => buildApp({
|
|
627
|
+
config,
|
|
628
|
+
workspaceRoot: workspace?.root ?? secretsRoot,
|
|
629
|
+
appRoot,
|
|
630
|
+
providers: ["server"],
|
|
631
|
+
enableOpenApi,
|
|
632
|
+
cacheBackend: require_credentials.cacheBackendFor(require_credentials.providerOf(workspace ?? config)),
|
|
633
|
+
telescope,
|
|
634
|
+
studio,
|
|
635
|
+
hooks,
|
|
636
|
+
skipBundle: true,
|
|
637
|
+
bustCache,
|
|
638
|
+
serveEmpty: true
|
|
639
|
+
});
|
|
640
|
+
const initial = await build();
|
|
641
|
+
const runtime = config.runtime ?? "node";
|
|
642
|
+
let secretsJsonPath;
|
|
643
|
+
const appSecrets = await require_credentials.loadSecretsForApp(secretsRoot, config.stages.local, workspaceAppName);
|
|
644
|
+
if (workspace && require_credentials.usesConstructs(workspace)) {
|
|
645
|
+
const reconciled = await require_credentials.reconcileWorkspace(workspace, { stage: workspace.stages.local });
|
|
646
|
+
if (reconciled.changed && reconciled.plan.containers.length > 0) {
|
|
647
|
+
logger$11.log(`🐳 Services: ${reconciled.plan.containers.join(", ")}`);
|
|
648
|
+
for (const [container, address] of Object.entries(reconciled.addresses)) logger$11.log(` ${container}: ${address}`);
|
|
849
649
|
}
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
await
|
|
853
|
-
|
|
854
|
-
const
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
handler: (0, node_path.relative)(process.cwd(), handlerFile).replace(/\.ts$/, ".handler"),
|
|
858
|
-
batchSize: construct.batchSize,
|
|
859
|
-
fifo: construct.fifo,
|
|
860
|
-
timeout: construct.timeout,
|
|
861
|
-
environment: await construct.getEnvironment({ markOptional: context.markOptional }),
|
|
862
|
-
dependencies: construct.constructs
|
|
863
|
-
});
|
|
864
|
-
logger.log(`Generated queue handler: ${key}`);
|
|
650
|
+
Object.assign(appSecrets, reconciled.env);
|
|
651
|
+
} else {
|
|
652
|
+
const resolvedPorts = await require_credentials.resolveServicePorts(secretsRoot);
|
|
653
|
+
if (Object.keys(resolvedPorts.ports).length > 0) {
|
|
654
|
+
const rewritten = require_credentials.rewriteUrlsWithPorts(appSecrets, resolvedPorts);
|
|
655
|
+
Object.assign(appSecrets, rewritten);
|
|
656
|
+
logger$11.log(`🔌 Applied ${Object.keys(resolvedPorts.ports).length} port mapping(s)`);
|
|
865
657
|
}
|
|
866
|
-
return queueInfos;
|
|
867
658
|
}
|
|
868
|
-
|
|
869
|
-
const
|
|
870
|
-
|
|
871
|
-
const runtime = require_openapi.runtimeFor(context, (0, node_path.dirname)(handlerPath), owner);
|
|
872
|
-
const content = `import { AWSLambdaQueue } from '@geekmidas/constructs/aws';
|
|
873
|
-
import { ${exportName} } from '${importPath}';
|
|
874
|
-
${runtime.imports}
|
|
875
|
-
${runtime.bindings}
|
|
876
|
-
|
|
877
|
-
const adapter = new AWSLambdaQueue(envParser, ${exportName});
|
|
878
|
-
|
|
879
|
-
export const handler = adapter.handler;
|
|
880
|
-
`;
|
|
881
|
-
await (0, node_fs_promises.writeFile)(handlerPath, content);
|
|
882
|
-
return handlerPath;
|
|
659
|
+
if (workspace && workspaceAppName) {
|
|
660
|
+
const depEnv = require_workspace.getDependencyEnvVars(workspace, workspaceAppName);
|
|
661
|
+
Object.assign(appSecrets, depEnv);
|
|
883
662
|
}
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
const
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
: {};
|
|
950
|
-
|
|
951
|
-
// A queue subscribes to a single "type" — its own name.
|
|
952
|
-
await eventSubscriber.subscribe([queue.name], async (message) => {
|
|
953
|
-
try {
|
|
954
|
-
const validation = await queue.messageSchema['~standard'].validate(message.payload);
|
|
955
|
-
if (validation.issues) {
|
|
956
|
-
logger.error({ issues: validation.issues, queue: queue.name }, 'Queue message failed validation');
|
|
957
|
-
return;
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
await queue.handler({
|
|
961
|
-
messages: [validation.value],
|
|
962
|
-
services: services,
|
|
963
|
-
logger: queue.logger,
|
|
964
|
-
});
|
|
965
|
-
|
|
966
|
-
logger.debug({ queue: queue.name }, 'Successfully processed queue message');
|
|
967
|
-
} catch (error) {
|
|
968
|
-
logger.error({ error, queue: queue.name }, 'Failed to process queue message');
|
|
969
|
-
// Message will become visible again for retry.
|
|
970
|
-
}
|
|
971
|
-
});
|
|
972
|
-
|
|
973
|
-
activeSubscribers.push(eventSubscriber);
|
|
974
|
-
logger.info({ queue: queue.name }, 'Queue worker started polling');
|
|
975
|
-
} catch (error) {
|
|
976
|
-
logger.error({ error, queue: queue.name }, 'Failed to setup queue worker');
|
|
977
|
-
}
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
const shutdown = () => {
|
|
981
|
-
logger.info('Stopping all queue workers');
|
|
982
|
-
for (const _ of activeSubscribers) {
|
|
983
|
-
connection.stop();
|
|
984
|
-
}
|
|
985
|
-
};
|
|
986
|
-
|
|
987
|
-
process.on('SIGTERM', shutdown);
|
|
988
|
-
process.on('SIGINT', shutdown);
|
|
663
|
+
if (Object.keys(appSecrets).length > 0) {
|
|
664
|
+
const secretsDir = (0, node_path.join)(secretsRoot, ".gkm");
|
|
665
|
+
await (0, node_fs_promises.mkdir)(secretsDir, { recursive: true });
|
|
666
|
+
const secretsFileName = workspaceAppName ? `dev-secrets-${workspaceAppName}.json` : "dev-secrets.json";
|
|
667
|
+
secretsJsonPath = (0, node_path.join)(secretsDir, secretsFileName);
|
|
668
|
+
await (0, node_fs_promises.writeFile)(secretsJsonPath, JSON.stringify(appSecrets, null, 2));
|
|
669
|
+
logger$11.log(`🔐 Loaded ${Object.keys(appSecrets).length} secret(s)`);
|
|
670
|
+
}
|
|
671
|
+
const devServer = new DevServer(resolved.providers[0], options.port ?? workspaceAppPort ?? 3e3, options.portExplicit ?? false, enableOpenApi, telescope, studio, runtime, appRoot, secretsJsonPath, initial.selfServing !== void 0);
|
|
672
|
+
await devServer.start();
|
|
673
|
+
const hooksFile = (config.hooks?.server?.split("#"))?.[0];
|
|
674
|
+
const normalizedPatterns = [config.constructs, ...hooksFile ? [hooksFile.endsWith(".ts") ? hooksFile : `${hooksFile}.ts`] : []].flat().filter((p) => typeof p === "string").map((p) => p.startsWith("./") ? p.slice(2) : p);
|
|
675
|
+
logger$11.log(`👀 Watching for changes in: ${normalizedPatterns.join(", ")}`);
|
|
676
|
+
const resolvedFiles = await (0, fast_glob.default)(normalizedPatterns, {
|
|
677
|
+
cwd: appRoot,
|
|
678
|
+
absolute: false,
|
|
679
|
+
onlyFiles: true
|
|
680
|
+
});
|
|
681
|
+
const dirsToWatch = [...new Set(resolvedFiles.map((f) => {
|
|
682
|
+
return f.split("/").slice(0, -1).join("/");
|
|
683
|
+
}))];
|
|
684
|
+
logger$11.log(`📁 Found ${resolvedFiles.length} files in ${dirsToWatch.length} directories`);
|
|
685
|
+
const watcher = chokidar.default.watch([...resolvedFiles, ...dirsToWatch], {
|
|
686
|
+
ignored: /(^|[/\\])\../,
|
|
687
|
+
persistent: true,
|
|
688
|
+
ignoreInitial: true,
|
|
689
|
+
cwd: appRoot
|
|
690
|
+
});
|
|
691
|
+
watcher.on("ready", () => {
|
|
692
|
+
logger$11.log("🔍 File watcher ready");
|
|
693
|
+
});
|
|
694
|
+
watcher.on("error", (error) => {
|
|
695
|
+
logger$11.error("❌ Watcher error:", error);
|
|
696
|
+
});
|
|
697
|
+
let rebuildTimeout = null;
|
|
698
|
+
watcher.on("change", async (path) => {
|
|
699
|
+
logger$11.log(`📝 File changed: ${path}`);
|
|
700
|
+
if (rebuildTimeout) clearTimeout(rebuildTimeout);
|
|
701
|
+
rebuildTimeout = setTimeout(async () => {
|
|
702
|
+
try {
|
|
703
|
+
logger$11.log("🔄 Rebuilding...");
|
|
704
|
+
const rebuilt = await build(true);
|
|
705
|
+
logger$11.log("✅ Rebuild complete, restarting server...");
|
|
706
|
+
devServer.selfServing = rebuilt.selfServing !== void 0;
|
|
707
|
+
await devServer.restart();
|
|
708
|
+
} catch (error) {
|
|
709
|
+
const err = error;
|
|
710
|
+
logger$11.error(`❌ Rebuild failed: ${err.message}`);
|
|
711
|
+
if (err.stack) logger$11.error(err.stack);
|
|
712
|
+
}
|
|
713
|
+
}, 300);
|
|
714
|
+
});
|
|
715
|
+
let isShuttingDown = false;
|
|
716
|
+
const shutdown = () => {
|
|
717
|
+
if (isShuttingDown) return;
|
|
718
|
+
isShuttingDown = true;
|
|
719
|
+
logger$11.log("\n🛑 Shutting down...");
|
|
720
|
+
Promise.all([watcher.close(), devServer.stop()]).catch((err) => {
|
|
721
|
+
logger$11.error("Error during shutdown:", err);
|
|
722
|
+
}).finally(() => {
|
|
723
|
+
process.exit(0);
|
|
724
|
+
});
|
|
725
|
+
};
|
|
726
|
+
process.on("SIGINT", shutdown);
|
|
727
|
+
process.on("SIGTERM", shutdown);
|
|
989
728
|
}
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
729
|
+
/**
|
|
730
|
+
* Generate all dependency environment variables for all apps.
|
|
731
|
+
* Returns a flat object with all {APP_NAME}_URL variables.
|
|
732
|
+
* @internal Exported for testing
|
|
733
|
+
*/
|
|
734
|
+
function generateAllDependencyEnvVars(workspace, urlPrefix = "http://localhost") {
|
|
735
|
+
const env = {};
|
|
736
|
+
for (const appName of Object.keys(workspace.apps)) {
|
|
737
|
+
const appEnv = require_workspace.getDependencyEnvVars(workspace, appName, urlPrefix);
|
|
738
|
+
Object.assign(env, appEnv);
|
|
993
739
|
}
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
740
|
+
return env;
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Check for port conflicts across all apps.
|
|
744
|
+
* Returns list of conflicts if any ports are duplicated.
|
|
745
|
+
* @internal Exported for testing
|
|
746
|
+
*/
|
|
747
|
+
function checkPortConflicts(workspace) {
|
|
748
|
+
const conflicts = [];
|
|
749
|
+
const portToApp = /* @__PURE__ */ new Map();
|
|
750
|
+
for (const [appName, app] of Object.entries(workspace.apps)) {
|
|
751
|
+
const existingApp = portToApp.get(app.port);
|
|
752
|
+
if (existingApp) conflicts.push({
|
|
753
|
+
app1: existingApp,
|
|
754
|
+
app2: appName,
|
|
755
|
+
port: app.port
|
|
756
|
+
});
|
|
757
|
+
else portToApp.set(app.port, appName);
|
|
1000
758
|
}
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
759
|
+
return conflicts;
|
|
760
|
+
}
|
|
761
|
+
const FRONTEND_FRAMEWORKS = {
|
|
762
|
+
nextjs: {
|
|
763
|
+
displayName: "Next.js",
|
|
764
|
+
configFiles: [
|
|
765
|
+
"next.config.js",
|
|
766
|
+
"next.config.ts",
|
|
767
|
+
"next.config.mjs",
|
|
768
|
+
"next.config.cjs"
|
|
769
|
+
],
|
|
770
|
+
dependency: "next",
|
|
771
|
+
installCommand: "pnpm add next react react-dom"
|
|
772
|
+
},
|
|
773
|
+
remix: {
|
|
774
|
+
displayName: "Remix",
|
|
775
|
+
configFiles: [
|
|
776
|
+
"remix.config.js",
|
|
777
|
+
"remix.config.mjs",
|
|
778
|
+
"remix.config.ts",
|
|
779
|
+
"vite.config.js",
|
|
780
|
+
"vite.config.ts",
|
|
781
|
+
"vite.config.mjs",
|
|
782
|
+
"vite.config.cjs"
|
|
783
|
+
],
|
|
784
|
+
dependency: "@remix-run/dev",
|
|
785
|
+
installCommand: "pnpm add -D @remix-run/dev"
|
|
786
|
+
},
|
|
787
|
+
vite: {
|
|
788
|
+
displayName: "Vite",
|
|
789
|
+
configFiles: [
|
|
790
|
+
"vite.config.js",
|
|
791
|
+
"vite.config.ts",
|
|
792
|
+
"vite.config.mjs",
|
|
793
|
+
"vite.config.cjs"
|
|
794
|
+
],
|
|
795
|
+
dependency: "vite",
|
|
796
|
+
installCommand: "pnpm add -D vite"
|
|
797
|
+
},
|
|
798
|
+
"tanstack-start": {
|
|
799
|
+
displayName: "TanStack Start",
|
|
800
|
+
configFiles: [
|
|
801
|
+
"vite.config.js",
|
|
802
|
+
"vite.config.ts",
|
|
803
|
+
"vite.config.mjs",
|
|
804
|
+
"vite.config.cjs"
|
|
805
|
+
],
|
|
806
|
+
dependency: "@tanstack/react-start",
|
|
807
|
+
installCommand: "pnpm add @tanstack/react-start @tanstack/react-router"
|
|
808
|
+
},
|
|
809
|
+
expo: {
|
|
810
|
+
displayName: "Expo",
|
|
811
|
+
configFiles: [
|
|
812
|
+
"app.config.ts",
|
|
813
|
+
"app.config.js",
|
|
814
|
+
"app.json"
|
|
815
|
+
],
|
|
816
|
+
dependency: "expo",
|
|
817
|
+
installCommand: "pnpm add expo"
|
|
1032
818
|
}
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
819
|
+
};
|
|
820
|
+
/** Whether a configured framework is one this table knows how to start. */
|
|
821
|
+
function isClientFramework(framework) {
|
|
822
|
+
return framework !== void 0 && framework in FRONTEND_FRAMEWORKS;
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Auto-detect the frontend framework by scanning package.json deps and config
|
|
826
|
+
* files. Dependency match wins over config file match because deps are more
|
|
827
|
+
* distinctive (a Remix app has both vite.config.ts AND @remix-run/dev).
|
|
828
|
+
*/
|
|
829
|
+
function detectFrontendFramework(fullPath, deps) {
|
|
830
|
+
const order = [
|
|
831
|
+
"expo",
|
|
832
|
+
"tanstack-start",
|
|
833
|
+
"nextjs",
|
|
834
|
+
"remix",
|
|
835
|
+
"vite"
|
|
836
|
+
];
|
|
837
|
+
for (const name of order) if (deps[FRONTEND_FRAMEWORKS[name].dependency]) return name;
|
|
838
|
+
for (const name of order) if (FRONTEND_FRAMEWORKS[name].configFiles.some((file) => (0, node_fs.existsSync)((0, node_path.join)(fullPath, file)))) return name;
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* Validate a frontend app configuration.
|
|
842
|
+
*
|
|
843
|
+
* If `framework` is provided, validates strictly against that framework's
|
|
844
|
+
* expected config file and dependency. Otherwise auto-detects the framework
|
|
845
|
+
* from package.json and config files. If no recognized framework is found, the
|
|
846
|
+
* app is allowed through with a warning provided it has a `dev` script.
|
|
847
|
+
*
|
|
848
|
+
* @internal Exported for testing
|
|
849
|
+
*/
|
|
850
|
+
async function validateFrontendApp(appName, appPath, workspaceRoot, framework) {
|
|
851
|
+
const errors = [];
|
|
852
|
+
const warnings = [];
|
|
853
|
+
const fullPath = (0, node_path.join)(workspaceRoot, appPath);
|
|
854
|
+
const packageJsonPath = (0, node_path.join)(fullPath, "package.json");
|
|
855
|
+
if (!(0, node_fs.existsSync)(packageJsonPath)) {
|
|
856
|
+
errors.push(`package.json not found at ${appPath}. Run: pnpm init in the app directory.`);
|
|
857
|
+
return {
|
|
858
|
+
appName,
|
|
859
|
+
valid: false,
|
|
860
|
+
errors,
|
|
861
|
+
warnings
|
|
862
|
+
};
|
|
1049
863
|
}
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
*
|
|
1062
|
-
* ⚠️ WARNING: This is for LOCAL DEVELOPMENT ONLY
|
|
1063
|
-
* This uses event polling which is not suitable for production.
|
|
1064
|
-
*
|
|
1065
|
-
* For production, use AWS Lambda with SQS/SNS event source mappings.
|
|
1066
|
-
* Lambda automatically:
|
|
1067
|
-
* - Scales based on queue depth
|
|
1068
|
-
* - Handles batch processing and retries
|
|
1069
|
-
* - Manages dead letter queues
|
|
1070
|
-
* - Provides better cost optimization
|
|
1071
|
-
*
|
|
1072
|
-
* This polling implementation is useful for:
|
|
1073
|
-
* - Local development and testing
|
|
1074
|
-
* - Understanding event flow without Lambda deployment
|
|
1075
|
-
*
|
|
1076
|
-
* Supported connection strings:
|
|
1077
|
-
* - sqs://region/account-id/queue-name (SQS queue)
|
|
1078
|
-
* - sns://region/account-id/topic-name (SNS topic)
|
|
1079
|
-
* - rabbitmq://host:port/queue-name (RabbitMQ)
|
|
1080
|
-
* - pgboss://user:pass@host:port/database (pg-boss / PostgreSQL)
|
|
1081
|
-
* - basic://in-memory (In-memory for testing)
|
|
1082
|
-
*/
|
|
1083
|
-
import type { EnvironmentParser } from '@geekmidas/envkit';
|
|
1084
|
-
import type { Logger } from '@geekmidas/logger';
|
|
1085
|
-
import { EventConnectionFactory, Subscriber } from '@geekmidas/events';
|
|
1086
|
-
import type { EventConnection, EventSubscriber } from '@geekmidas/events';
|
|
1087
|
-
import { ServiceDiscovery } from '@geekmidas/services';
|
|
1088
|
-
${Array.from(importsByFile.entries()).map(([importPath, exports]) => `import { ${exports.join(", ")} } from '${importPath}';`).join("\n")}
|
|
1089
|
-
|
|
1090
|
-
const subscribers = [
|
|
1091
|
-
${subscribers.map(({ key }) => key).join(",\n ")}
|
|
1092
|
-
];
|
|
1093
|
-
|
|
1094
|
-
const activeSubscribers: EventSubscriber<any>[] = [];
|
|
1095
|
-
|
|
1096
|
-
export async function setupSubscribers(
|
|
1097
|
-
envParser: EnvironmentParser<any>,
|
|
1098
|
-
logger: Logger,
|
|
1099
|
-
): Promise<void> {
|
|
1100
|
-
if (subscribers.length === 0) {
|
|
1101
|
-
return;
|
|
1102
|
-
}
|
|
1103
|
-
|
|
1104
|
-
logger.info('Setting up subscribers in polling mode (local development)');
|
|
1105
|
-
|
|
1106
|
-
const config = envParser.create((get) => ({
|
|
1107
|
-
connectionString: get('EVENT_SUBSCRIBER_CONNECTION_STRING').string(),
|
|
1108
|
-
})).parse();
|
|
1109
|
-
|
|
1110
|
-
const serviceDiscovery = ServiceDiscovery.getInstance(envParser);
|
|
1111
|
-
|
|
1112
|
-
// Create connection once, outside the loop (more efficient)
|
|
1113
|
-
// EventConnectionFactory automatically determines the right connection type
|
|
1114
|
-
let connection: EventConnection;
|
|
1115
|
-
try {
|
|
1116
|
-
connection = await EventConnectionFactory.fromConnectionString(config.connectionString);
|
|
1117
|
-
|
|
1118
|
-
const connectionType = new URL(config.connectionString).protocol.replace(':', '');
|
|
1119
|
-
logger.info({ connectionType }, 'Created shared event connection');
|
|
1120
|
-
} catch (error) {
|
|
1121
|
-
logger.error({ error }, 'Failed to create event connection');
|
|
1122
|
-
return;
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
for (const subscriber of subscribers) {
|
|
1126
|
-
try {
|
|
1127
|
-
// Create subscriber from shared connection
|
|
1128
|
-
const eventSubscriber = await Subscriber.fromConnection(connection);
|
|
1129
|
-
|
|
1130
|
-
// Register services
|
|
1131
|
-
const services = subscriber.services.length > 0
|
|
1132
|
-
? await serviceDiscovery.register(subscriber.services)
|
|
1133
|
-
: {};
|
|
1134
|
-
|
|
1135
|
-
// Subscribe to events
|
|
1136
|
-
const subscribedEvents = subscriber.subscribedEvents || [];
|
|
1137
|
-
|
|
1138
|
-
if (subscribedEvents.length === 0) {
|
|
1139
|
-
logger.warn({ subscriber: subscriber.constructor.name }, 'Subscriber has no subscribed events, skipping');
|
|
1140
|
-
continue;
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
await eventSubscriber.subscribe(subscribedEvents, async (event) => {
|
|
1144
|
-
try {
|
|
1145
|
-
// Process single event (batch of 1)
|
|
1146
|
-
await subscriber.handler({
|
|
1147
|
-
events: [event],
|
|
1148
|
-
services: services as any,
|
|
1149
|
-
logger: subscriber.logger,
|
|
1150
|
-
});
|
|
1151
|
-
|
|
1152
|
-
logger.debug({ eventType: event.type }, 'Successfully processed event');
|
|
1153
|
-
} catch (error) {
|
|
1154
|
-
logger.error({ error, event }, 'Failed to process event');
|
|
1155
|
-
// Event will become visible again for retry
|
|
1156
|
-
}
|
|
1157
|
-
});
|
|
1158
|
-
|
|
1159
|
-
activeSubscribers.push(eventSubscriber);
|
|
1160
|
-
|
|
1161
|
-
logger.info(
|
|
1162
|
-
{
|
|
1163
|
-
events: subscribedEvents,
|
|
1164
|
-
},
|
|
1165
|
-
'Subscriber started polling'
|
|
1166
|
-
);
|
|
1167
|
-
} catch (error) {
|
|
1168
|
-
logger.error({ error, subscriber: subscriber.constructor.name }, 'Failed to setup subscriber');
|
|
1169
|
-
}
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
// Setup graceful shutdown
|
|
1173
|
-
const shutdown = () => {
|
|
1174
|
-
logger.info('Stopping all subscribers');
|
|
1175
|
-
for (const eventSubscriber of activeSubscribers) {
|
|
1176
|
-
connection.stop();
|
|
1177
|
-
}
|
|
1178
|
-
};
|
|
1179
|
-
|
|
1180
|
-
process.on('SIGTERM', shutdown);
|
|
1181
|
-
process.on('SIGINT', shutdown);
|
|
1182
|
-
}
|
|
1183
|
-
`;
|
|
1184
|
-
await (0, node_fs_promises.writeFile)(subscribersPath, content);
|
|
1185
|
-
return subscribersPath;
|
|
864
|
+
let pkg;
|
|
865
|
+
try {
|
|
866
|
+
pkg = require(packageJsonPath);
|
|
867
|
+
} catch {
|
|
868
|
+
errors.push(`Failed to read package.json at ${packageJsonPath}`);
|
|
869
|
+
return {
|
|
870
|
+
appName,
|
|
871
|
+
valid: false,
|
|
872
|
+
errors,
|
|
873
|
+
warnings
|
|
874
|
+
};
|
|
1186
875
|
}
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
876
|
+
const deps = {
|
|
877
|
+
...pkg.dependencies,
|
|
878
|
+
...pkg.devDependencies
|
|
879
|
+
};
|
|
880
|
+
const resolvedFramework = framework ?? detectFrontendFramework(fullPath, deps);
|
|
881
|
+
if (resolvedFramework) {
|
|
882
|
+
const spec = FRONTEND_FRAMEWORKS[resolvedFramework];
|
|
883
|
+
if (!spec.configFiles.some((file) => (0, node_fs.existsSync)((0, node_path.join)(fullPath, file)))) errors.push(`${spec.displayName} config file not found. Expected one of: ${spec.configFiles.join(", ")}`);
|
|
884
|
+
if (!deps[spec.dependency]) errors.push(`${spec.displayName} not found in dependencies. Run: ${spec.installCommand}`);
|
|
885
|
+
} else warnings.push("No recognized frontend framework detected (Next.js, Vite, Remix). The app will run via its \"dev\" script. Set `framework` in your app config to enable strict validation.");
|
|
886
|
+
if (!pkg.scripts?.dev) {
|
|
887
|
+
if (resolvedFramework) warnings.push("No \"dev\" script found in package.json. Turbo expects a \"dev\" script to run.");
|
|
888
|
+
else errors.push("No \"dev\" script found in package.json. Without a recognized framework or a dev script, there is nothing to run.");
|
|
889
|
+
}
|
|
890
|
+
return {
|
|
891
|
+
appName,
|
|
892
|
+
valid: errors.length === 0,
|
|
893
|
+
errors,
|
|
894
|
+
warnings
|
|
895
|
+
};
|
|
896
|
+
}
|
|
1190
897
|
/**
|
|
1191
|
-
*
|
|
1192
|
-
*
|
|
1193
|
-
*
|
|
1194
|
-
* producing construct) and subscribers bind via `s.topic(topic)`; this generator
|
|
1195
|
-
* only records the topic + its event contract so infra can provision the SNS
|
|
1196
|
-
* topic. Identical output for every provider (locally pg-boss routes by event
|
|
1197
|
-
* name, so there's nothing to run).
|
|
898
|
+
* Validate all frontend apps in the workspace.
|
|
899
|
+
* Returns validation results for each frontend app.
|
|
900
|
+
* @internal Exported for testing
|
|
1198
901
|
*/
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
const logger = console;
|
|
1205
|
-
const topicInfos = constructs.map(({ construct }) => ({
|
|
1206
|
-
name: construct.name,
|
|
1207
|
-
events: construct.eventTypes
|
|
1208
|
-
}));
|
|
1209
|
-
for (const topic of topicInfos) logger.log(`Discovered topic: ${topic.name}`);
|
|
1210
|
-
return topicInfos;
|
|
902
|
+
async function validateFrontendApps(workspace) {
|
|
903
|
+
const results = [];
|
|
904
|
+
for (const [appName, app] of Object.entries(workspace.apps)) if (app.type === "web") {
|
|
905
|
+
const result = await validateFrontendApp(appName, app.path, workspace.root, isClientFramework(app.framework) ? app.framework : void 0);
|
|
906
|
+
results.push(result);
|
|
1211
907
|
}
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
//#region src/workspace/client-generator.ts
|
|
908
|
+
return results;
|
|
909
|
+
}
|
|
1215
910
|
/**
|
|
1216
|
-
*
|
|
1217
|
-
*
|
|
1218
|
-
* @internal Exported for
|
|
911
|
+
* Load secrets for development stage.
|
|
912
|
+
* Returns env vars to inject, or empty object if secrets not configured/found.
|
|
913
|
+
* @internal Exported for testing
|
|
1219
914
|
*/
|
|
1220
|
-
function
|
|
1221
|
-
if (!
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
var NoCommandSpecified = class extends Error {
|
|
1230
|
-
constructor() {
|
|
1231
|
-
super("No command specified. Usage: gkm exec -- <command>");
|
|
1232
|
-
this.name = "NoCommandSpecified";
|
|
915
|
+
async function loadDevSecrets(workspace) {
|
|
916
|
+
if (!workspace.secrets.enabled) return {};
|
|
917
|
+
const stage = workspace.stages.local;
|
|
918
|
+
if (require_storage.secretsExist(stage, workspace.root)) {
|
|
919
|
+
const secrets = await require_storage.readStageSecrets(stage, workspace.root);
|
|
920
|
+
if (secrets) {
|
|
921
|
+
logger$11.log(`🔐 Loading secrets from stage: ${stage}`);
|
|
922
|
+
return require_storage.toEmbeddableSecrets(secrets);
|
|
923
|
+
}
|
|
1233
924
|
}
|
|
1234
|
-
};
|
|
925
|
+
logger$11.warn(`⚠️ Secrets enabled but no "${stage}" secrets found. Run "gkm setup" to initialize the local stage`);
|
|
926
|
+
return {};
|
|
927
|
+
}
|
|
1235
928
|
/**
|
|
1236
|
-
*
|
|
1237
|
-
* Uses Node's --import flag to preload a script that populates Credentials
|
|
1238
|
-
* before the command loads any modules that depend on them.
|
|
929
|
+
* Workspace dev command - orchestrates multi-app development using Turbo.
|
|
1239
930
|
*
|
|
1240
|
-
*
|
|
1241
|
-
*
|
|
1242
|
-
*
|
|
1243
|
-
*
|
|
1244
|
-
*
|
|
931
|
+
* Flow:
|
|
932
|
+
* 1. Check for port conflicts
|
|
933
|
+
* 2. Start docker-compose services (db, cache, mail)
|
|
934
|
+
* 3. Generate dependency URLs ({APP_NAME}_URL)
|
|
935
|
+
* 4. Spawn turbo run dev with injected env vars
|
|
1245
936
|
*/
|
|
1246
|
-
async function
|
|
1247
|
-
const
|
|
1248
|
-
|
|
1249
|
-
const
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
});
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
937
|
+
async function workspaceDevCommand(workspace, options) {
|
|
938
|
+
const appCount = Object.keys(workspace.apps).length;
|
|
939
|
+
const backendApps = Object.entries(workspace.apps).filter(([_, app]) => app.type === "backend");
|
|
940
|
+
const frontendApps = Object.entries(workspace.apps).filter(([_, app]) => app.type === "web");
|
|
941
|
+
const mobileApps = Object.entries(workspace.apps).filter(([_, app]) => app.type === "mobile");
|
|
942
|
+
logger$11.log(`\n🚀 Starting workspace: ${workspace.name}`);
|
|
943
|
+
const counts = [`${backendApps.length} backend`, `${frontendApps.length} web`];
|
|
944
|
+
if (mobileApps.length > 0) counts.push(`${mobileApps.length} mobile`);
|
|
945
|
+
logger$11.log(` ${counts.join(", ")} app(s)`);
|
|
946
|
+
const conflicts = checkPortConflicts(workspace);
|
|
947
|
+
if (conflicts.length > 0) {
|
|
948
|
+
for (const conflict of conflicts) logger$11.error(`❌ Port conflict: Apps "${conflict.app1}" and "${conflict.app2}" both use port ${conflict.port}`);
|
|
949
|
+
throw new Error("Port conflicts detected. Please assign unique ports to each app.");
|
|
950
|
+
}
|
|
951
|
+
if (frontendApps.length > 0) {
|
|
952
|
+
logger$11.log("\n🔍 Validating frontend apps...");
|
|
953
|
+
const validationResults = await validateFrontendApps(workspace);
|
|
954
|
+
let hasErrors = false;
|
|
955
|
+
for (const result of validationResults) {
|
|
956
|
+
if (!result.valid) {
|
|
957
|
+
hasErrors = true;
|
|
958
|
+
logger$11.error(`\n❌ Frontend app "${result.appName}" validation failed:`);
|
|
959
|
+
for (const error of result.errors) logger$11.error(` • ${error}`);
|
|
960
|
+
}
|
|
961
|
+
for (const warning of result.warnings) logger$11.warn(` ⚠️ ${result.appName}: ${warning}`);
|
|
962
|
+
}
|
|
963
|
+
if (hasErrors) throw new Error("Frontend app validation failed. Fix the issues above and try again.");
|
|
964
|
+
logger$11.log("✅ Frontend apps validated");
|
|
965
|
+
}
|
|
966
|
+
const rawSecrets = await loadDevSecrets(workspace);
|
|
967
|
+
let secretsEnv;
|
|
968
|
+
if (require_credentials.usesConstructs(workspace)) {
|
|
969
|
+
const reconciled = await require_credentials.reconcileWorkspace(workspace, { stage: workspace.stages.local });
|
|
970
|
+
if (reconciled.changed && reconciled.plan.containers.length > 0) {
|
|
971
|
+
logger$11.log(`🐳 Services: ${reconciled.plan.containers.join(", ")}`);
|
|
972
|
+
for (const [container, address] of Object.entries(reconciled.addresses)) logger$11.log(` ${container}: ${address}`);
|
|
973
|
+
}
|
|
974
|
+
secretsEnv = {
|
|
975
|
+
...rawSecrets,
|
|
976
|
+
...reconciled.env
|
|
977
|
+
};
|
|
978
|
+
} else {
|
|
979
|
+
const resolvedPorts = await require_credentials.resolveServicePorts(workspace.root);
|
|
980
|
+
await require_credentials.startWorkspaceServices(workspace, resolvedPorts.dockerEnv, rawSecrets);
|
|
981
|
+
secretsEnv = require_credentials.rewriteUrlsWithPorts(rawSecrets, resolvedPorts);
|
|
982
|
+
}
|
|
983
|
+
if (Object.keys(secretsEnv).length > 0) logger$11.log(` Loaded ${Object.keys(secretsEnv).length} secret(s)`);
|
|
984
|
+
const dependencyEnv = generateAllDependencyEnvVars(workspace);
|
|
985
|
+
if (Object.keys(dependencyEnv).length > 0) {
|
|
986
|
+
logger$11.log("📡 Dependency URLs:");
|
|
987
|
+
for (const [key, value] of Object.entries(dependencyEnv)) logger$11.log(` ${key}=${value}`);
|
|
988
|
+
}
|
|
989
|
+
let turboFilter = [];
|
|
990
|
+
if (options.app) {
|
|
991
|
+
if (!workspace.apps[options.app]) {
|
|
992
|
+
const appNames = Object.keys(workspace.apps).join(", ");
|
|
993
|
+
throw new Error(`App "${options.app}" not found. Available apps: ${appNames}`);
|
|
994
|
+
}
|
|
995
|
+
turboFilter = ["--filter", appPackageName(workspace, options.app) ?? options.app];
|
|
996
|
+
logger$11.log(`\n🎯 Running single app: ${options.app}`);
|
|
997
|
+
} else if (options.filter) {
|
|
998
|
+
turboFilter = ["--filter", options.filter];
|
|
999
|
+
logger$11.log(`\n🔍 Using filter: ${options.filter}`);
|
|
1000
|
+
} else {
|
|
1001
|
+
const { filters, unpackaged } = turboFilters(workspace);
|
|
1002
|
+
turboFilter = filters.flatMap((name) => ["--filter", name]);
|
|
1003
|
+
if (unpackaged.length > 0) logger$11.warn(`⚠️ No package.json with a name for: ${unpackaged.join(", ")} — turbo cannot run ${unpackaged.length === 1 ? "it" : "them"}.`);
|
|
1004
|
+
logger$11.log(`\n🎯 Running all ${appCount} apps`);
|
|
1005
|
+
}
|
|
1006
|
+
const buildOrder = require_workspace.getAppBuildOrder(workspace);
|
|
1007
|
+
logger$11.log("\n📋 Apps (in dependency order):");
|
|
1008
|
+
for (const appName of buildOrder) {
|
|
1009
|
+
const app = workspace.apps[appName];
|
|
1010
|
+
if (!app) continue;
|
|
1011
|
+
const deps = app.dependencies.length > 0 ? ` (depends on: ${app.dependencies.join(", ")})` : "";
|
|
1012
|
+
const icon = app.type === "backend" ? "🔧" : app.type === "mobile" ? "📱" : "🌐";
|
|
1013
|
+
logger$11.log(` ${icon} ${appName} → http://localhost:${app.port}${deps}`);
|
|
1014
|
+
}
|
|
1015
|
+
const configFiles = [
|
|
1016
|
+
"gkm.config.ts",
|
|
1017
|
+
"gkm.config.js",
|
|
1018
|
+
"gkm.config.json"
|
|
1019
|
+
];
|
|
1020
|
+
let configPath = "";
|
|
1021
|
+
for (const file of configFiles) {
|
|
1022
|
+
const fullPath = (0, node_path.join)(workspace.root, file);
|
|
1023
|
+
if ((0, node_fs.existsSync)(fullPath)) {
|
|
1024
|
+
configPath = fullPath;
|
|
1025
|
+
break;
|
|
1272
1026
|
}
|
|
1027
|
+
}
|
|
1028
|
+
const turboEnv = {
|
|
1029
|
+
...process.env,
|
|
1030
|
+
...secretsEnv,
|
|
1031
|
+
...dependencyEnv,
|
|
1032
|
+
NODE_ENV: "development",
|
|
1033
|
+
...configPath ? { GKM_CONFIG_PATH: configPath } : {}
|
|
1034
|
+
};
|
|
1035
|
+
logger$11.log("\n🏃 Starting turbo run dev...\n");
|
|
1036
|
+
const turboProcess = (0, node_child_process.spawn)("pnpm", [
|
|
1037
|
+
"turbo",
|
|
1038
|
+
"run",
|
|
1039
|
+
"dev",
|
|
1040
|
+
...turboFilter
|
|
1041
|
+
], {
|
|
1042
|
+
cwd: workspace.root,
|
|
1043
|
+
stdio: "inherit",
|
|
1044
|
+
env: turboEnv,
|
|
1045
|
+
detached: true
|
|
1273
1046
|
});
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1047
|
+
let isShuttingDown = false;
|
|
1048
|
+
const shutdown = () => {
|
|
1049
|
+
if (isShuttingDown) return;
|
|
1050
|
+
isShuttingDown = true;
|
|
1051
|
+
logger$11.log("\n🛑 Shutting down workspace...");
|
|
1052
|
+
const pid = turboProcess.pid;
|
|
1053
|
+
if (pid) try {
|
|
1054
|
+
process.kill(-pid, "SIGTERM");
|
|
1055
|
+
} catch {
|
|
1056
|
+
try {
|
|
1057
|
+
process.kill(pid, "SIGTERM");
|
|
1058
|
+
} catch {}
|
|
1059
|
+
}
|
|
1060
|
+
setTimeout(() => {
|
|
1061
|
+
if (pid) try {
|
|
1062
|
+
process.kill(-pid, "SIGKILL");
|
|
1063
|
+
} catch {
|
|
1064
|
+
try {
|
|
1065
|
+
process.kill(pid, "SIGKILL");
|
|
1066
|
+
} catch {}
|
|
1067
|
+
}
|
|
1068
|
+
process.exit(0);
|
|
1069
|
+
}, 3e3);
|
|
1070
|
+
};
|
|
1071
|
+
process.on("SIGINT", shutdown);
|
|
1072
|
+
process.on("SIGTERM", shutdown);
|
|
1073
|
+
return new Promise((resolve, reject) => {
|
|
1074
|
+
turboProcess.on("error", (error) => {
|
|
1075
|
+
logger$11.error("❌ Turbo error:", error);
|
|
1076
|
+
reject(error);
|
|
1077
|
+
});
|
|
1078
|
+
turboProcess.on("exit", (code) => {
|
|
1079
|
+
if (code !== null && code !== 0) reject(/* @__PURE__ */ new Error(`Turbo exited with code ${code}`));
|
|
1080
|
+
else resolve();
|
|
1279
1081
|
});
|
|
1280
1082
|
});
|
|
1281
|
-
if (exitCode !== 0) process.exit(exitCode);
|
|
1282
1083
|
}
|
|
1283
|
-
//#endregion
|
|
1284
|
-
//#region src/dev/index.ts
|
|
1285
|
-
const logger$11 = console;
|
|
1286
1084
|
/**
|
|
1287
|
-
*
|
|
1288
|
-
*
|
|
1085
|
+
* Run any TypeScript file with secret injection.
|
|
1086
|
+
* Does not require gkm.config.ts.
|
|
1289
1087
|
*/
|
|
1290
|
-
function
|
|
1291
|
-
|
|
1292
|
-
if (
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1088
|
+
async function entryDevCommand(options) {
|
|
1089
|
+
const { entry, watch = true } = options;
|
|
1090
|
+
if (!entry) throw new Error("--entry requires a file path");
|
|
1091
|
+
const entryPath = (0, node_path.resolve)(process.cwd(), entry);
|
|
1092
|
+
if (!(0, node_fs.existsSync)(entryPath)) throw new Error(`Entry file not found: ${entryPath}`);
|
|
1093
|
+
const defaultEnv = require_credentials.loadEnvFiles(".env");
|
|
1094
|
+
if (defaultEnv.loaded.length > 0) logger$11.log(`📦 Loaded env: ${defaultEnv.loaded.join(", ")}`);
|
|
1095
|
+
const { credentials, resolvedPort, secretsJsonPath, appName } = await require_credentials.prepareEntryCredentials({ explicitPort: options.portExplicit ? options.port : void 0 });
|
|
1096
|
+
if (appName) logger$11.log(`📦 App: ${appName} (port ${resolvedPort})`);
|
|
1097
|
+
logger$11.log(`🚀 Starting entry file: ${entry} on port ${resolvedPort}`);
|
|
1098
|
+
if (Object.keys(credentials).length > 1) logger$11.log(`🔐 Loaded ${Object.keys(credentials).length - 1} secret(s) + PORT`);
|
|
1099
|
+
const wrapperDir = (0, node_path.join)(process.cwd(), ".gkm");
|
|
1100
|
+
await (0, node_fs_promises.mkdir)(wrapperDir, { recursive: true });
|
|
1101
|
+
const wrapperPath = (0, node_path.join)(wrapperDir, "entry-wrapper.ts");
|
|
1102
|
+
await require_credentials.createEntryWrapper(wrapperPath, entryPath, secretsJsonPath);
|
|
1103
|
+
const runner = new EntryRunner(wrapperPath, entryPath, watch, resolvedPort);
|
|
1104
|
+
await runner.start();
|
|
1105
|
+
let isShuttingDown = false;
|
|
1106
|
+
const shutdown = () => {
|
|
1107
|
+
if (isShuttingDown) return;
|
|
1108
|
+
isShuttingDown = true;
|
|
1109
|
+
logger$11.log("\n🛑 Shutting down...");
|
|
1110
|
+
runner.stop();
|
|
1111
|
+
process.exit(0);
|
|
1314
1112
|
};
|
|
1113
|
+
process.on("SIGINT", shutdown);
|
|
1114
|
+
process.on("SIGTERM", shutdown);
|
|
1115
|
+
await new Promise(() => {});
|
|
1315
1116
|
}
|
|
1316
1117
|
/**
|
|
1317
|
-
*
|
|
1318
|
-
* @internal Exported for testing
|
|
1118
|
+
* Runs and watches a TypeScript entry file using tsx.
|
|
1319
1119
|
*/
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1120
|
+
var EntryRunner = class {
|
|
1121
|
+
wrapperPath;
|
|
1122
|
+
entryPath;
|
|
1123
|
+
watch;
|
|
1124
|
+
port;
|
|
1125
|
+
childProcess = null;
|
|
1126
|
+
watcher = null;
|
|
1127
|
+
isRunning = false;
|
|
1128
|
+
constructor(wrapperPath, entryPath, watch, port) {
|
|
1129
|
+
this.wrapperPath = wrapperPath;
|
|
1130
|
+
this.entryPath = entryPath;
|
|
1131
|
+
this.watch = watch;
|
|
1132
|
+
this.port = port;
|
|
1331
1133
|
}
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
/**
|
|
1341
|
-
* Normalize hooks configuration
|
|
1342
|
-
* @internal Exported for testing
|
|
1343
|
-
*/
|
|
1344
|
-
function normalizeHooksConfig(config, cwd = process.cwd()) {
|
|
1345
|
-
if (!config?.server) return;
|
|
1346
|
-
const serverPath = config.server.endsWith(".ts") ? config.server : `${config.server}.ts`;
|
|
1347
|
-
return { serverHooksPath: (0, node_path.resolve)(cwd, serverPath) };
|
|
1348
|
-
}
|
|
1349
|
-
/**
|
|
1350
|
-
* Normalize production configuration
|
|
1351
|
-
* @internal Exported for testing
|
|
1352
|
-
*/
|
|
1353
|
-
function normalizeProductionConfig(cliProduction, configProduction) {
|
|
1354
|
-
if (!cliProduction) return;
|
|
1355
|
-
const config = configProduction ?? {};
|
|
1356
|
-
return {
|
|
1357
|
-
enabled: true,
|
|
1358
|
-
bundle: config.bundle ?? true,
|
|
1359
|
-
minify: config.minify ?? true,
|
|
1360
|
-
healthCheck: config.healthCheck ?? "/health",
|
|
1361
|
-
gracefulShutdown: config.gracefulShutdown ?? true,
|
|
1362
|
-
external: config.external ?? [],
|
|
1363
|
-
subscribers: config.subscribers ?? "exclude",
|
|
1364
|
-
openapi: config.openapi ?? false,
|
|
1365
|
-
optimizedHandlers: config.optimizedHandlers ?? true
|
|
1366
|
-
};
|
|
1367
|
-
}
|
|
1368
|
-
/**
|
|
1369
|
-
* Get production config from GkmConfig
|
|
1370
|
-
* @internal
|
|
1371
|
-
*/
|
|
1372
|
-
function getProductionConfigFromGkm(config) {
|
|
1373
|
-
const serverConfig = config.providers?.server;
|
|
1374
|
-
if (typeof serverConfig === "object") return serverConfig.production;
|
|
1375
|
-
}
|
|
1376
|
-
async function devCommand(options) {
|
|
1377
|
-
if (options.entry) return entryDevCommand(options);
|
|
1378
|
-
const defaultEnv = require_credentials.loadEnvFiles(".env");
|
|
1379
|
-
if (defaultEnv.loaded.length > 0) logger$11.log(`📦 Loaded env: ${defaultEnv.loaded.join(", ")}`);
|
|
1380
|
-
const appName = require_config.getAppNameFromCwd();
|
|
1381
|
-
let config;
|
|
1382
|
-
let appRoot = process.cwd();
|
|
1383
|
-
let secretsRoot = process.cwd();
|
|
1384
|
-
let workspaceAppName;
|
|
1385
|
-
let workspaceAppPort;
|
|
1386
|
-
let workspace;
|
|
1387
|
-
if (appName) try {
|
|
1388
|
-
const appConfig = await require_config.loadAppConfig();
|
|
1389
|
-
config = appConfig.gkmConfig;
|
|
1390
|
-
appRoot = appConfig.appRoot;
|
|
1391
|
-
secretsRoot = appConfig.workspaceRoot;
|
|
1392
|
-
workspaceAppName = appConfig.appName;
|
|
1393
|
-
workspaceAppPort = appConfig.app.port;
|
|
1394
|
-
workspace = appConfig.workspace;
|
|
1395
|
-
logger$11.log(`📦 Running app: ${appConfig.appName} on port ${workspaceAppPort}`);
|
|
1396
|
-
if (appConfig.app.entry) {
|
|
1397
|
-
logger$11.log(`📄 Using entry point: ${appConfig.app.entry}`);
|
|
1398
|
-
return entryDevCommand({
|
|
1399
|
-
...options,
|
|
1400
|
-
entry: appConfig.app.entry,
|
|
1401
|
-
port: workspaceAppPort,
|
|
1402
|
-
portExplicit: true
|
|
1134
|
+
async start() {
|
|
1135
|
+
await this.runProcess();
|
|
1136
|
+
if (this.watch) {
|
|
1137
|
+
const watchDir = (0, node_path.dirname)(this.entryPath);
|
|
1138
|
+
this.watcher = chokidar.default.watch(watchDir, {
|
|
1139
|
+
ignored: /(^|[/\\])\../,
|
|
1140
|
+
persistent: true,
|
|
1141
|
+
ignoreInitial: true
|
|
1403
1142
|
});
|
|
1143
|
+
let restartTimeout = null;
|
|
1144
|
+
this.watcher.on("change", (path) => {
|
|
1145
|
+
logger$11.log(`📝 File changed: ${path}`);
|
|
1146
|
+
if (restartTimeout) clearTimeout(restartTimeout);
|
|
1147
|
+
restartTimeout = setTimeout(async () => {
|
|
1148
|
+
logger$11.log("🔄 Restarting...");
|
|
1149
|
+
await this.restart();
|
|
1150
|
+
}, 300);
|
|
1151
|
+
});
|
|
1152
|
+
logger$11.log(`👀 Watching for changes in: ${watchDir}`);
|
|
1404
1153
|
}
|
|
1405
|
-
} catch {
|
|
1406
|
-
const loadedConfig = await require_config.loadWorkspaceConfig();
|
|
1407
|
-
if (loadedConfig.type === "workspace") {
|
|
1408
|
-
logger$11.log("📦 Detected workspace configuration");
|
|
1409
|
-
return workspaceDevCommand(loadedConfig.workspace, options);
|
|
1410
|
-
}
|
|
1411
|
-
config = loadedConfig.raw;
|
|
1412
|
-
}
|
|
1413
|
-
else {
|
|
1414
|
-
const loadedConfig = await require_config.loadWorkspaceConfig();
|
|
1415
|
-
if (loadedConfig.type === "workspace") {
|
|
1416
|
-
logger$11.log("📦 Detected workspace configuration");
|
|
1417
|
-
return workspaceDevCommand(loadedConfig.workspace, options);
|
|
1418
|
-
}
|
|
1419
|
-
config = loadedConfig.raw;
|
|
1420
|
-
workspace = loadedConfig.workspace;
|
|
1421
|
-
}
|
|
1422
|
-
if (config.env) {
|
|
1423
|
-
const { loaded, missing } = require_credentials.loadEnvFiles(config.env, appRoot);
|
|
1424
|
-
if (loaded.length > 0) logger$11.log(`📦 Loaded env: ${loaded.join(", ")}`);
|
|
1425
|
-
if (missing.length > 0) logger$11.warn(`⚠️ Missing env files: ${missing.join(", ")}`);
|
|
1426
1154
|
}
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
const reconciled = await require_credentials.reconcileWorkspace(workspace, { stage: workspace.stages.local });
|
|
1451
|
-
if (reconciled.changed && reconciled.plan.containers.length > 0) {
|
|
1452
|
-
logger$11.log(`🐳 Services: ${reconciled.plan.containers.join(", ")}`);
|
|
1453
|
-
for (const [container, address] of Object.entries(reconciled.addresses)) logger$11.log(` ${container}: ${address}`);
|
|
1454
|
-
}
|
|
1455
|
-
Object.assign(appSecrets, reconciled.env);
|
|
1456
|
-
} else {
|
|
1457
|
-
const resolvedPorts = await require_credentials.resolveServicePorts(secretsRoot);
|
|
1458
|
-
if (Object.keys(resolvedPorts.ports).length > 0) {
|
|
1459
|
-
const rewritten = require_credentials.rewriteUrlsWithPorts(appSecrets, resolvedPorts);
|
|
1460
|
-
Object.assign(appSecrets, rewritten);
|
|
1461
|
-
logger$11.log(`🔌 Applied ${Object.keys(resolvedPorts.ports).length} port mapping(s)`);
|
|
1155
|
+
async runProcess() {
|
|
1156
|
+
const env = {
|
|
1157
|
+
...process.env,
|
|
1158
|
+
PORT: String(this.port)
|
|
1159
|
+
};
|
|
1160
|
+
this.childProcess = (0, node_child_process.spawn)("npx", ["tsx", this.wrapperPath], {
|
|
1161
|
+
stdio: "inherit",
|
|
1162
|
+
env,
|
|
1163
|
+
detached: true
|
|
1164
|
+
});
|
|
1165
|
+
this.isRunning = true;
|
|
1166
|
+
this.childProcess.on("error", (error) => {
|
|
1167
|
+
logger$11.error("❌ Process error:", error);
|
|
1168
|
+
});
|
|
1169
|
+
this.childProcess.on("exit", (code) => {
|
|
1170
|
+
if (code !== null && code !== 0 && code !== 143) logger$11.error(`❌ Process exited with code ${code}`);
|
|
1171
|
+
this.isRunning = false;
|
|
1172
|
+
});
|
|
1173
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
1174
|
+
if (this.isRunning) {
|
|
1175
|
+
logger$11.log("");
|
|
1176
|
+
logger$11.log(` \x1b[32m✓ Ready\x1b[0m at \x1b[36mhttp://localhost:${this.port}\x1b[0m`);
|
|
1177
|
+
logger$11.log("");
|
|
1462
1178
|
}
|
|
1463
1179
|
}
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1180
|
+
async restart() {
|
|
1181
|
+
this.stopProcess();
|
|
1182
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
1183
|
+
await this.runProcess();
|
|
1467
1184
|
}
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
const secretsFileName = workspaceAppName ? `dev-secrets-${workspaceAppName}.json` : "dev-secrets.json";
|
|
1472
|
-
secretsJsonPath = (0, node_path.join)(secretsDir, secretsFileName);
|
|
1473
|
-
await (0, node_fs_promises.writeFile)(secretsJsonPath, JSON.stringify(appSecrets, null, 2));
|
|
1474
|
-
logger$11.log(`🔐 Loaded ${Object.keys(appSecrets).length} secret(s)`);
|
|
1185
|
+
stop() {
|
|
1186
|
+
this.watcher?.close();
|
|
1187
|
+
this.stopProcess();
|
|
1475
1188
|
}
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
});
|
|
1486
|
-
const dirsToWatch = [...new Set(resolvedFiles.map((f) => {
|
|
1487
|
-
return f.split("/").slice(0, -1).join("/");
|
|
1488
|
-
}))];
|
|
1489
|
-
logger$11.log(`📁 Found ${resolvedFiles.length} files in ${dirsToWatch.length} directories`);
|
|
1490
|
-
const watcher = chokidar.default.watch([...resolvedFiles, ...dirsToWatch], {
|
|
1491
|
-
ignored: /(^|[/\\])\../,
|
|
1492
|
-
persistent: true,
|
|
1493
|
-
ignoreInitial: true,
|
|
1494
|
-
cwd: appRoot
|
|
1495
|
-
});
|
|
1496
|
-
watcher.on("ready", () => {
|
|
1497
|
-
logger$11.log("🔍 File watcher ready");
|
|
1498
|
-
});
|
|
1499
|
-
watcher.on("error", (error) => {
|
|
1500
|
-
logger$11.error("❌ Watcher error:", error);
|
|
1501
|
-
});
|
|
1502
|
-
let rebuildTimeout = null;
|
|
1503
|
-
watcher.on("change", async (path) => {
|
|
1504
|
-
logger$11.log(`📝 File changed: ${path}`);
|
|
1505
|
-
if (rebuildTimeout) clearTimeout(rebuildTimeout);
|
|
1506
|
-
rebuildTimeout = setTimeout(async () => {
|
|
1507
|
-
try {
|
|
1508
|
-
logger$11.log("🔄 Rebuilding...");
|
|
1509
|
-
await buildServer(config, buildContext, resolved.providers[0], enableOpenApi, appRoot, workspaceRoot, true);
|
|
1510
|
-
if (enableOpenApi) await require_openapi.generateOpenApiFrom(config.constructs, {
|
|
1511
|
-
openapi: config.openapi,
|
|
1512
|
-
silent: true,
|
|
1513
|
-
bustCache: true
|
|
1514
|
-
});
|
|
1515
|
-
logger$11.log("✅ Rebuild complete, restarting server...");
|
|
1516
|
-
await devServer.restart();
|
|
1517
|
-
} catch (error) {
|
|
1518
|
-
const err = error;
|
|
1519
|
-
logger$11.error(`❌ Rebuild failed: ${err.message}`);
|
|
1520
|
-
if (err.stack) logger$11.error(err.stack);
|
|
1189
|
+
stopProcess() {
|
|
1190
|
+
if (this.childProcess && this.isRunning) {
|
|
1191
|
+
const pid = this.childProcess.pid;
|
|
1192
|
+
if (pid) try {
|
|
1193
|
+
process.kill(-pid, "SIGTERM");
|
|
1194
|
+
} catch {
|
|
1195
|
+
try {
|
|
1196
|
+
process.kill(pid, "SIGTERM");
|
|
1197
|
+
} catch {}
|
|
1521
1198
|
}
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
const shutdown = () => {
|
|
1526
|
-
if (isShuttingDown) return;
|
|
1527
|
-
isShuttingDown = true;
|
|
1528
|
-
logger$11.log("\n🛑 Shutting down...");
|
|
1529
|
-
Promise.all([watcher.close(), devServer.stop()]).catch((err) => {
|
|
1530
|
-
logger$11.error("Error during shutdown:", err);
|
|
1531
|
-
}).finally(() => {
|
|
1532
|
-
process.exit(0);
|
|
1533
|
-
});
|
|
1534
|
-
};
|
|
1535
|
-
process.on("SIGINT", shutdown);
|
|
1536
|
-
process.on("SIGTERM", shutdown);
|
|
1537
|
-
}
|
|
1538
|
-
/**
|
|
1539
|
-
* Generate all dependency environment variables for all apps.
|
|
1540
|
-
* Returns a flat object with all {APP_NAME}_URL variables.
|
|
1541
|
-
* @internal Exported for testing
|
|
1542
|
-
*/
|
|
1543
|
-
function generateAllDependencyEnvVars(workspace, urlPrefix = "http://localhost") {
|
|
1544
|
-
const env = {};
|
|
1545
|
-
for (const appName of Object.keys(workspace.apps)) {
|
|
1546
|
-
const appEnv = require_workspace.getDependencyEnvVars(workspace, appName, urlPrefix);
|
|
1547
|
-
Object.assign(env, appEnv);
|
|
1199
|
+
this.childProcess = null;
|
|
1200
|
+
this.isRunning = false;
|
|
1201
|
+
}
|
|
1548
1202
|
}
|
|
1549
|
-
|
|
1550
|
-
}
|
|
1203
|
+
};
|
|
1551
1204
|
/**
|
|
1552
|
-
*
|
|
1553
|
-
*
|
|
1205
|
+
* Generate the content of the dev server entry file (server.ts).
|
|
1206
|
+
* Uses dynamic import for createApp so Credentials are populated
|
|
1207
|
+
* before any app modules evaluate.
|
|
1554
1208
|
* @internal Exported for testing
|
|
1555
1209
|
*/
|
|
1556
|
-
function
|
|
1557
|
-
const
|
|
1558
|
-
const
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
else portToApp.set(app.port, appName);
|
|
1567
|
-
}
|
|
1568
|
-
return conflicts;
|
|
1569
|
-
}
|
|
1570
|
-
const FRONTEND_FRAMEWORKS = {
|
|
1571
|
-
nextjs: {
|
|
1572
|
-
displayName: "Next.js",
|
|
1573
|
-
configFiles: [
|
|
1574
|
-
"next.config.js",
|
|
1575
|
-
"next.config.ts",
|
|
1576
|
-
"next.config.mjs",
|
|
1577
|
-
"next.config.cjs"
|
|
1578
|
-
],
|
|
1579
|
-
dependency: "next",
|
|
1580
|
-
installCommand: "pnpm add next react react-dom"
|
|
1581
|
-
},
|
|
1582
|
-
remix: {
|
|
1583
|
-
displayName: "Remix",
|
|
1584
|
-
configFiles: [
|
|
1585
|
-
"remix.config.js",
|
|
1586
|
-
"remix.config.mjs",
|
|
1587
|
-
"remix.config.ts",
|
|
1588
|
-
"vite.config.js",
|
|
1589
|
-
"vite.config.ts",
|
|
1590
|
-
"vite.config.mjs",
|
|
1591
|
-
"vite.config.cjs"
|
|
1592
|
-
],
|
|
1593
|
-
dependency: "@remix-run/dev",
|
|
1594
|
-
installCommand: "pnpm add -D @remix-run/dev"
|
|
1595
|
-
},
|
|
1596
|
-
vite: {
|
|
1597
|
-
displayName: "Vite",
|
|
1598
|
-
configFiles: [
|
|
1599
|
-
"vite.config.js",
|
|
1600
|
-
"vite.config.ts",
|
|
1601
|
-
"vite.config.mjs",
|
|
1602
|
-
"vite.config.cjs"
|
|
1603
|
-
],
|
|
1604
|
-
dependency: "vite",
|
|
1605
|
-
installCommand: "pnpm add -D vite"
|
|
1606
|
-
},
|
|
1607
|
-
"tanstack-start": {
|
|
1608
|
-
displayName: "TanStack Start",
|
|
1609
|
-
configFiles: [
|
|
1610
|
-
"vite.config.js",
|
|
1611
|
-
"vite.config.ts",
|
|
1612
|
-
"vite.config.mjs",
|
|
1613
|
-
"vite.config.cjs"
|
|
1614
|
-
],
|
|
1615
|
-
dependency: "@tanstack/react-start",
|
|
1616
|
-
installCommand: "pnpm add @tanstack/react-start @tanstack/react-router"
|
|
1617
|
-
},
|
|
1618
|
-
expo: {
|
|
1619
|
-
displayName: "Expo",
|
|
1620
|
-
configFiles: [
|
|
1621
|
-
"app.config.ts",
|
|
1622
|
-
"app.config.js",
|
|
1623
|
-
"app.json"
|
|
1624
|
-
],
|
|
1625
|
-
dependency: "expo",
|
|
1626
|
-
installCommand: "pnpm add expo"
|
|
1627
|
-
}
|
|
1628
|
-
};
|
|
1629
|
-
/** Whether a configured framework is one this table knows how to start. */
|
|
1630
|
-
function isClientFramework(framework) {
|
|
1631
|
-
return framework !== void 0 && framework in FRONTEND_FRAMEWORKS;
|
|
1632
|
-
}
|
|
1633
|
-
/**
|
|
1634
|
-
* Auto-detect the frontend framework by scanning package.json deps and config
|
|
1635
|
-
* files. Dependency match wins over config file match because deps are more
|
|
1636
|
-
* distinctive (a Remix app has both vite.config.ts AND @remix-run/dev).
|
|
1637
|
-
*/
|
|
1638
|
-
function detectFrontendFramework(fullPath, deps) {
|
|
1639
|
-
const order = [
|
|
1640
|
-
"expo",
|
|
1641
|
-
"tanstack-start",
|
|
1642
|
-
"nextjs",
|
|
1643
|
-
"remix",
|
|
1644
|
-
"vite"
|
|
1645
|
-
];
|
|
1646
|
-
for (const name of order) if (deps[FRONTEND_FRAMEWORKS[name].dependency]) return name;
|
|
1647
|
-
for (const name of order) if (FRONTEND_FRAMEWORKS[name].configFiles.some((file) => (0, node_fs.existsSync)((0, node_path.join)(fullPath, file)))) return name;
|
|
1210
|
+
function generateServerEntryContent(options) {
|
|
1211
|
+
const { secretsJsonPath, runtime = "node", enableOpenApi = false, appImportPath = "./app.js", selfServing = false } = options;
|
|
1212
|
+
const credentialsInjection = secretsJsonPath ? `import { existsSync, readFileSync } from 'node:fs';
|
|
1213
|
+
|
|
1214
|
+
// Inject dev secrets via globalThis (must happen before app import)
|
|
1215
|
+
const secretsPath = '${secretsJsonPath}';
|
|
1216
|
+
if (existsSync(secretsPath)) {
|
|
1217
|
+
const __secrets = JSON.parse(readFileSync(secretsPath, 'utf-8'));
|
|
1218
|
+
globalThis.__gkm_credentials__ = __secrets;
|
|
1219
|
+
Object.assign(process.env, __secrets);
|
|
1648
1220
|
}
|
|
1221
|
+
|
|
1222
|
+
` : "";
|
|
1223
|
+
const serveCode = runtime === "bun" ? `Bun.serve({
|
|
1224
|
+
port,
|
|
1225
|
+
fetch: app.fetch,
|
|
1226
|
+
});` : `const { serve } = await import('@hono/node-server');
|
|
1227
|
+
const server = serve({
|
|
1228
|
+
fetch: app.fetch,
|
|
1229
|
+
port,
|
|
1230
|
+
});
|
|
1231
|
+
// Inject WebSocket support if available
|
|
1232
|
+
const injectWs = (app as any).__injectWebSocket;
|
|
1233
|
+
if (injectWs) {
|
|
1234
|
+
injectWs(server);
|
|
1235
|
+
console.log('🔌 Telescope real-time updates enabled');
|
|
1236
|
+
}`;
|
|
1237
|
+
if (selfServing) return `#!/usr/bin/env node
|
|
1649
1238
|
/**
|
|
1650
|
-
*
|
|
1651
|
-
*
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
errors.push(`package.json not found at ${appPath}. Run: pnpm init in the app directory.`);
|
|
1666
|
-
return {
|
|
1667
|
-
appName,
|
|
1668
|
-
valid: false,
|
|
1669
|
-
errors,
|
|
1670
|
-
warnings
|
|
1671
|
-
};
|
|
1672
|
-
}
|
|
1673
|
-
let pkg;
|
|
1674
|
-
try {
|
|
1675
|
-
pkg = require(packageJsonPath);
|
|
1676
|
-
} catch {
|
|
1677
|
-
errors.push(`Failed to read package.json at ${packageJsonPath}`);
|
|
1678
|
-
return {
|
|
1679
|
-
appName,
|
|
1680
|
-
valid: false,
|
|
1681
|
-
errors,
|
|
1682
|
-
warnings
|
|
1683
|
-
};
|
|
1684
|
-
}
|
|
1685
|
-
const deps = {
|
|
1686
|
-
...pkg.dependencies,
|
|
1687
|
-
...pkg.devDependencies
|
|
1688
|
-
};
|
|
1689
|
-
const resolvedFramework = framework ?? detectFrontendFramework(fullPath, deps);
|
|
1690
|
-
if (resolvedFramework) {
|
|
1691
|
-
const spec = FRONTEND_FRAMEWORKS[resolvedFramework];
|
|
1692
|
-
if (!spec.configFiles.some((file) => (0, node_fs.existsSync)((0, node_path.join)(fullPath, file)))) errors.push(`${spec.displayName} config file not found. Expected one of: ${spec.configFiles.join(", ")}`);
|
|
1693
|
-
if (!deps[spec.dependency]) errors.push(`${spec.displayName} not found in dependencies. Run: ${spec.installCommand}`);
|
|
1694
|
-
} else warnings.push("No recognized frontend framework detected (Next.js, Vite, Remix). The app will run via its \"dev\" script. Set `framework` in your app config to enable strict validation.");
|
|
1695
|
-
if (!pkg.scripts?.dev) {
|
|
1696
|
-
if (resolvedFramework) warnings.push("No \"dev\" script found in package.json. Turbo expects a \"dev\" script to run.");
|
|
1697
|
-
else errors.push("No \"dev\" script found in package.json. Without a recognized framework or a dev script, there is nothing to run.");
|
|
1698
|
-
}
|
|
1699
|
-
return {
|
|
1700
|
-
appName,
|
|
1701
|
-
valid: errors.length === 0,
|
|
1702
|
-
errors,
|
|
1703
|
-
warnings
|
|
1704
|
-
};
|
|
1705
|
-
}
|
|
1239
|
+
* Development server entry point for a surface that serves itself
|
|
1240
|
+
* This file is auto-generated by 'gkm dev'
|
|
1241
|
+
*/
|
|
1242
|
+
${credentialsInjection}
|
|
1243
|
+
const port = process.argv.includes('--port')
|
|
1244
|
+
? Number.parseInt(process.argv[process.argv.indexOf('--port') + 1])
|
|
1245
|
+
: 3000;
|
|
1246
|
+
|
|
1247
|
+
// Dynamic import so Credentials are populated before the surface evaluates
|
|
1248
|
+
const { app } = await import('${appImportPath}');
|
|
1249
|
+
|
|
1250
|
+
${serveCode.replace(/^ {4}/gm, "")}
|
|
1251
|
+
console.log(\`Server started on port \${port}\`);
|
|
1252
|
+
`;
|
|
1253
|
+
return `#!/usr/bin/env node
|
|
1706
1254
|
/**
|
|
1707
|
-
*
|
|
1708
|
-
*
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1255
|
+
* Development server entry point
|
|
1256
|
+
* This file is auto-generated by 'gkm dev'
|
|
1257
|
+
*/
|
|
1258
|
+
${credentialsInjection}
|
|
1259
|
+
const port = process.argv.includes('--port')
|
|
1260
|
+
? Number.parseInt(process.argv[process.argv.indexOf('--port') + 1])
|
|
1261
|
+
: 3000;
|
|
1262
|
+
|
|
1263
|
+
// Dynamic import so Credentials are populated before env.ts evaluates
|
|
1264
|
+
const { createApp } = await import('${appImportPath}');
|
|
1265
|
+
|
|
1266
|
+
// createApp is async to support optional WebSocket setup
|
|
1267
|
+
const { app, start } = await createApp(undefined, ${enableOpenApi});
|
|
1268
|
+
|
|
1269
|
+
// Start the server
|
|
1270
|
+
start({
|
|
1271
|
+
port,
|
|
1272
|
+
serve: async (app, port) => {
|
|
1273
|
+
${serveCode}
|
|
1274
|
+
},
|
|
1275
|
+
}).catch((error) => {
|
|
1276
|
+
console.error('Failed to start server:', error);
|
|
1277
|
+
process.exit(1);
|
|
1278
|
+
});
|
|
1279
|
+
`;
|
|
1718
1280
|
}
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1281
|
+
var DevServer = class {
|
|
1282
|
+
provider;
|
|
1283
|
+
requestedPort;
|
|
1284
|
+
portExplicit;
|
|
1285
|
+
enableOpenApi;
|
|
1286
|
+
telescope;
|
|
1287
|
+
studio;
|
|
1288
|
+
runtime;
|
|
1289
|
+
appRoot;
|
|
1290
|
+
secretsJsonPath;
|
|
1291
|
+
selfServing;
|
|
1292
|
+
serverProcess = null;
|
|
1293
|
+
isRunning = false;
|
|
1294
|
+
actualPort;
|
|
1295
|
+
startTime = Date.now();
|
|
1296
|
+
constructor(provider, requestedPort, portExplicit, enableOpenApi, telescope, studio, runtime = "node", appRoot = process.cwd(), secretsJsonPath, selfServing = false) {
|
|
1297
|
+
this.provider = provider;
|
|
1298
|
+
this.requestedPort = requestedPort;
|
|
1299
|
+
this.portExplicit = portExplicit;
|
|
1300
|
+
this.enableOpenApi = enableOpenApi;
|
|
1301
|
+
this.telescope = telescope;
|
|
1302
|
+
this.studio = studio;
|
|
1303
|
+
this.runtime = runtime;
|
|
1304
|
+
this.appRoot = appRoot;
|
|
1305
|
+
this.secretsJsonPath = secretsJsonPath;
|
|
1306
|
+
this.selfServing = selfServing;
|
|
1307
|
+
this.actualPort = requestedPort;
|
|
1733
1308
|
}
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
* 3. Generate dependency URLs ({APP_NAME}_URL)
|
|
1744
|
-
* 4. Spawn turbo run dev with injected env vars
|
|
1745
|
-
*/
|
|
1746
|
-
async function workspaceDevCommand(workspace, options) {
|
|
1747
|
-
const appCount = Object.keys(workspace.apps).length;
|
|
1748
|
-
const backendApps = Object.entries(workspace.apps).filter(([_, app]) => app.type === "backend");
|
|
1749
|
-
const frontendApps = Object.entries(workspace.apps).filter(([_, app]) => app.type === "web");
|
|
1750
|
-
const mobileApps = Object.entries(workspace.apps).filter(([_, app]) => app.type === "mobile");
|
|
1751
|
-
logger$11.log(`\n🚀 Starting workspace: ${workspace.name}`);
|
|
1752
|
-
const counts = [`${backendApps.length} backend`, `${frontendApps.length} web`];
|
|
1753
|
-
if (mobileApps.length > 0) counts.push(`${mobileApps.length} mobile`);
|
|
1754
|
-
logger$11.log(` ${counts.join(", ")} app(s)`);
|
|
1755
|
-
const conflicts = checkPortConflicts(workspace);
|
|
1756
|
-
if (conflicts.length > 0) {
|
|
1757
|
-
for (const conflict of conflicts) logger$11.error(`❌ Port conflict: Apps "${conflict.app1}" and "${conflict.app2}" both use port ${conflict.port}`);
|
|
1758
|
-
throw new Error("Port conflicts detected. Please assign unique ports to each app.");
|
|
1759
|
-
}
|
|
1760
|
-
if (frontendApps.length > 0) {
|
|
1761
|
-
logger$11.log("\n🔍 Validating frontend apps...");
|
|
1762
|
-
const validationResults = await validateFrontendApps(workspace);
|
|
1763
|
-
let hasErrors = false;
|
|
1764
|
-
for (const result of validationResults) {
|
|
1765
|
-
if (!result.valid) {
|
|
1766
|
-
hasErrors = true;
|
|
1767
|
-
logger$11.error(`\n❌ Frontend app "${result.appName}" validation failed:`);
|
|
1768
|
-
for (const error of result.errors) logger$11.error(` • ${error}`);
|
|
1769
|
-
}
|
|
1770
|
-
for (const warning of result.warnings) logger$11.warn(` ⚠️ ${result.appName}: ${warning}`);
|
|
1771
|
-
}
|
|
1772
|
-
if (hasErrors) throw new Error("Frontend app validation failed. Fix the issues above and try again.");
|
|
1773
|
-
logger$11.log("✅ Frontend apps validated");
|
|
1774
|
-
}
|
|
1775
|
-
const rawSecrets = await loadDevSecrets(workspace);
|
|
1776
|
-
let secretsEnv;
|
|
1777
|
-
if (require_credentials.usesConstructs(workspace)) {
|
|
1778
|
-
const reconciled = await require_credentials.reconcileWorkspace(workspace, { stage: workspace.stages.local });
|
|
1779
|
-
if (reconciled.changed && reconciled.plan.containers.length > 0) {
|
|
1780
|
-
logger$11.log(`🐳 Services: ${reconciled.plan.containers.join(", ")}`);
|
|
1781
|
-
for (const [container, address] of Object.entries(reconciled.addresses)) logger$11.log(` ${container}: ${address}`);
|
|
1782
|
-
}
|
|
1783
|
-
secretsEnv = {
|
|
1784
|
-
...rawSecrets,
|
|
1785
|
-
...reconciled.env
|
|
1786
|
-
};
|
|
1787
|
-
} else {
|
|
1788
|
-
const resolvedPorts = await require_credentials.resolveServicePorts(workspace.root);
|
|
1789
|
-
await require_credentials.startWorkspaceServices(workspace, resolvedPorts.dockerEnv, rawSecrets);
|
|
1790
|
-
secretsEnv = require_credentials.rewriteUrlsWithPorts(rawSecrets, resolvedPorts);
|
|
1791
|
-
}
|
|
1792
|
-
if (Object.keys(secretsEnv).length > 0) logger$11.log(` Loaded ${Object.keys(secretsEnv).length} secret(s)`);
|
|
1793
|
-
const dependencyEnv = generateAllDependencyEnvVars(workspace);
|
|
1794
|
-
if (Object.keys(dependencyEnv).length > 0) {
|
|
1795
|
-
logger$11.log("📡 Dependency URLs:");
|
|
1796
|
-
for (const [key, value] of Object.entries(dependencyEnv)) logger$11.log(` ${key}=${value}`);
|
|
1797
|
-
}
|
|
1798
|
-
let turboFilter = [];
|
|
1799
|
-
if (options.app) {
|
|
1800
|
-
if (!workspace.apps[options.app]) {
|
|
1801
|
-
const appNames = Object.keys(workspace.apps).join(", ");
|
|
1802
|
-
throw new Error(`App "${options.app}" not found. Available apps: ${appNames}`);
|
|
1309
|
+
async start() {
|
|
1310
|
+
this.startTime = Date.now();
|
|
1311
|
+
if (this.isRunning) await this.stop();
|
|
1312
|
+
if (this.portExplicit) {
|
|
1313
|
+
if (!await require_credentials.isPortAvailable(this.requestedPort)) throw new Error(`Port ${this.requestedPort} is already in use. Either stop the process using that port or omit -p/--port to auto-select an available port.`);
|
|
1314
|
+
this.actualPort = this.requestedPort;
|
|
1315
|
+
} else {
|
|
1316
|
+
this.actualPort = await require_credentials.findAvailablePort(this.requestedPort);
|
|
1317
|
+
if (this.actualPort !== this.requestedPort) logger$11.log(`ℹ️ Port ${this.requestedPort} was in use, using port ${this.actualPort} instead`);
|
|
1803
1318
|
}
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1319
|
+
const serverEntryPath = (0, node_path.join)(this.appRoot, ".gkm", this.provider, "server.ts");
|
|
1320
|
+
await this.createServerEntry();
|
|
1321
|
+
logger$11.log(`\n⏳ Starting server...`);
|
|
1322
|
+
this.serverProcess = (0, node_child_process.spawn)("npx", [
|
|
1323
|
+
"tsx",
|
|
1324
|
+
serverEntryPath,
|
|
1325
|
+
"--port",
|
|
1326
|
+
this.actualPort.toString()
|
|
1327
|
+
], {
|
|
1328
|
+
stdio: "inherit",
|
|
1329
|
+
env: {
|
|
1330
|
+
...process.env,
|
|
1331
|
+
NODE_ENV: "development"
|
|
1332
|
+
},
|
|
1333
|
+
detached: true
|
|
1334
|
+
});
|
|
1335
|
+
this.isRunning = true;
|
|
1336
|
+
this.serverProcess.on("error", (error) => {
|
|
1337
|
+
logger$11.error("❌ Server error:", error);
|
|
1338
|
+
});
|
|
1339
|
+
this.serverProcess.on("exit", (code, signal) => {
|
|
1340
|
+
if (code !== null && code !== 0 && signal !== "SIGTERM") logger$11.error(`❌ Server exited with code ${code}`);
|
|
1341
|
+
this.isRunning = false;
|
|
1342
|
+
});
|
|
1343
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
1344
|
+
if (this.isRunning) {
|
|
1345
|
+
const base = `http://localhost:${this.actualPort}`;
|
|
1346
|
+
const lines = [` Local: ${base}`];
|
|
1347
|
+
if (this.enableOpenApi) lines.push(` API Docs: ${base}/__docs`);
|
|
1348
|
+
if (this.telescope) lines.push(` Telescope: ${base}${this.telescope.path}`);
|
|
1349
|
+
if (this.studio) lines.push(` Studio: ${base}${this.studio.path}`);
|
|
1350
|
+
const maxLen = Math.max(...lines.map((l) => l.length));
|
|
1351
|
+
const pad = (s) => s.padEnd(maxLen);
|
|
1352
|
+
const border = "─".repeat(maxLen + 2);
|
|
1353
|
+
logger$11.log("");
|
|
1354
|
+
logger$11.log(` \x1b[32m✓ Ready\x1b[0m in ${((Date.now() - this.startTime) / 1e3).toFixed(1)}s`);
|
|
1355
|
+
logger$11.log("");
|
|
1356
|
+
logger$11.log(` ┌${border}┐`);
|
|
1357
|
+
for (const line of lines) logger$11.log(` │ ${pad(line)} │`);
|
|
1358
|
+
logger$11.log(` └${border}┘`);
|
|
1359
|
+
logger$11.log("");
|
|
1830
1360
|
}
|
|
1831
1361
|
}
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
NODE_ENV: "development",
|
|
1837
|
-
...configPath ? { GKM_CONFIG_PATH: configPath } : {}
|
|
1838
|
-
};
|
|
1839
|
-
logger$11.log("\n🏃 Starting turbo run dev...\n");
|
|
1840
|
-
const turboProcess = (0, node_child_process.spawn)("pnpm", [
|
|
1841
|
-
"turbo",
|
|
1842
|
-
"run",
|
|
1843
|
-
"dev",
|
|
1844
|
-
...turboFilter
|
|
1845
|
-
], {
|
|
1846
|
-
cwd: workspace.root,
|
|
1847
|
-
stdio: "inherit",
|
|
1848
|
-
env: turboEnv,
|
|
1849
|
-
detached: true
|
|
1850
|
-
});
|
|
1851
|
-
let isShuttingDown = false;
|
|
1852
|
-
const shutdown = () => {
|
|
1853
|
-
if (isShuttingDown) return;
|
|
1854
|
-
isShuttingDown = true;
|
|
1855
|
-
logger$11.log("\n🛑 Shutting down workspace...");
|
|
1856
|
-
const pid = turboProcess.pid;
|
|
1857
|
-
if (pid) try {
|
|
1858
|
-
process.kill(-pid, "SIGTERM");
|
|
1859
|
-
} catch {
|
|
1860
|
-
try {
|
|
1861
|
-
process.kill(pid, "SIGTERM");
|
|
1862
|
-
} catch {}
|
|
1863
|
-
}
|
|
1864
|
-
setTimeout(() => {
|
|
1362
|
+
async stop() {
|
|
1363
|
+
const port = this.actualPort;
|
|
1364
|
+
if (this.serverProcess && this.isRunning) {
|
|
1365
|
+
const pid = this.serverProcess.pid;
|
|
1865
1366
|
if (pid) try {
|
|
1866
1367
|
process.kill(-pid, "SIGKILL");
|
|
1867
1368
|
} catch {
|
|
@@ -1869,386 +1370,803 @@ async function workspaceDevCommand(workspace, options) {
|
|
|
1869
1370
|
process.kill(pid, "SIGKILL");
|
|
1870
1371
|
} catch {}
|
|
1871
1372
|
}
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
process.on("SIGTERM", shutdown);
|
|
1877
|
-
return new Promise((resolve, reject) => {
|
|
1878
|
-
turboProcess.on("error", (error) => {
|
|
1879
|
-
logger$11.error("❌ Turbo error:", error);
|
|
1880
|
-
reject(error);
|
|
1881
|
-
});
|
|
1882
|
-
turboProcess.on("exit", (code) => {
|
|
1883
|
-
if (code !== null && code !== 0) reject(/* @__PURE__ */ new Error(`Turbo exited with code ${code}`));
|
|
1884
|
-
else resolve();
|
|
1885
|
-
});
|
|
1886
|
-
});
|
|
1887
|
-
}
|
|
1888
|
-
/**
|
|
1889
|
-
* What the app declares, or nothing when it declares in the old way.
|
|
1890
|
-
*
|
|
1891
|
-
* Discovery imports the application's construct modules, which is why this is
|
|
1892
|
-
* guarded rather than unconditional: a project with no `constructs` glob has
|
|
1893
|
-
* not adopted the model, and there is nothing to import.
|
|
1894
|
-
*/
|
|
1895
|
-
async function declaredConstructs(config, appRoot, sources) {
|
|
1896
|
-
const patterns = typeof config?.constructs === "string" || Array.isArray(config?.constructs) ? config.constructs : void 0;
|
|
1897
|
-
if (!patterns) return {};
|
|
1898
|
-
try {
|
|
1899
|
-
return await require_discover.discover({
|
|
1900
|
-
patterns: (Array.isArray(patterns) ? patterns : [patterns]).map((p) => (0, node_path.isAbsolute)(p) ? p : (0, node_path.join)(appRoot, p)),
|
|
1901
|
-
cwd: appRoot,
|
|
1902
|
-
sources
|
|
1903
|
-
});
|
|
1904
|
-
} catch {
|
|
1905
|
-
return {};
|
|
1373
|
+
this.serverProcess = null;
|
|
1374
|
+
this.isRunning = false;
|
|
1375
|
+
}
|
|
1376
|
+
this.killProcessesOnPort(port);
|
|
1906
1377
|
}
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1378
|
+
killProcessesOnPort(port) {
|
|
1379
|
+
try {
|
|
1380
|
+
(0, node_child_process.execSync)(`lsof -ti tcp:${port} -sTCP:LISTEN | xargs kill -9 2>/dev/null || true`, { stdio: "ignore" });
|
|
1381
|
+
} catch {}
|
|
1382
|
+
}
|
|
1383
|
+
async restart() {
|
|
1384
|
+
const portToReuse = this.actualPort;
|
|
1385
|
+
await this.stop();
|
|
1386
|
+
let attempts = 0;
|
|
1387
|
+
while (attempts < 30) {
|
|
1388
|
+
if (await require_credentials.isPortAvailable(portToReuse)) break;
|
|
1389
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1390
|
+
attempts++;
|
|
1391
|
+
}
|
|
1392
|
+
this.requestedPort = portToReuse;
|
|
1393
|
+
await this.start();
|
|
1394
|
+
}
|
|
1395
|
+
async createServerEntry() {
|
|
1396
|
+
const { writeFile: fsWriteFile } = await import("node:fs/promises");
|
|
1397
|
+
await fsWriteFile((0, node_path.join)(this.appRoot, ".gkm", this.provider, "server.ts"), generateServerEntryContent({
|
|
1398
|
+
secretsJsonPath: this.secretsJsonPath,
|
|
1399
|
+
runtime: this.runtime,
|
|
1400
|
+
enableOpenApi: this.enableOpenApi,
|
|
1401
|
+
selfServing: this.selfServing
|
|
1402
|
+
}));
|
|
1403
|
+
}
|
|
1404
|
+
};
|
|
1405
|
+
//#endregion
|
|
1406
|
+
//#region src/generators/CronGenerator.ts
|
|
1407
|
+
var CronGenerator = class extends require_discover.ConstructGenerator {
|
|
1408
|
+
async build(context, constructs, outputDir, options) {
|
|
1409
|
+
const provider = options?.provider || "aws-lambda";
|
|
1410
|
+
const logger = console;
|
|
1411
|
+
const cronInfos = [];
|
|
1412
|
+
if (provider === "server") {
|
|
1413
|
+
await this.generateServerCronsFile(outputDir, constructs);
|
|
1414
|
+
logger.log(`Generated server crons file with ${constructs.length} crons`);
|
|
1415
|
+
return cronInfos;
|
|
1416
|
+
}
|
|
1417
|
+
if (constructs.length === 0 || provider !== "aws-lambda") return cronInfos;
|
|
1418
|
+
const cronsDir = (0, node_path.join)(outputDir, "crons");
|
|
1419
|
+
await (0, node_fs_promises.mkdir)(cronsDir, { recursive: true });
|
|
1420
|
+
for (const { key, construct, path } of constructs) {
|
|
1421
|
+
const handlerFile = await this.generateCronHandler(cronsDir, path.relative, key, context, construct.owner);
|
|
1422
|
+
cronInfos.push({
|
|
1423
|
+
name: key,
|
|
1424
|
+
handler: (0, node_path.relative)(process.cwd(), handlerFile).replace(/\.ts$/, ".handler"),
|
|
1425
|
+
schedule: construct.schedule || "rate(1 hour)",
|
|
1426
|
+
timeout: construct.timeout,
|
|
1427
|
+
memorySize: construct.memorySize,
|
|
1428
|
+
environment: await construct.getEnvironment({ markOptional: context.markOptional }),
|
|
1429
|
+
dependencies: construct.constructs
|
|
1430
|
+
});
|
|
1431
|
+
logger.log(`Generated cron handler: ${key}`);
|
|
1432
|
+
}
|
|
1433
|
+
return cronInfos;
|
|
1434
|
+
}
|
|
1435
|
+
isConstruct(value) {
|
|
1436
|
+
return _geekmidas_constructs_crons.Cron.isCron(value);
|
|
1437
|
+
}
|
|
1438
|
+
/**
|
|
1439
|
+
* The crons file a server entry imports.
|
|
1440
|
+
*
|
|
1441
|
+
* Shaped after `generateServerSubscribersFile`: one module, every cron, a
|
|
1442
|
+
* `setupCrons` the generated entry calls beside `setupSubscribers`.
|
|
1443
|
+
*
|
|
1444
|
+
* It picks its scheduler from what the process can reach. With a Postgres it
|
|
1445
|
+
* uses pg-boss, whose schedules live in the database — so a deployment
|
|
1446
|
+
* running four replicas fires each job once, which is the thing a timer in
|
|
1447
|
+
* every process gets wrong and does not report. With no database it falls
|
|
1448
|
+
* back to timers and says plainly that it is doing so.
|
|
1449
|
+
*/
|
|
1450
|
+
async generateServerCronsFile(outputDir, crons) {
|
|
1451
|
+
await (0, node_fs_promises.mkdir)(outputDir, { recursive: true });
|
|
1452
|
+
const cronsPath = (0, node_path.join)(outputDir, "crons.ts");
|
|
1453
|
+
const importsByFile = /* @__PURE__ */ new Map();
|
|
1454
|
+
for (const { path, key } of crons) {
|
|
1455
|
+
const importPath = (0, node_path.relative)((0, node_path.dirname)(cronsPath), path.relative).replace(/\.ts$/, ".js");
|
|
1456
|
+
if (!importsByFile.has(importPath)) importsByFile.set(importPath, []);
|
|
1457
|
+
importsByFile.get(importPath)?.push(key);
|
|
1458
|
+
}
|
|
1459
|
+
const content = `/**
|
|
1460
|
+
* Generated cron setup.
|
|
1461
|
+
*
|
|
1462
|
+
* Schedules every cron this app declares, in the process that serves it.
|
|
1463
|
+
*
|
|
1464
|
+
* The schedule lives in Postgres, through pg-boss, in the database the worker
|
|
1465
|
+
* named with \`.database(db)\`. That is what makes running several replicas
|
|
1466
|
+
* safe: each job fires once, where a timer in every process fires it once per
|
|
1467
|
+
* replica and reports nothing.
|
|
1468
|
+
*
|
|
1469
|
+
* No connection string appears here. The construct that owns the database is
|
|
1470
|
+
* the only thing that knows its key, and it is resolved through service
|
|
1471
|
+
* discovery like any other dependency.
|
|
1472
|
+
*/
|
|
1473
|
+
import type { EnvironmentParser } from '@geekmidas/envkit';
|
|
1474
|
+
import type { Logger } from '@geekmidas/logger';
|
|
1475
|
+
import { toCronExpression } from '@geekmidas/constructs/crons';
|
|
1476
|
+
import { ServiceDiscovery } from '@geekmidas/services';
|
|
1477
|
+
${Array.from(importsByFile.entries()).map(([importPath, exports]) => `import { ${exports.join(", ")} } from '${importPath}';`).join("\n")}
|
|
1478
|
+
|
|
1479
|
+
const crons = [
|
|
1480
|
+
${crons.map(({ key }) => ` { name: '${key}', cron: ${key} }`).join(",\n")}
|
|
1481
|
+
];
|
|
1482
|
+
|
|
1483
|
+
type Stoppable = { stop: () => Promise<void> | void };
|
|
1484
|
+
const running: Stoppable[] = [];
|
|
1485
|
+
|
|
1486
|
+
export async function setupCrons(
|
|
1487
|
+
envParser: EnvironmentParser<any>,
|
|
1488
|
+
logger: Logger,
|
|
1489
|
+
): Promise<void> {
|
|
1490
|
+
if (crons.length === 0) {
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
const serviceDiscovery = ServiceDiscovery.getInstance(envParser);
|
|
1495
|
+
|
|
1496
|
+
// Resolved once per cron rather than per firing: registering services on
|
|
1497
|
+
// every tick would reconnect a database every minute.
|
|
1498
|
+
const prepared = [];
|
|
1499
|
+
for (const { name, cron } of crons) {
|
|
1500
|
+
try {
|
|
1501
|
+
const schedule = toCronExpression(cron.schedule);
|
|
1502
|
+
const services = cron.services.length > 0
|
|
1503
|
+
? await serviceDiscovery.register(cron.services)
|
|
1504
|
+
: {};
|
|
1505
|
+
|
|
1506
|
+
prepared.push({ name, cron, schedule, services });
|
|
1507
|
+
} catch (error) {
|
|
1508
|
+
// An unrepresentable schedule is a build-time mistake, and failing the
|
|
1509
|
+
// process for it would take the HTTP server down with it.
|
|
1510
|
+
logger.error({ error, cron: name }, 'Cron has no runnable schedule, skipping');
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
const run = async (entry: (typeof prepared)[number]) => {
|
|
1515
|
+
try {
|
|
1516
|
+
await entry.cron.handler({
|
|
1517
|
+
input: undefined,
|
|
1518
|
+
services: entry.services,
|
|
1519
|
+
logger: entry.cron.logger,
|
|
1520
|
+
});
|
|
1521
|
+
} catch (error) {
|
|
1522
|
+
logger.error({ error, cron: entry.name }, 'Cron failed');
|
|
1523
|
+
}
|
|
1524
|
+
};
|
|
1525
|
+
|
|
1526
|
+
// The app's one pg-boss: the events broker's, as the broker's role, in the
|
|
1527
|
+
// broker's schema. A server fires its own crons, and a process scheduling in
|
|
1528
|
+
// memory fires each job once per replica — so the schedule lives in
|
|
1529
|
+
// Postgres. It used to be a second pg-boss on the worker's database, as the
|
|
1530
|
+
// runtime role, which can neither create a schema nor use the broker's.
|
|
1531
|
+
const { url } = envParser
|
|
1532
|
+
.create((get) => ({
|
|
1533
|
+
url: get('EVENT_SUBSCRIBER_CONNECTION_STRING').string().optional(),
|
|
1534
|
+
}))
|
|
1535
|
+
.parse();
|
|
1536
|
+
|
|
1537
|
+
if (!url?.startsWith('pgboss://')) {
|
|
1538
|
+
logger.error(
|
|
1539
|
+
{ crons: prepared.map(({ name }) => name) },
|
|
1540
|
+
'These crons have nowhere to keep their schedule. On a server they are ' +
|
|
1541
|
+
'scheduled through the pg-boss events broker ' +
|
|
1542
|
+
'(EVENT_SUBSCRIBER_CONNECTION_STRING), and ' +
|
|
1543
|
+
(url ? 'the broker configured is not pg-boss.' : 'none is configured.'),
|
|
1544
|
+
);
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
const { PgBossConnection } = await import('@geekmidas/events/pgboss');
|
|
1549
|
+
const connection = await PgBossConnection.fromConnectionString(url);
|
|
1550
|
+
const boss = connection.instance!;
|
|
1551
|
+
|
|
1552
|
+
// What this app declares now. Anything else under the prefix belonged to a
|
|
1553
|
+
// cron since renamed or deleted, and would keep firing at a handler that has
|
|
1554
|
+
// gone.
|
|
1555
|
+
//
|
|
1556
|
+
// The prefix is \`cron.\` rather than \`cron:\`: pg-boss accepts
|
|
1557
|
+
// alphanumerics, underscores, hyphens, periods and slashes in a queue name,
|
|
1558
|
+
// and rejects a colon outright.
|
|
1559
|
+
const declared = new Set(prepared.map(({ name }) => \`cron.\${name}\`));
|
|
1560
|
+
for (const existing of await boss.getSchedules()) {
|
|
1561
|
+
if (existing.name.startsWith('cron.') && !declared.has(existing.name)) {
|
|
1562
|
+
await boss.unschedule(existing.name);
|
|
1563
|
+
logger.info({ cron: existing.name }, 'Removed a schedule nothing declares');
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
for (const entry of prepared) {
|
|
1568
|
+
const queue = \`cron.\${entry.name}\`;
|
|
1569
|
+
await boss.createQueue(queue);
|
|
1570
|
+
// UTC, so this agrees with the schedule a deploy target would have made.
|
|
1571
|
+
await boss.schedule(queue, entry.schedule, undefined, { tz: 'UTC' });
|
|
1572
|
+
await boss.work(queue, () => run(entry));
|
|
1573
|
+
|
|
1574
|
+
logger.info({ cron: entry.name, schedule: entry.schedule }, 'Cron scheduled');
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
running.push({ stop: () => connection.close() });
|
|
1578
|
+
logger.info({ count: prepared.length }, 'Crons scheduled');
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
export async function stopCrons(): Promise<void> {
|
|
1582
|
+
await Promise.all(running.map((r) => r.stop()));
|
|
1583
|
+
running.length = 0;
|
|
1584
|
+
}
|
|
1585
|
+
`;
|
|
1586
|
+
await (0, node_fs_promises.writeFile)(cronsPath, content);
|
|
1587
|
+
return cronsPath;
|
|
1588
|
+
}
|
|
1589
|
+
async generateCronHandler(outputDir, sourceFile, exportName, context, owner) {
|
|
1590
|
+
const handlerFileName = `${exportName}.ts`;
|
|
1591
|
+
const handlerPath = (0, node_path.join)(outputDir, handlerFileName);
|
|
1592
|
+
const importPath = (0, node_path.relative)((0, node_path.dirname)(handlerPath), sourceFile).replace(/\.ts$/, ".js");
|
|
1593
|
+
const runtime = require_openapi.runtimeFor(context, (0, node_path.dirname)(handlerPath), owner);
|
|
1594
|
+
const content = `import { AWSScheduledFunction } from '@geekmidas/constructs/aws';
|
|
1595
|
+
import { ${exportName} } from '${importPath}';
|
|
1596
|
+
${runtime.imports}
|
|
1597
|
+
${runtime.bindings}
|
|
1598
|
+
|
|
1599
|
+
const adapter = new AWSScheduledFunction(envParser, ${exportName});
|
|
1600
|
+
|
|
1601
|
+
export const handler = adapter.handler;
|
|
1602
|
+
`;
|
|
1603
|
+
await (0, node_fs_promises.writeFile)(handlerPath, content);
|
|
1604
|
+
return handlerPath;
|
|
1605
|
+
}
|
|
1606
|
+
};
|
|
1607
|
+
//#endregion
|
|
1608
|
+
//#region src/generators/drivers.ts
|
|
1609
|
+
/**
|
|
1610
|
+
* Driver registration for generated entry points.
|
|
1611
|
+
*
|
|
1612
|
+
* A construct declares that it needs object storage and is handed one URL; the
|
|
1613
|
+
* scheme in that URL picks the driver that builds the client. Which drivers
|
|
1614
|
+
* exist is therefore the *entry point's* decision, not the construct's and not
|
|
1615
|
+
* the application's — an app that never talks to S3 should never resolve the AWS
|
|
1616
|
+
* SDK, and application code that registers a driver has named a provider in the
|
|
1617
|
+
* one layer this design keeps provider-free.
|
|
1618
|
+
*
|
|
1619
|
+
* So the generated entries register: `gkm dev`'s server for local development,
|
|
1620
|
+
* and each Lambda handler for its own target. Registration is an explicit call
|
|
1621
|
+
* rather than an import side effect, because a side-effecting module is exactly
|
|
1622
|
+
* what a bundler is entitled to drop.
|
|
1623
|
+
*/
|
|
1624
|
+
/** The `s3://` driver, which serves MinIO locally and S3 deployed. */
|
|
1625
|
+
const S3 = {
|
|
1626
|
+
imports: `import { registerStorageDriver } from '@geekmidas/storage';\nimport { s3Driver } from '@geekmidas/storage/aws';`,
|
|
1627
|
+
setup: "registerStorageDriver(s3Driver);"
|
|
1628
|
+
};
|
|
1629
|
+
const NONE = {
|
|
1630
|
+
imports: "",
|
|
1631
|
+
setup: ""
|
|
1632
|
+
};
|
|
1633
|
+
/**
|
|
1634
|
+
* The cache driver for a backend — exactly one, never all three.
|
|
1635
|
+
*
|
|
1636
|
+
* Keyed off the *backend* rather than off a dependency, because unlike storage
|
|
1637
|
+
* the backend already says which driver is right: a project caching in Postgres
|
|
1638
|
+
* resolves `pg` and never `ioredis`, and one caching in Upstash resolves
|
|
1639
|
+
* neither. That is the whole reason the drivers live behind separate subpaths.
|
|
1640
|
+
*
|
|
1641
|
+
* Both HTTP schemes are registered for Upstash, because the local proxy speaks
|
|
1642
|
+
* `http://` and Upstash speaks `https://`, and an entry that registered one
|
|
1643
|
+
* would work in exactly one of the two places.
|
|
1644
|
+
*/
|
|
1645
|
+
const CACHE_DRIVERS = {
|
|
1646
|
+
upstash: {
|
|
1647
|
+
imports: `import { registerCacheDriver } from '@geekmidas/cache';\nimport { upstashCacheDriver, upstashInsecureCacheDriver } from '@geekmidas/cache/upstash';`,
|
|
1648
|
+
setup: "registerCacheDriver(upstashCacheDriver);\nregisterCacheDriver(upstashInsecureCacheDriver);"
|
|
1649
|
+
},
|
|
1650
|
+
elasticache: {
|
|
1651
|
+
imports: `import { registerCacheDriver } from '@geekmidas/cache';\nimport { redisCacheDriver, redissCacheDriver } from '@geekmidas/cache/redis';`,
|
|
1652
|
+
setup: "registerCacheDriver(redisCacheDriver);\nregisterCacheDriver(redissCacheDriver);"
|
|
1653
|
+
},
|
|
1654
|
+
db: {
|
|
1655
|
+
imports: `import { registerCacheDriver } from '@geekmidas/cache';\nimport { postgresCacheDriver } from '@geekmidas/cache/postgres';`,
|
|
1656
|
+
setup: "registerCacheDriver(postgresCacheDriver);"
|
|
1657
|
+
}
|
|
1658
|
+
};
|
|
1659
|
+
/**
|
|
1660
|
+
* Everything a generated entry should register: object storage, and the cache.
|
|
1661
|
+
*
|
|
1662
|
+
* Merged here rather than threaded separately, because a generated file has one
|
|
1663
|
+
* import block and one setup block whatever fills them.
|
|
1664
|
+
*/
|
|
1665
|
+
function driversFor(options) {
|
|
1666
|
+
const backends = options.cache === false || options.cache === void 0 ? [] : typeof options.cache === "string" ? [options.cache] : options.cache;
|
|
1667
|
+
const parts = [storageDriversFor(options.appRoot), ...[...new Set(backends)].map((backend) => CACHE_DRIVERS[backend] ?? CACHE_DRIVERS[require_discover.DEFAULT_CACHE.aws])].filter((part) => part.imports || part.setup);
|
|
1668
|
+
if (parts.length === 0) return NONE;
|
|
1669
|
+
return {
|
|
1670
|
+
imports: parts.map((part) => part.imports).join("\n"),
|
|
1671
|
+
setup: parts.map((part) => part.setup).join("\n")
|
|
1672
|
+
};
|
|
1673
|
+
}
|
|
1674
|
+
/**
|
|
1675
|
+
* Which cache backends an app's entry actually has to speak.
|
|
1676
|
+
*
|
|
1677
|
+
* The config answers this for a cache that named nowhere, and the *declaration*
|
|
1678
|
+
* answers it for one that named a database — `orders.cache('Sessions')` is a
|
|
1679
|
+
* statement about the application, so a deployment cannot move that cache and
|
|
1680
|
+
* the entry cannot register a driver for somewhere else.
|
|
1681
|
+
*
|
|
1682
|
+
* Reading only the config is how an entry ends up registering the Upstash
|
|
1683
|
+
* driver for a URL the target composed as `postgres://`, which fails at the
|
|
1684
|
+
* first request with `UnregisteredCacheScheme` and a stack that points at the
|
|
1685
|
+
* cache rather than at the config that disagreed.
|
|
1686
|
+
*/
|
|
1687
|
+
function cacheBackendsIn(manifest, configured) {
|
|
1688
|
+
const caches = Object.values(manifest).filter((d) => d.kind === "cache");
|
|
1689
|
+
return [...new Set(caches.map((c) => c.of ? "db" : configured))];
|
|
1690
|
+
}
|
|
1691
|
+
/**
|
|
1692
|
+
* The drivers an app's entry points should register.
|
|
1693
|
+
*
|
|
1694
|
+
* Keyed off the dependency rather than off a declared bucket, because the entry
|
|
1695
|
+
* is generated before discovery has run on the watcher's rebuild path — and an
|
|
1696
|
+
* app that installed `@geekmidas/storage` has already paid for the SDK it would
|
|
1697
|
+
* otherwise resolve lazily.
|
|
1698
|
+
*/
|
|
1699
|
+
function storageDriversFor(appRoot) {
|
|
1700
|
+
return dependsOnStorage(appRoot) ? S3 : NONE;
|
|
1701
|
+
}
|
|
1702
|
+
function dependsOnStorage(appRoot) {
|
|
1703
|
+
const manifest = (0, node_path.join)(appRoot, "package.json");
|
|
1704
|
+
if (!(0, node_fs.existsSync)(manifest)) return false;
|
|
1705
|
+
try {
|
|
1706
|
+
const pkg = JSON.parse((0, node_fs.readFileSync)(manifest, "utf8"));
|
|
1707
|
+
return Boolean(pkg.dependencies?.["@geekmidas/storage"] ?? pkg.devDependencies?.["@geekmidas/storage"]);
|
|
1708
|
+
} catch {
|
|
1709
|
+
return false;
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
//#endregion
|
|
1713
|
+
//#region src/generators/FunctionGenerator.ts
|
|
1714
|
+
var FunctionGenerator = class extends require_discover.ConstructGenerator {
|
|
1715
|
+
isConstruct(value) {
|
|
1716
|
+
return _geekmidas_constructs_functions.Function.isFunction(value);
|
|
1717
|
+
}
|
|
1718
|
+
async build(context, constructs, outputDir, options) {
|
|
1719
|
+
const provider = options?.provider || "aws-lambda";
|
|
1720
|
+
const logger = console;
|
|
1721
|
+
const functionInfos = [];
|
|
1722
|
+
if (constructs.length === 0 || provider !== "aws-lambda") return functionInfos;
|
|
1723
|
+
const functionsDir = (0, node_path.join)(outputDir, "functions");
|
|
1724
|
+
await (0, node_fs_promises.mkdir)(functionsDir, { recursive: true });
|
|
1725
|
+
for (const { key, construct, path } of constructs) {
|
|
1726
|
+
const handlerFile = await this.generateFunctionHandler(functionsDir, path.relative, key, context, construct.owner);
|
|
1727
|
+
functionInfos.push({
|
|
1728
|
+
name: key,
|
|
1729
|
+
handler: (0, node_path.relative)(process.cwd(), handlerFile).replace(/\.ts$/, ".handler"),
|
|
1730
|
+
timeout: construct.timeout,
|
|
1731
|
+
memorySize: construct.memorySize,
|
|
1732
|
+
environment: await construct.getEnvironment({ markOptional: context.markOptional }),
|
|
1733
|
+
dependencies: construct.constructs
|
|
1734
|
+
});
|
|
1735
|
+
logger.log(`Generated function handler: ${key}`);
|
|
1736
|
+
}
|
|
1737
|
+
return functionInfos;
|
|
1738
|
+
}
|
|
1739
|
+
async generateFunctionHandler(outputDir, sourceFile, exportName, context, owner) {
|
|
1740
|
+
const handlerFileName = `${exportName}.ts`;
|
|
1741
|
+
const handlerPath = (0, node_path.join)(outputDir, handlerFileName);
|
|
1742
|
+
const importPath = (0, node_path.relative)((0, node_path.dirname)(handlerPath), sourceFile).replace(/\.ts$/, ".js");
|
|
1743
|
+
const runtime = require_openapi.runtimeFor(context, (0, node_path.dirname)(handlerPath), owner);
|
|
1744
|
+
const content = `import { AWSLambdaFunction } from '@geekmidas/constructs/aws';
|
|
1745
|
+
import { ${exportName} } from '${importPath}';
|
|
1746
|
+
${runtime.imports}
|
|
1747
|
+
${runtime.bindings}
|
|
1748
|
+
|
|
1749
|
+
const adapter = new AWSLambdaFunction(envParser, ${exportName});
|
|
1750
|
+
|
|
1751
|
+
export const handler = adapter.handler;
|
|
1752
|
+
`;
|
|
1753
|
+
await (0, node_fs_promises.writeFile)(handlerPath, content);
|
|
1754
|
+
return handlerPath;
|
|
1755
|
+
}
|
|
1756
|
+
};
|
|
1757
|
+
//#endregion
|
|
1758
|
+
//#region src/generators/QueueGenerator.ts
|
|
1987
1759
|
/**
|
|
1988
|
-
*
|
|
1760
|
+
* Generates the runtime for `q` queue workers.
|
|
1761
|
+
*
|
|
1762
|
+
* - **server** (`gkm dev`): a single `queues.ts` exposing `setupQueues()` that
|
|
1763
|
+
* runs an in-process pg-boss poller alongside the Hono server — no SQS/Lambda.
|
|
1764
|
+
* Each queue subscribes by its **name** on the shared event connection.
|
|
1765
|
+
* - **aws-lambda**: one handler file per queue wrapping `AWSLambdaQueue`, backed
|
|
1766
|
+
* by an SQS event-source mapping.
|
|
1989
1767
|
*/
|
|
1990
|
-
var
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
watch;
|
|
1994
|
-
port;
|
|
1995
|
-
childProcess = null;
|
|
1996
|
-
watcher = null;
|
|
1997
|
-
isRunning = false;
|
|
1998
|
-
constructor(wrapperPath, entryPath, watch, port) {
|
|
1999
|
-
this.wrapperPath = wrapperPath;
|
|
2000
|
-
this.entryPath = entryPath;
|
|
2001
|
-
this.watch = watch;
|
|
2002
|
-
this.port = port;
|
|
1768
|
+
var QueueGenerator = class extends require_discover.ConstructGenerator {
|
|
1769
|
+
isConstruct(value) {
|
|
1770
|
+
return _geekmidas_constructs_queue.Queue.isQueue(value);
|
|
2003
1771
|
}
|
|
2004
|
-
async
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
1772
|
+
async build(context, constructs, outputDir, options) {
|
|
1773
|
+
const provider = options?.provider || "aws-lambda";
|
|
1774
|
+
const logger = console;
|
|
1775
|
+
const queueInfos = [];
|
|
1776
|
+
if (provider === "server") {
|
|
1777
|
+
await this.generateServerQueuesFile(outputDir, constructs);
|
|
1778
|
+
logger.log(`Generated server queues file with ${constructs.length} queues (polling mode)`);
|
|
1779
|
+
return queueInfos;
|
|
1780
|
+
}
|
|
1781
|
+
if (constructs.length === 0 || provider !== "aws-lambda") return queueInfos;
|
|
1782
|
+
const queuesDir = (0, node_path.join)(outputDir, "queues");
|
|
1783
|
+
await (0, node_fs_promises.mkdir)(queuesDir, { recursive: true });
|
|
1784
|
+
for (const { key, construct, path } of constructs) {
|
|
1785
|
+
const handlerFile = await this.generateQueueHandler(queuesDir, path.relative, key, context, construct.owner);
|
|
1786
|
+
queueInfos.push({
|
|
1787
|
+
name: construct.name,
|
|
1788
|
+
handler: (0, node_path.relative)(process.cwd(), handlerFile).replace(/\.ts$/, ".handler"),
|
|
1789
|
+
batchSize: construct.batchSize,
|
|
1790
|
+
fifo: construct.fifo,
|
|
1791
|
+
timeout: construct.timeout,
|
|
1792
|
+
environment: await construct.getEnvironment({ markOptional: context.markOptional }),
|
|
1793
|
+
dependencies: construct.constructs
|
|
2021
1794
|
});
|
|
2022
|
-
logger
|
|
1795
|
+
logger.log(`Generated queue handler: ${key}`);
|
|
1796
|
+
}
|
|
1797
|
+
return queueInfos;
|
|
1798
|
+
}
|
|
1799
|
+
async generateQueueHandler(outputDir, sourceFile, exportName, context, owner) {
|
|
1800
|
+
const handlerPath = (0, node_path.join)(outputDir, `${exportName}.ts`);
|
|
1801
|
+
const importPath = (0, node_path.relative)((0, node_path.dirname)(handlerPath), sourceFile).replace(/\.ts$/, ".js");
|
|
1802
|
+
const runtime = require_openapi.runtimeFor(context, (0, node_path.dirname)(handlerPath), owner);
|
|
1803
|
+
const content = `import { AWSLambdaQueue } from '@geekmidas/constructs/aws';
|
|
1804
|
+
import { ${exportName} } from '${importPath}';
|
|
1805
|
+
${runtime.imports}
|
|
1806
|
+
${runtime.bindings}
|
|
1807
|
+
|
|
1808
|
+
const adapter = new AWSLambdaQueue(envParser, ${exportName});
|
|
1809
|
+
|
|
1810
|
+
export const handler = adapter.handler;
|
|
1811
|
+
`;
|
|
1812
|
+
await (0, node_fs_promises.writeFile)(handlerPath, content);
|
|
1813
|
+
return handlerPath;
|
|
1814
|
+
}
|
|
1815
|
+
async generateServerQueuesFile(outputDir, queues) {
|
|
1816
|
+
await (0, node_fs_promises.mkdir)(outputDir, { recursive: true });
|
|
1817
|
+
const queuesPath = (0, node_path.join)(outputDir, "queues.ts");
|
|
1818
|
+
if (queues.length === 0) {
|
|
1819
|
+
await (0, node_fs_promises.writeFile)(queuesPath, `/**
|
|
1820
|
+
* Generated queues setup — this app declares none.
|
|
1821
|
+
*/
|
|
1822
|
+
import type { EnvironmentParser } from '@geekmidas/envkit';
|
|
1823
|
+
import type { Logger } from '@geekmidas/logger';
|
|
1824
|
+
|
|
1825
|
+
export async function setupQueues(
|
|
1826
|
+
_envParser: EnvironmentParser<any>,
|
|
1827
|
+
_logger: Logger,
|
|
1828
|
+
): Promise<void> {}
|
|
1829
|
+
`);
|
|
1830
|
+
return queuesPath;
|
|
1831
|
+
}
|
|
1832
|
+
const importsByFile = /* @__PURE__ */ new Map();
|
|
1833
|
+
for (const { path, key } of queues) {
|
|
1834
|
+
const importPath = (0, node_path.relative)((0, node_path.dirname)(queuesPath), path.relative).replace(/\.ts$/, ".js");
|
|
1835
|
+
if (!importsByFile.has(importPath)) importsByFile.set(importPath, []);
|
|
1836
|
+
importsByFile.get(importPath)?.push(key);
|
|
1837
|
+
}
|
|
1838
|
+
const content = `/**
|
|
1839
|
+
* Generated queue workers setup
|
|
1840
|
+
*
|
|
1841
|
+
* ⚠️ WARNING: This is for LOCAL DEVELOPMENT ONLY
|
|
1842
|
+
* This runs an in-process pg-boss poller alongside the HTTP server. Each queue
|
|
1843
|
+
* subscribes by its name on the shared event connection
|
|
1844
|
+
* (EVENT_SUBSCRIBER_CONNECTION_STRING), so producers using
|
|
1845
|
+
* <NAME>_PUBLISHER_CONNECTION_STRING (pgboss:// locally) reach it.
|
|
1846
|
+
*
|
|
1847
|
+
* For production, queues run as AWS Lambda with SQS event-source mappings.
|
|
1848
|
+
*/
|
|
1849
|
+
import type { EnvironmentParser } from '@geekmidas/envkit';
|
|
1850
|
+
import type { Logger } from '@geekmidas/logger';
|
|
1851
|
+
import { EventConnectionFactory, Subscriber } from '@geekmidas/events';
|
|
1852
|
+
import type { EventConnection, EventSubscriber } from '@geekmidas/events';
|
|
1853
|
+
import { ServiceDiscovery } from '@geekmidas/services';
|
|
1854
|
+
${Array.from(importsByFile.entries()).map(([importPath, exports]) => `import { ${exports.join(", ")} } from '${importPath}';`).join("\n")}
|
|
1855
|
+
|
|
1856
|
+
const queues = [
|
|
1857
|
+
${queues.map(({ key }) => key).join(",\n ")}
|
|
1858
|
+
];
|
|
1859
|
+
|
|
1860
|
+
const activeSubscribers: EventSubscriber<any>[] = [];
|
|
1861
|
+
|
|
1862
|
+
export async function setupQueues(
|
|
1863
|
+
envParser: EnvironmentParser<any>,
|
|
1864
|
+
logger: Logger,
|
|
1865
|
+
): Promise<void> {
|
|
1866
|
+
if (queues.length === 0) {
|
|
1867
|
+
return;
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
logger.info('Setting up queue workers in polling mode (local development)');
|
|
1871
|
+
|
|
1872
|
+
const config = envParser.create((get) => ({
|
|
1873
|
+
connectionString: get('EVENT_SUBSCRIBER_CONNECTION_STRING').string(),
|
|
1874
|
+
})).parse();
|
|
1875
|
+
|
|
1876
|
+
const serviceDiscovery = ServiceDiscovery.getInstance(envParser);
|
|
1877
|
+
|
|
1878
|
+
let connection: EventConnection;
|
|
1879
|
+
try {
|
|
1880
|
+
connection = await EventConnectionFactory.fromConnectionString(config.connectionString);
|
|
1881
|
+
const connectionType = new URL(config.connectionString).protocol.replace(':', '');
|
|
1882
|
+
logger.info({ connectionType }, 'Created shared event connection for queues');
|
|
1883
|
+
} catch (error) {
|
|
1884
|
+
logger.error({ error }, 'Failed to create event connection for queues');
|
|
1885
|
+
return;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
for (const queue of queues) {
|
|
1889
|
+
try {
|
|
1890
|
+
const eventSubscriber = await Subscriber.fromConnection(connection);
|
|
1891
|
+
|
|
1892
|
+
const services = queue.services.length > 0
|
|
1893
|
+
? await serviceDiscovery.register(queue.services)
|
|
1894
|
+
: {};
|
|
1895
|
+
|
|
1896
|
+
// A queue subscribes to a single "type" — its own name.
|
|
1897
|
+
await eventSubscriber.subscribe([queue.name], async (message) => {
|
|
1898
|
+
try {
|
|
1899
|
+
const validation = await queue.messageSchema['~standard'].validate(message.payload);
|
|
1900
|
+
if (validation.issues) {
|
|
1901
|
+
logger.error({ issues: validation.issues, queue: queue.name }, 'Queue message failed validation');
|
|
1902
|
+
return;
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
await queue.handler({
|
|
1906
|
+
messages: [validation.value],
|
|
1907
|
+
services: services,
|
|
1908
|
+
logger: queue.logger,
|
|
1909
|
+
});
|
|
1910
|
+
|
|
1911
|
+
logger.debug({ queue: queue.name }, 'Successfully processed queue message');
|
|
1912
|
+
} catch (error) {
|
|
1913
|
+
logger.error({ error, queue: queue.name }, 'Failed to process queue message');
|
|
1914
|
+
// Message will become visible again for retry.
|
|
1915
|
+
}
|
|
1916
|
+
});
|
|
1917
|
+
|
|
1918
|
+
activeSubscribers.push(eventSubscriber);
|
|
1919
|
+
logger.info({ queue: queue.name }, 'Queue worker started polling');
|
|
1920
|
+
} catch (error) {
|
|
1921
|
+
logger.error({ error, queue: queue.name }, 'Failed to setup queue worker');
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
const shutdown = () => {
|
|
1926
|
+
logger.info('Stopping all queue workers');
|
|
1927
|
+
for (const _ of activeSubscribers) {
|
|
1928
|
+
connection.stop();
|
|
1929
|
+
}
|
|
1930
|
+
};
|
|
1931
|
+
|
|
1932
|
+
process.on('SIGTERM', shutdown);
|
|
1933
|
+
process.on('SIGINT', shutdown);
|
|
1934
|
+
}
|
|
1935
|
+
`;
|
|
1936
|
+
await (0, node_fs_promises.writeFile)(queuesPath, content);
|
|
1937
|
+
return queuesPath;
|
|
1938
|
+
}
|
|
1939
|
+
};
|
|
1940
|
+
//#endregion
|
|
1941
|
+
//#region src/generators/SubscriberGenerator.ts
|
|
1942
|
+
var SubscriberGenerator = class extends require_discover.ConstructGenerator {
|
|
1943
|
+
isConstruct(value) {
|
|
1944
|
+
return _geekmidas_constructs_subscribers.Subscriber.isSubscriber(value);
|
|
1945
|
+
}
|
|
1946
|
+
async build(context, constructs, outputDir, options) {
|
|
1947
|
+
const provider = options?.provider || "aws-lambda";
|
|
1948
|
+
const logger = console;
|
|
1949
|
+
const subscriberInfos = [];
|
|
1950
|
+
if (provider === "server") {
|
|
1951
|
+
await this.generateServerSubscribersFile(outputDir, constructs);
|
|
1952
|
+
logger.log(`Generated server subscribers file with ${constructs.length} subscribers (polling mode)`);
|
|
1953
|
+
return subscriberInfos;
|
|
2023
1954
|
}
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
const
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
if (this.isRunning) {
|
|
2045
|
-
logger$11.log("");
|
|
2046
|
-
logger$11.log(` \x1b[32m✓ Ready\x1b[0m at \x1b[36mhttp://localhost:${this.port}\x1b[0m`);
|
|
2047
|
-
logger$11.log("");
|
|
1955
|
+
if (constructs.length === 0) return subscriberInfos;
|
|
1956
|
+
if (provider !== "aws-lambda") return subscriberInfos;
|
|
1957
|
+
const subscribersDir = (0, node_path.join)(outputDir, "subscribers");
|
|
1958
|
+
await (0, node_fs_promises.mkdir)(subscribersDir, { recursive: true });
|
|
1959
|
+
for (const { key, construct, path } of constructs) {
|
|
1960
|
+
const handlerFile = await this.generateSubscriberHandler(subscribersDir, path.relative, key, construct, context, construct.owner);
|
|
1961
|
+
subscriberInfos.push({
|
|
1962
|
+
name: key,
|
|
1963
|
+
handler: (0, node_path.relative)(process.cwd(), handlerFile).replace(/\.ts$/, ".handler"),
|
|
1964
|
+
subscribedEvents: construct.subscribedEvents || [],
|
|
1965
|
+
...construct.topicName ? {
|
|
1966
|
+
transport: "topic",
|
|
1967
|
+
topic: construct.topicName
|
|
1968
|
+
} : {},
|
|
1969
|
+
timeout: construct.timeout,
|
|
1970
|
+
memorySize: construct.memorySize,
|
|
1971
|
+
environment: await construct.getEnvironment({ markOptional: context.markOptional }),
|
|
1972
|
+
dependencies: construct.constructs
|
|
1973
|
+
});
|
|
1974
|
+
logger.log(`Generated subscriber handler: ${key}`);
|
|
2048
1975
|
}
|
|
1976
|
+
return subscriberInfos;
|
|
2049
1977
|
}
|
|
2050
|
-
async
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
1978
|
+
async generateSubscriberHandler(outputDir, sourceFile, exportName, _subscriber, context, owner) {
|
|
1979
|
+
const handlerFileName = `${exportName}.ts`;
|
|
1980
|
+
const handlerPath = (0, node_path.join)(outputDir, handlerFileName);
|
|
1981
|
+
const importPath = (0, node_path.relative)((0, node_path.dirname)(handlerPath), sourceFile).replace(/\.ts$/, ".js");
|
|
1982
|
+
const runtime = require_openapi.runtimeFor(context, (0, node_path.dirname)(handlerPath), owner);
|
|
1983
|
+
const content = `import { AWSLambdaSubscriber } from '@geekmidas/constructs/aws';
|
|
1984
|
+
import { ${exportName} } from '${importPath}';
|
|
1985
|
+
${runtime.imports}
|
|
1986
|
+
${runtime.bindings}
|
|
1987
|
+
|
|
1988
|
+
const adapter = new AWSLambdaSubscriber(envParser, ${exportName});
|
|
1989
|
+
|
|
1990
|
+
export const handler = adapter.handler;
|
|
1991
|
+
`;
|
|
1992
|
+
await (0, node_fs_promises.writeFile)(handlerPath, content);
|
|
1993
|
+
return handlerPath;
|
|
2058
1994
|
}
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
1995
|
+
async generateServerSubscribersFile(outputDir, subscribers) {
|
|
1996
|
+
await (0, node_fs_promises.mkdir)(outputDir, { recursive: true });
|
|
1997
|
+
const subscribersPath = (0, node_path.join)(outputDir, "subscribers.ts");
|
|
1998
|
+
if (subscribers.length === 0) {
|
|
1999
|
+
await (0, node_fs_promises.writeFile)(subscribersPath, `/**
|
|
2000
|
+
* Generated subscribers setup — this app declares none.
|
|
2001
|
+
*/
|
|
2002
|
+
import type { EnvironmentParser } from '@geekmidas/envkit';
|
|
2003
|
+
import type { Logger } from '@geekmidas/logger';
|
|
2004
|
+
|
|
2005
|
+
export async function setupSubscribers(
|
|
2006
|
+
_envParser: EnvironmentParser<any>,
|
|
2007
|
+
_logger: Logger,
|
|
2008
|
+
): Promise<void> {}
|
|
2009
|
+
`);
|
|
2010
|
+
return subscribersPath;
|
|
2071
2011
|
}
|
|
2072
|
-
|
|
2073
|
-
}
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
*
|
|
2085
|
-
*
|
|
2012
|
+
const importsByFile = /* @__PURE__ */ new Map();
|
|
2013
|
+
for (const { path, key } of subscribers) {
|
|
2014
|
+
const importPath = (0, node_path.relative)((0, node_path.dirname)(subscribersPath), path.relative).replace(/\.ts$/, ".js");
|
|
2015
|
+
if (!importsByFile.has(importPath)) importsByFile.set(importPath, []);
|
|
2016
|
+
importsByFile.get(importPath)?.push(key);
|
|
2017
|
+
}
|
|
2018
|
+
const content = `/**
|
|
2019
|
+
* Generated subscribers setup
|
|
2020
|
+
*
|
|
2021
|
+
* ⚠️ WARNING: This is for LOCAL DEVELOPMENT ONLY
|
|
2022
|
+
* This uses event polling which is not suitable for production.
|
|
2023
|
+
*
|
|
2024
|
+
* For production, use AWS Lambda with SQS/SNS event source mappings.
|
|
2025
|
+
* Lambda automatically:
|
|
2026
|
+
* - Scales based on queue depth
|
|
2027
|
+
* - Handles batch processing and retries
|
|
2028
|
+
* - Manages dead letter queues
|
|
2029
|
+
* - Provides better cost optimization
|
|
2030
|
+
*
|
|
2031
|
+
* This polling implementation is useful for:
|
|
2032
|
+
* - Local development and testing
|
|
2033
|
+
* - Understanding event flow without Lambda deployment
|
|
2034
|
+
*
|
|
2035
|
+
* Supported connection strings:
|
|
2036
|
+
* - sqs://region/account-id/queue-name (SQS queue)
|
|
2037
|
+
* - sns://region/account-id/topic-name (SNS topic)
|
|
2038
|
+
* - rabbitmq://host:port/queue-name (RabbitMQ)
|
|
2039
|
+
* - pgboss://user:pass@host:port/database (pg-boss / PostgreSQL)
|
|
2040
|
+
* - basic://in-memory (In-memory for testing)
|
|
2086
2041
|
*/
|
|
2087
|
-
|
|
2042
|
+
import type { EnvironmentParser } from '@geekmidas/envkit';
|
|
2043
|
+
import type { Logger } from '@geekmidas/logger';
|
|
2044
|
+
import { EventConnectionFactory, Subscriber } from '@geekmidas/events';
|
|
2045
|
+
import type { EventConnection, EventSubscriber } from '@geekmidas/events';
|
|
2046
|
+
import { ServiceDiscovery } from '@geekmidas/services';
|
|
2047
|
+
${Array.from(importsByFile.entries()).map(([importPath, exports]) => `import { ${exports.join(", ")} } from '${importPath}';`).join("\n")}
|
|
2088
2048
|
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
const __secrets = JSON.parse(readFileSync(secretsPath, 'utf-8'));
|
|
2093
|
-
globalThis.__gkm_credentials__ = __secrets;
|
|
2094
|
-
Object.assign(process.env, __secrets);
|
|
2095
|
-
}
|
|
2049
|
+
const subscribers = [
|
|
2050
|
+
${subscribers.map(({ key }) => key).join(",\n ")}
|
|
2051
|
+
];
|
|
2096
2052
|
|
|
2097
|
-
|
|
2098
|
-
const port = process.argv.includes('--port')
|
|
2099
|
-
? Number.parseInt(process.argv[process.argv.indexOf('--port') + 1])
|
|
2100
|
-
: 3000;
|
|
2053
|
+
const activeSubscribers: EventSubscriber<any>[] = [];
|
|
2101
2054
|
|
|
2102
|
-
|
|
2103
|
-
|
|
2055
|
+
export async function setupSubscribers(
|
|
2056
|
+
envParser: EnvironmentParser<any>,
|
|
2057
|
+
logger: Logger,
|
|
2058
|
+
): Promise<void> {
|
|
2059
|
+
if (subscribers.length === 0) {
|
|
2060
|
+
return;
|
|
2061
|
+
}
|
|
2104
2062
|
|
|
2105
|
-
|
|
2106
|
-
const { app, start } = await createApp(undefined, ${enableOpenApi});
|
|
2063
|
+
logger.info('Setting up subscribers in polling mode (local development)');
|
|
2107
2064
|
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2065
|
+
const config = envParser.create((get) => ({
|
|
2066
|
+
connectionString: get('EVENT_SUBSCRIBER_CONNECTION_STRING').string(),
|
|
2067
|
+
})).parse();
|
|
2068
|
+
|
|
2069
|
+
const serviceDiscovery = ServiceDiscovery.getInstance(envParser);
|
|
2070
|
+
|
|
2071
|
+
// Create connection once, outside the loop (more efficient)
|
|
2072
|
+
// EventConnectionFactory automatically determines the right connection type
|
|
2073
|
+
let connection: EventConnection;
|
|
2074
|
+
try {
|
|
2075
|
+
connection = await EventConnectionFactory.fromConnectionString(config.connectionString);
|
|
2076
|
+
|
|
2077
|
+
const connectionType = new URL(config.connectionString).protocol.replace(':', '');
|
|
2078
|
+
logger.info({ connectionType }, 'Created shared event connection');
|
|
2079
|
+
} catch (error) {
|
|
2080
|
+
logger.error({ error }, 'Failed to create event connection');
|
|
2081
|
+
return;
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
for (const subscriber of subscribers) {
|
|
2085
|
+
try {
|
|
2086
|
+
// Create subscriber from shared connection
|
|
2087
|
+
const eventSubscriber = await Subscriber.fromConnection(connection);
|
|
2088
|
+
|
|
2089
|
+
// Register services
|
|
2090
|
+
const services = subscriber.services.length > 0
|
|
2091
|
+
? await serviceDiscovery.register(subscriber.services)
|
|
2092
|
+
: {};
|
|
2093
|
+
|
|
2094
|
+
// Subscribe to events
|
|
2095
|
+
const subscribedEvents = subscriber.subscribedEvents || [];
|
|
2096
|
+
|
|
2097
|
+
if (subscribedEvents.length === 0) {
|
|
2098
|
+
logger.warn({ subscriber: subscriber.constructor.name }, 'Subscriber has no subscribed events, skipping');
|
|
2099
|
+
continue;
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
await eventSubscriber.subscribe(subscribedEvents, async (event) => {
|
|
2103
|
+
try {
|
|
2104
|
+
// Process single event (batch of 1)
|
|
2105
|
+
await subscriber.handler({
|
|
2106
|
+
events: [event],
|
|
2107
|
+
services: services as any,
|
|
2108
|
+
logger: subscriber.logger,
|
|
2109
|
+
});
|
|
2110
|
+
|
|
2111
|
+
logger.debug({ eventType: event.type }, 'Successfully processed event');
|
|
2112
|
+
} catch (error) {
|
|
2113
|
+
logger.error({ error, event }, 'Failed to process event');
|
|
2114
|
+
// Event will become visible again for retry
|
|
2115
|
+
}
|
|
2116
|
+
});
|
|
2117
|
+
|
|
2118
|
+
activeSubscribers.push(eventSubscriber);
|
|
2119
|
+
|
|
2120
|
+
logger.info(
|
|
2121
|
+
{
|
|
2122
|
+
events: subscribedEvents,
|
|
2123
|
+
},
|
|
2124
|
+
'Subscriber started polling'
|
|
2125
|
+
);
|
|
2126
|
+
} catch (error) {
|
|
2127
|
+
logger.error({ error, subscriber: subscriber.constructor.name }, 'Failed to setup subscriber');
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
// Setup graceful shutdown
|
|
2132
|
+
const shutdown = () => {
|
|
2133
|
+
logger.info('Stopping all subscribers');
|
|
2134
|
+
for (const eventSubscriber of activeSubscribers) {
|
|
2135
|
+
connection.stop();
|
|
2136
|
+
}
|
|
2137
|
+
};
|
|
2138
|
+
|
|
2139
|
+
process.on('SIGTERM', shutdown);
|
|
2140
|
+
process.on('SIGINT', shutdown);
|
|
2132
2141
|
}
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
portExplicit;
|
|
2137
|
-
enableOpenApi;
|
|
2138
|
-
telescope;
|
|
2139
|
-
studio;
|
|
2140
|
-
runtime;
|
|
2141
|
-
appRoot;
|
|
2142
|
-
secretsJsonPath;
|
|
2143
|
-
serverProcess = null;
|
|
2144
|
-
isRunning = false;
|
|
2145
|
-
actualPort;
|
|
2146
|
-
startTime = Date.now();
|
|
2147
|
-
constructor(provider, requestedPort, portExplicit, enableOpenApi, telescope, studio, runtime = "node", appRoot = process.cwd(), secretsJsonPath) {
|
|
2148
|
-
this.provider = provider;
|
|
2149
|
-
this.requestedPort = requestedPort;
|
|
2150
|
-
this.portExplicit = portExplicit;
|
|
2151
|
-
this.enableOpenApi = enableOpenApi;
|
|
2152
|
-
this.telescope = telescope;
|
|
2153
|
-
this.studio = studio;
|
|
2154
|
-
this.runtime = runtime;
|
|
2155
|
-
this.appRoot = appRoot;
|
|
2156
|
-
this.secretsJsonPath = secretsJsonPath;
|
|
2157
|
-
this.actualPort = requestedPort;
|
|
2158
|
-
}
|
|
2159
|
-
async start() {
|
|
2160
|
-
this.startTime = Date.now();
|
|
2161
|
-
if (this.isRunning) await this.stop();
|
|
2162
|
-
if (this.portExplicit) {
|
|
2163
|
-
if (!await require_credentials.isPortAvailable(this.requestedPort)) throw new Error(`Port ${this.requestedPort} is already in use. Either stop the process using that port or omit -p/--port to auto-select an available port.`);
|
|
2164
|
-
this.actualPort = this.requestedPort;
|
|
2165
|
-
} else {
|
|
2166
|
-
this.actualPort = await require_credentials.findAvailablePort(this.requestedPort);
|
|
2167
|
-
if (this.actualPort !== this.requestedPort) logger$11.log(`ℹ️ Port ${this.requestedPort} was in use, using port ${this.actualPort} instead`);
|
|
2168
|
-
}
|
|
2169
|
-
const serverEntryPath = (0, node_path.join)(this.appRoot, ".gkm", this.provider, "server.ts");
|
|
2170
|
-
await this.createServerEntry();
|
|
2171
|
-
logger$11.log(`\n⏳ Starting server...`);
|
|
2172
|
-
this.serverProcess = (0, node_child_process.spawn)("npx", [
|
|
2173
|
-
"tsx",
|
|
2174
|
-
serverEntryPath,
|
|
2175
|
-
"--port",
|
|
2176
|
-
this.actualPort.toString()
|
|
2177
|
-
], {
|
|
2178
|
-
stdio: "inherit",
|
|
2179
|
-
env: {
|
|
2180
|
-
...process.env,
|
|
2181
|
-
NODE_ENV: "development"
|
|
2182
|
-
},
|
|
2183
|
-
detached: true
|
|
2184
|
-
});
|
|
2185
|
-
this.isRunning = true;
|
|
2186
|
-
this.serverProcess.on("error", (error) => {
|
|
2187
|
-
logger$11.error("❌ Server error:", error);
|
|
2188
|
-
});
|
|
2189
|
-
this.serverProcess.on("exit", (code, signal) => {
|
|
2190
|
-
if (code !== null && code !== 0 && signal !== "SIGTERM") logger$11.error(`❌ Server exited with code ${code}`);
|
|
2191
|
-
this.isRunning = false;
|
|
2192
|
-
});
|
|
2193
|
-
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
2194
|
-
if (this.isRunning) {
|
|
2195
|
-
const base = `http://localhost:${this.actualPort}`;
|
|
2196
|
-
const lines = [` Local: ${base}`];
|
|
2197
|
-
if (this.enableOpenApi) lines.push(` API Docs: ${base}/__docs`);
|
|
2198
|
-
if (this.telescope) lines.push(` Telescope: ${base}${this.telescope.path}`);
|
|
2199
|
-
if (this.studio) lines.push(` Studio: ${base}${this.studio.path}`);
|
|
2200
|
-
const maxLen = Math.max(...lines.map((l) => l.length));
|
|
2201
|
-
const pad = (s) => s.padEnd(maxLen);
|
|
2202
|
-
const border = "─".repeat(maxLen + 2);
|
|
2203
|
-
logger$11.log("");
|
|
2204
|
-
logger$11.log(` \x1b[32m✓ Ready\x1b[0m in ${((Date.now() - this.startTime) / 1e3).toFixed(1)}s`);
|
|
2205
|
-
logger$11.log("");
|
|
2206
|
-
logger$11.log(` ┌${border}┐`);
|
|
2207
|
-
for (const line of lines) logger$11.log(` │ ${pad(line)} │`);
|
|
2208
|
-
logger$11.log(` └${border}┘`);
|
|
2209
|
-
logger$11.log("");
|
|
2210
|
-
}
|
|
2211
|
-
}
|
|
2212
|
-
async stop() {
|
|
2213
|
-
const port = this.actualPort;
|
|
2214
|
-
if (this.serverProcess && this.isRunning) {
|
|
2215
|
-
const pid = this.serverProcess.pid;
|
|
2216
|
-
if (pid) try {
|
|
2217
|
-
process.kill(-pid, "SIGKILL");
|
|
2218
|
-
} catch {
|
|
2219
|
-
try {
|
|
2220
|
-
process.kill(pid, "SIGKILL");
|
|
2221
|
-
} catch {}
|
|
2222
|
-
}
|
|
2223
|
-
this.serverProcess = null;
|
|
2224
|
-
this.isRunning = false;
|
|
2225
|
-
}
|
|
2226
|
-
this.killProcessesOnPort(port);
|
|
2227
|
-
}
|
|
2228
|
-
killProcessesOnPort(port) {
|
|
2229
|
-
try {
|
|
2230
|
-
(0, node_child_process.execSync)(`lsof -ti tcp:${port} | xargs kill -9 2>/dev/null || true`, { stdio: "ignore" });
|
|
2231
|
-
} catch {}
|
|
2142
|
+
`;
|
|
2143
|
+
await (0, node_fs_promises.writeFile)(subscribersPath, content);
|
|
2144
|
+
return subscribersPath;
|
|
2232
2145
|
}
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2146
|
+
};
|
|
2147
|
+
//#endregion
|
|
2148
|
+
//#region src/generators/TopicGenerator.ts
|
|
2149
|
+
/**
|
|
2150
|
+
* Discovers `t` topics into `manifest.topics`. A topic is a *resource*, not a
|
|
2151
|
+
* function — it has no handler to generate. Producers reach it via its derived
|
|
2152
|
+
* publisher (`topic.publisher`, whose connection string is sniffed onto the
|
|
2153
|
+
* producing construct) and subscribers bind via `s.topic(topic)`; this generator
|
|
2154
|
+
* only records the topic + its event contract so infra can provision the SNS
|
|
2155
|
+
* topic. Identical output for every provider (locally pg-boss routes by event
|
|
2156
|
+
* name, so there's nothing to run).
|
|
2157
|
+
*/
|
|
2158
|
+
var TopicGenerator = class extends require_discover.ConstructGenerator {
|
|
2159
|
+
isConstruct(value) {
|
|
2160
|
+
return _geekmidas_constructs_topic.Topic.isTopic(value);
|
|
2244
2161
|
}
|
|
2245
|
-
async
|
|
2246
|
-
const
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
enableOpenApi: this.enableOpenApi
|
|
2162
|
+
async build(_context, constructs, _outputDir, _options) {
|
|
2163
|
+
const logger = console;
|
|
2164
|
+
const topicInfos = constructs.map(({ construct }) => ({
|
|
2165
|
+
name: construct.name,
|
|
2166
|
+
events: construct.eventTypes
|
|
2251
2167
|
}));
|
|
2168
|
+
for (const topic of topicInfos) logger.log(`Discovered topic: ${topic.name}`);
|
|
2169
|
+
return topicInfos;
|
|
2252
2170
|
}
|
|
2253
2171
|
};
|
|
2254
2172
|
//#endregion
|
|
@@ -2462,6 +2380,76 @@ function mapTopicMetadata(topics) {
|
|
|
2462
2380
|
for (const [partition, partitionTopics] of Object.entries(topics)) result[partition] = partitionTopics.map(mapFn);
|
|
2463
2381
|
return result;
|
|
2464
2382
|
}
|
|
2383
|
+
//#endregion
|
|
2384
|
+
//#region src/build/owners.ts
|
|
2385
|
+
/**
|
|
2386
|
+
* What a generated entry imports its runtime from, read off the discovered
|
|
2387
|
+
* constructs: the surface it serves, every construct a runnable can be built
|
|
2388
|
+
* from, and the database Studio browses.
|
|
2389
|
+
*
|
|
2390
|
+
* `gkm build` and `gkm dev` both write entries that import the surface for its
|
|
2391
|
+
* logger and environment parser, so both ask here. A dev server that skipped it
|
|
2392
|
+
* had nothing to import them from.
|
|
2393
|
+
*/
|
|
2394
|
+
function ownersContext(options) {
|
|
2395
|
+
const { declared, sources, workspaceRoot, appRoot, studio } = options;
|
|
2396
|
+
const surfaces = restApis(declared);
|
|
2397
|
+
const primary = servedSurface(declared, workspaceRoot, appRoot) ?? surfaces.find((d) => d.endpoints.length === 0) ?? surfaces[0];
|
|
2398
|
+
const browsable = Object.entries(declared).find(([, d]) => d.kind === "database");
|
|
2399
|
+
const browsableSource = browsable ? sources[browsable[0]] : void 0;
|
|
2400
|
+
const owners = {};
|
|
2401
|
+
for (const [id, declaration] of Object.entries(declared)) {
|
|
2402
|
+
if (declaration.kind !== "rest-api" && declaration.kind !== "worker") continue;
|
|
2403
|
+
const source = sources[id];
|
|
2404
|
+
if (!source) continue;
|
|
2405
|
+
owners[id] = {
|
|
2406
|
+
specifier: source.file,
|
|
2407
|
+
exportName: source.exportName
|
|
2408
|
+
};
|
|
2409
|
+
}
|
|
2410
|
+
const primarySource = primary ? sources[primary.id] : void 0;
|
|
2411
|
+
return {
|
|
2412
|
+
owners,
|
|
2413
|
+
...primary ? { surface: {
|
|
2414
|
+
id: primary.id,
|
|
2415
|
+
trustedOriginsKey: (0, _geekmidas_manifest.provideKey)(primary.id, "trustedOrigins"),
|
|
2416
|
+
...primary.cors ? { cors: primary.cors } : {},
|
|
2417
|
+
...primarySource ? { module: {
|
|
2418
|
+
specifier: primarySource.file,
|
|
2419
|
+
exportName: primarySource.exportName
|
|
2420
|
+
} } : {}
|
|
2421
|
+
} } : {},
|
|
2422
|
+
studio: studio && browsableSource ? {
|
|
2423
|
+
...studio,
|
|
2424
|
+
database: {
|
|
2425
|
+
specifier: browsableSource.file,
|
|
2426
|
+
exportName: browsableSource.exportName
|
|
2427
|
+
}
|
|
2428
|
+
} : studio
|
|
2429
|
+
};
|
|
2430
|
+
}
|
|
2431
|
+
/**
|
|
2432
|
+
* The endpoints one surface serves.
|
|
2433
|
+
*
|
|
2434
|
+
* One glob finds every surface's endpoints, wherever their files live. A build
|
|
2435
|
+
* keeps the ones built from the surface it serves; one built from no surface at
|
|
2436
|
+
* all has nowhere else to go.
|
|
2437
|
+
*/
|
|
2438
|
+
function servedBy(endpoints, surface) {
|
|
2439
|
+
if (!surface) return endpoints;
|
|
2440
|
+
return endpoints.filter(({ construct }) => !construct.surface || construct.surface.id === surface.id);
|
|
2441
|
+
}
|
|
2442
|
+
function restApis(declared) {
|
|
2443
|
+
return Object.values(declared).filter((d) => d.kind === "rest-api");
|
|
2444
|
+
}
|
|
2445
|
+
/**
|
|
2446
|
+
* The surface whose declared `path` is the directory being built — asked of
|
|
2447
|
+
* every surface, because a workspace has several and each app's build serves
|
|
2448
|
+
* exactly one.
|
|
2449
|
+
*/
|
|
2450
|
+
function servedSurface(declared, workspaceRoot, appRoot) {
|
|
2451
|
+
return restApis(declared).find((d) => (0, node_path.resolve)(workspaceRoot, d.path) === (0, node_path.resolve)(appRoot));
|
|
2452
|
+
}
|
|
2465
2453
|
/**
|
|
2466
2454
|
* Check if any construct across the given arrays has a non-undefined partition.
|
|
2467
2455
|
* When true, the manifest should use the partitioned shape for that construct type.
|
|
@@ -2484,7 +2472,29 @@ function groupInfosByPartition(infos, constructs) {
|
|
|
2484
2472
|
return groups;
|
|
2485
2473
|
}
|
|
2486
2474
|
//#endregion
|
|
2487
|
-
//#region src/build/
|
|
2475
|
+
//#region src/build/surfaceEntry.ts
|
|
2476
|
+
/** Whether a construct serves itself, asked of the construct. */
|
|
2477
|
+
function servesItself(construct) {
|
|
2478
|
+
return typeof construct?.server === "function";
|
|
2479
|
+
}
|
|
2480
|
+
/**
|
|
2481
|
+
* The surface this app serves, when that surface serves itself.
|
|
2482
|
+
*
|
|
2483
|
+
* An auth server's routes are a wildcard the construct mounts, so a glob finds
|
|
2484
|
+
* nothing and the entry only has to start it. That used to be inferred —
|
|
2485
|
+
* "nothing found, and the surface declares routes" — which is a guess about
|
|
2486
|
+
* the construct from the outside. The construct can simply be asked.
|
|
2487
|
+
*/
|
|
2488
|
+
function selfServingSurface(options) {
|
|
2489
|
+
const { declared, sources, workspaceRoot, appRoot } = options;
|
|
2490
|
+
const served = servedSurface(declared, workspaceRoot, appRoot);
|
|
2491
|
+
const source = served ? sources[served.id] : void 0;
|
|
2492
|
+
if (!served || !source || !servesItself(source.construct)) return void 0;
|
|
2493
|
+
return {
|
|
2494
|
+
id: served.id,
|
|
2495
|
+
source
|
|
2496
|
+
};
|
|
2497
|
+
}
|
|
2488
2498
|
/**
|
|
2489
2499
|
* The entry for a surface that serves itself.
|
|
2490
2500
|
*
|
|
@@ -2518,6 +2528,8 @@ export const { app } = await surface.server({ envParser, context: snifferContext
|
|
|
2518
2528
|
export default app;
|
|
2519
2529
|
`);
|
|
2520
2530
|
}
|
|
2531
|
+
//#endregion
|
|
2532
|
+
//#region src/build/index.ts
|
|
2521
2533
|
const logger$9 = console;
|
|
2522
2534
|
/**
|
|
2523
2535
|
* What a build run at the workspace root reads.
|
|
@@ -2561,18 +2573,56 @@ async function buildCommand(options) {
|
|
|
2561
2573
|
if (studio) logger$9.log(`🗄️ Studio enabled at ${studio.path}`);
|
|
2562
2574
|
const hooks = normalizeHooksConfig(config.hooks);
|
|
2563
2575
|
if (hooks) logger$9.log(`🪝 Server hooks enabled`);
|
|
2576
|
+
return buildApp({
|
|
2577
|
+
config,
|
|
2578
|
+
workspaceRoot: loadedConfig.workspace.root,
|
|
2579
|
+
appRoot: process.cwd(),
|
|
2580
|
+
providers: resolved.providers,
|
|
2581
|
+
enableOpenApi: resolved.enableOpenApi,
|
|
2582
|
+
cacheBackend,
|
|
2583
|
+
production,
|
|
2584
|
+
telescope,
|
|
2585
|
+
studio,
|
|
2586
|
+
hooks,
|
|
2587
|
+
markOptional: options.markOptional ?? false,
|
|
2588
|
+
skipBundle: options.skipBundle ?? false,
|
|
2589
|
+
stage: options.stage
|
|
2590
|
+
});
|
|
2591
|
+
}
|
|
2592
|
+
/**
|
|
2593
|
+
* Turn one app's constructs into its generated entry.
|
|
2594
|
+
*
|
|
2595
|
+
* `gkm build` and `gkm dev` both run this. They used to be two copies of it,
|
|
2596
|
+
* and every time the build learned something about constructs — that the
|
|
2597
|
+
* entry takes its logger from the surface, that an auth server serves itself —
|
|
2598
|
+
* dev did not, and failed on exactly the apps the build had learned to handle.
|
|
2599
|
+
*/
|
|
2600
|
+
async function buildApp(input) {
|
|
2601
|
+
const { config, workspaceRoot, appRoot, providers, enableOpenApi, cacheBackend, production, telescope, studio, hooks, bustCache = false } = input;
|
|
2564
2602
|
const constructGlobs = typeof config.constructs === "string" || Array.isArray(config.constructs) ? config.constructs : void 0;
|
|
2565
2603
|
const constructSources = {};
|
|
2566
2604
|
const declared = constructGlobs ? await require_discover.discover({
|
|
2567
2605
|
patterns: constructGlobs,
|
|
2568
|
-
cwd:
|
|
2606
|
+
cwd: appRoot,
|
|
2607
|
+
bustCache,
|
|
2569
2608
|
sources: constructSources
|
|
2570
2609
|
}) : {};
|
|
2610
|
+
const selfServing = selfServingSurface({
|
|
2611
|
+
declared,
|
|
2612
|
+
sources: constructSources,
|
|
2613
|
+
workspaceRoot,
|
|
2614
|
+
appRoot
|
|
2615
|
+
});
|
|
2616
|
+
if (selfServing) {
|
|
2617
|
+
await writeSurfaceEntry((0, node_path.join)(appRoot, ".gkm", "server"), selfServing.source);
|
|
2618
|
+
logger$9.log(`Generated a server for ${selfServing.id} from its own declaration`);
|
|
2619
|
+
return { selfServing: selfServing.id };
|
|
2620
|
+
}
|
|
2571
2621
|
const derived = ownersContext({
|
|
2572
2622
|
declared,
|
|
2573
2623
|
sources: constructSources,
|
|
2574
|
-
workspaceRoot
|
|
2575
|
-
appRoot
|
|
2624
|
+
workspaceRoot,
|
|
2625
|
+
appRoot,
|
|
2576
2626
|
studio
|
|
2577
2627
|
});
|
|
2578
2628
|
const buildContext = {
|
|
@@ -2584,10 +2634,10 @@ async function buildCommand(options) {
|
|
|
2584
2634
|
cacheBackend,
|
|
2585
2635
|
emailBackend: "ses",
|
|
2586
2636
|
storageDrivers: driversFor({
|
|
2587
|
-
appRoot
|
|
2637
|
+
appRoot,
|
|
2588
2638
|
cache: cacheBackendsIn(declared, cacheBackend)
|
|
2589
2639
|
}),
|
|
2590
|
-
markOptional:
|
|
2640
|
+
markOptional: input.markOptional ?? false
|
|
2591
2641
|
};
|
|
2592
2642
|
const endpointGenerator = new require_openapi.EndpointGenerator();
|
|
2593
2643
|
const functionGenerator = new FunctionGenerator();
|
|
@@ -2597,12 +2647,12 @@ async function buildCommand(options) {
|
|
|
2597
2647
|
const topicGenerator = new TopicGenerator();
|
|
2598
2648
|
const code = constructGlobs;
|
|
2599
2649
|
const [loadedEndpoints, allFunctions, allCrons, allSubscribers, allQueues, allTopics] = await Promise.all([
|
|
2600
|
-
endpointGenerator.load(code),
|
|
2601
|
-
functionGenerator.load(code),
|
|
2602
|
-
cronGenerator.load(code),
|
|
2603
|
-
subscriberGenerator.load(code),
|
|
2604
|
-
queueGenerator.load(code),
|
|
2605
|
-
topicGenerator.load(code)
|
|
2650
|
+
endpointGenerator.load(code, appRoot, bustCache),
|
|
2651
|
+
functionGenerator.load(code, appRoot, bustCache),
|
|
2652
|
+
cronGenerator.load(code, appRoot, bustCache),
|
|
2653
|
+
subscriberGenerator.load(code, appRoot, bustCache),
|
|
2654
|
+
queueGenerator.load(code, appRoot, bustCache),
|
|
2655
|
+
topicGenerator.load(code, appRoot, bustCache)
|
|
2606
2656
|
]);
|
|
2607
2657
|
const allEndpoints = servedBy(loadedEndpoints, derived.surface);
|
|
2608
2658
|
logger$9.log(`Found ${allEndpoints.length} endpoints`);
|
|
@@ -2611,30 +2661,23 @@ async function buildCommand(options) {
|
|
|
2611
2661
|
logger$9.log(`Found ${allSubscribers.length} subscribers`);
|
|
2612
2662
|
logger$9.log(`Found ${allQueues.length} queues`);
|
|
2613
2663
|
logger$9.log(`Found ${allTopics.length} topics`);
|
|
2614
|
-
if (allEndpoints.length === 0 && allFunctions.length === 0 && allCrons.length === 0 && allSubscribers.length === 0 && allQueues.length === 0 && allTopics.length === 0) {
|
|
2615
|
-
const served = servedSurface(declared, loadedConfig.workspace.root, process.cwd());
|
|
2616
|
-
const selfServing = served && served.endpoints.length > 0 && constructSources[served.id] !== void 0 ? [served.id, served] : void 0;
|
|
2617
|
-
if (selfServing) {
|
|
2618
|
-
const [id] = selfServing;
|
|
2619
|
-
await writeSurfaceEntry((0, node_path.join)(process.cwd(), ".gkm", "server"), constructSources[id]);
|
|
2620
|
-
logger$9.log(`Generated a server for ${id} from its own declaration`);
|
|
2621
|
-
return {};
|
|
2622
|
-
}
|
|
2664
|
+
if (!input.serveEmpty && allEndpoints.length === 0 && allFunctions.length === 0 && allCrons.length === 0 && allSubscribers.length === 0 && allQueues.length === 0 && allTopics.length === 0) {
|
|
2623
2665
|
logger$9.log("No endpoints, functions, crons, subscribers, queues, or topics found to process");
|
|
2624
2666
|
return {};
|
|
2625
2667
|
}
|
|
2626
|
-
const rootOutputDir = (0, node_path.join)(
|
|
2668
|
+
const rootOutputDir = (0, node_path.join)(appRoot, ".gkm");
|
|
2627
2669
|
await (0, node_fs_promises.mkdir)(rootOutputDir, { recursive: true });
|
|
2628
2670
|
let result = {};
|
|
2629
|
-
for (const provider of
|
|
2630
|
-
const providerResult = await buildForProvider(provider, buildContext,
|
|
2671
|
+
for (const provider of providers) {
|
|
2672
|
+
const providerResult = await buildForProvider(provider, buildContext, appRoot, declared, endpointGenerator, functionGenerator, cronGenerator, subscriberGenerator, queueGenerator, topicGenerator, allEndpoints, allFunctions, allCrons, allSubscribers, allQueues, allTopics, enableOpenApi, input.skipBundle ?? false, input.stage);
|
|
2631
2673
|
if (providerResult.masterKey) result = providerResult;
|
|
2632
2674
|
}
|
|
2633
2675
|
await require_openapi.generateOpenApi(allEndpoints.map(({ construct }) => construct), { openapi: config.openapi });
|
|
2634
2676
|
return result;
|
|
2635
2677
|
}
|
|
2636
|
-
async function buildForProvider(provider, context,
|
|
2637
|
-
const
|
|
2678
|
+
async function buildForProvider(provider, context, appRoot, discovered, endpointGenerator, functionGenerator, cronGenerator, subscriberGenerator, queueGenerator, topicGenerator, endpoints, functions, crons, subscribers, queues, topics, enableOpenApi, skipBundle, stage) {
|
|
2679
|
+
const rootOutputDir = (0, node_path.join)(appRoot, ".gkm");
|
|
2680
|
+
const outputDir = (0, node_path.join)(rootOutputDir, provider);
|
|
2638
2681
|
await (0, node_fs_promises.mkdir)(outputDir, { recursive: true });
|
|
2639
2682
|
logger$9.log(`\nGenerating handlers for provider: ${provider}`);
|
|
2640
2683
|
const [routes, functionInfos, cronInfos, subscriberInfos, queueInfos, topicInfos] = await Promise.all([
|
|
@@ -2650,10 +2693,6 @@ async function buildForProvider(provider, context, rootOutputDir, endpointGenera
|
|
|
2650
2693
|
]);
|
|
2651
2694
|
logger$9.log(`Generated ${routes.length} routes, ${functionInfos.length} functions, ${cronInfos.length} crons, ${subscriberInfos.length} subscribers, ${queueInfos.length} queues, ${topicInfos.length} topics for ${provider}`);
|
|
2652
2695
|
const manifestRoutes = assembleManifestField(routes, endpoints);
|
|
2653
|
-
const discovered = context.constructGlobs ? await require_discover.discover({
|
|
2654
|
-
patterns: context.constructGlobs,
|
|
2655
|
-
cwd: process.cwd()
|
|
2656
|
-
}) : {};
|
|
2657
2696
|
const manifestFunctions = assembleManifestField(functionInfos, functions);
|
|
2658
2697
|
const manifestCrons = assembleManifestField(cronInfos, crons);
|
|
2659
2698
|
const manifestQueues = assembleManifestField(queueInfos, queues);
|
|
@@ -2667,8 +2706,8 @@ async function buildForProvider(provider, context, rootOutputDir, endpointGenera
|
|
|
2667
2706
|
authorizer: construct.authorizer?.name ?? "none"
|
|
2668
2707
|
}))), endpoints);
|
|
2669
2708
|
await generateServerManifest(rootOutputDir, {
|
|
2670
|
-
handler: (0, node_path.relative)(
|
|
2671
|
-
endpoints: (0, node_path.relative)(
|
|
2709
|
+
handler: (0, node_path.relative)(appRoot, (0, node_path.join)(outputDir, "app.ts")),
|
|
2710
|
+
endpoints: (0, node_path.relative)(appRoot, (0, node_path.join)(outputDir, "endpoints.ts"))
|
|
2672
2711
|
}, serverRouteField, manifestSubscribers, manifestQueues, manifestTopics);
|
|
2673
2712
|
let masterKey;
|
|
2674
2713
|
if (context.production?.bundle && !skipBundle) {
|
|
@@ -2747,14 +2786,9 @@ function getTurboCommand(pm, filters = []) {
|
|
|
2747
2786
|
function turboFilters(workspace) {
|
|
2748
2787
|
const filters = [];
|
|
2749
2788
|
const unpackaged = [];
|
|
2750
|
-
for (const
|
|
2751
|
-
const
|
|
2752
|
-
if (
|
|
2753
|
-
unpackaged.push(appName);
|
|
2754
|
-
continue;
|
|
2755
|
-
}
|
|
2756
|
-
const name = JSON.parse((0, node_fs.readFileSync)(pkgPath, "utf8")).name;
|
|
2757
|
-
if (typeof name === "string" && name.length > 0) filters.push(name);
|
|
2789
|
+
for (const appName of Object.keys(workspace.apps)) {
|
|
2790
|
+
const name = appPackageName(workspace, appName);
|
|
2791
|
+
if (name) filters.push(name);
|
|
2758
2792
|
else unpackaged.push(appName);
|
|
2759
2793
|
}
|
|
2760
2794
|
return {
|
|
@@ -2763,6 +2797,20 @@ function turboFilters(workspace) {
|
|
|
2763
2797
|
};
|
|
2764
2798
|
}
|
|
2765
2799
|
/**
|
|
2800
|
+
* The package name turbo knows an app by, from its own `package.json`.
|
|
2801
|
+
*
|
|
2802
|
+
* Not the app's key: `api` in the workspace is `@shop/api` to turbo, and a
|
|
2803
|
+
* filter naming the key matches nothing.
|
|
2804
|
+
*/
|
|
2805
|
+
function appPackageName(workspace, appName) {
|
|
2806
|
+
const app = workspace.apps[appName];
|
|
2807
|
+
if (!app) return void 0;
|
|
2808
|
+
const pkgPath = (0, node_path.join)(workspace.root, app.path, "package.json");
|
|
2809
|
+
if (!(0, node_fs.existsSync)(pkgPath)) return void 0;
|
|
2810
|
+
const name = JSON.parse((0, node_fs.readFileSync)(pkgPath, "utf8")).name;
|
|
2811
|
+
return typeof name === "string" && name.length > 0 ? name : void 0;
|
|
2812
|
+
}
|
|
2813
|
+
/**
|
|
2766
2814
|
* Build all apps in a workspace using Turbo for dependency-ordered parallel builds.
|
|
2767
2815
|
* @internal Exported for testing
|
|
2768
2816
|
*/
|
|
@@ -8478,26 +8526,26 @@ function loadPackageJson() {
|
|
|
8478
8526
|
* Run: pnpm --filter @geekmidas/cli sync-versions
|
|
8479
8527
|
*/
|
|
8480
8528
|
const GEEKMIDAS_VERSIONS = {
|
|
8481
|
-
"@geekmidas/audit": "~10.0.0-alpha.
|
|
8482
|
-
"@geekmidas/auth": "~10.0.0-alpha.
|
|
8483
|
-
"@geekmidas/cache": "~10.0.0-alpha.
|
|
8484
|
-
"@geekmidas/client": "~10.0.0-alpha.
|
|
8485
|
-
"@geekmidas/cloud": "~10.0.0-alpha.
|
|
8486
|
-
"@geekmidas/constructs": "~10.0.0-alpha.
|
|
8487
|
-
"@geekmidas/db": "~10.0.0-alpha.
|
|
8488
|
-
"@geekmidas/emailkit": "~10.0.0-alpha.
|
|
8489
|
-
"@geekmidas/envkit": "~10.0.0-alpha.
|
|
8490
|
-
"@geekmidas/errors": "~10.0.0-alpha.
|
|
8491
|
-
"@geekmidas/events": "~10.0.0-alpha.
|
|
8492
|
-
"@geekmidas/logger": "~10.0.0-alpha.
|
|
8493
|
-
"@geekmidas/manifest": "~10.0.0-alpha.
|
|
8494
|
-
"@geekmidas/rate-limit": "~10.0.0-alpha.
|
|
8495
|
-
"@geekmidas/schema": "~10.0.0-alpha.
|
|
8496
|
-
"@geekmidas/services": "~10.0.0-alpha.
|
|
8497
|
-
"@geekmidas/storage": "~10.0.0-alpha.
|
|
8498
|
-
"@geekmidas/studio": "~10.0.0-alpha.
|
|
8499
|
-
"@geekmidas/telescope": "~10.0.0-alpha.
|
|
8500
|
-
"@geekmidas/testkit": "~10.0.0-alpha.
|
|
8529
|
+
"@geekmidas/audit": "~10.0.0-alpha.25",
|
|
8530
|
+
"@geekmidas/auth": "~10.0.0-alpha.25",
|
|
8531
|
+
"@geekmidas/cache": "~10.0.0-alpha.25",
|
|
8532
|
+
"@geekmidas/client": "~10.0.0-alpha.25",
|
|
8533
|
+
"@geekmidas/cloud": "~10.0.0-alpha.25",
|
|
8534
|
+
"@geekmidas/constructs": "~10.0.0-alpha.25",
|
|
8535
|
+
"@geekmidas/db": "~10.0.0-alpha.25",
|
|
8536
|
+
"@geekmidas/emailkit": "~10.0.0-alpha.25",
|
|
8537
|
+
"@geekmidas/envkit": "~10.0.0-alpha.25",
|
|
8538
|
+
"@geekmidas/errors": "~10.0.0-alpha.25",
|
|
8539
|
+
"@geekmidas/events": "~10.0.0-alpha.25",
|
|
8540
|
+
"@geekmidas/logger": "~10.0.0-alpha.25",
|
|
8541
|
+
"@geekmidas/manifest": "~10.0.0-alpha.25",
|
|
8542
|
+
"@geekmidas/rate-limit": "~10.0.0-alpha.25",
|
|
8543
|
+
"@geekmidas/schema": "~10.0.0-alpha.25",
|
|
8544
|
+
"@geekmidas/services": "~10.0.0-alpha.25",
|
|
8545
|
+
"@geekmidas/storage": "~10.0.0-alpha.25",
|
|
8546
|
+
"@geekmidas/studio": "~10.0.0-alpha.25",
|
|
8547
|
+
"@geekmidas/telescope": "~10.0.0-alpha.25",
|
|
8548
|
+
"@geekmidas/testkit": "~10.0.0-alpha.25",
|
|
8501
8549
|
"@geekmidas/cli": `~${loadPackageJson().version}`
|
|
8502
8550
|
};
|
|
8503
8551
|
//#endregion
|
|
@@ -15918,7 +15966,7 @@ program.command("dev").description("Reconcile constructs and start the dev serve
|
|
|
15918
15966
|
const globalOptions = program.opts();
|
|
15919
15967
|
if (globalOptions.cwd) process.chdir(globalOptions.cwd);
|
|
15920
15968
|
await devCommand({
|
|
15921
|
-
port: options.port ? Number.parseInt(options.port, 10) :
|
|
15969
|
+
port: options.port ? Number.parseInt(options.port, 10) : void 0,
|
|
15922
15970
|
portExplicit: !!options.port,
|
|
15923
15971
|
enableOpenApi: options.enableOpenapi ?? true,
|
|
15924
15972
|
entry: options.entry,
|
|
@@ -16071,7 +16119,7 @@ program.command("secrets:push").description("Send a deployed stage's secrets to
|
|
|
16071
16119
|
const globalOptions = program.opts();
|
|
16072
16120
|
if (globalOptions.cwd) process.chdir(globalOptions.cwd);
|
|
16073
16121
|
const { loadWorkspaceConfig } = await Promise.resolve().then(() => require("./config.cjs"));
|
|
16074
|
-
const { pushStageSecrets } = await Promise.resolve().then(() => require("./transfer-
|
|
16122
|
+
const { pushStageSecrets } = await Promise.resolve().then(() => require("./transfer-D59M4aaR.cjs")).then((n) => n.transfer_exports);
|
|
16075
16123
|
const { workspace } = await loadWorkspaceConfig();
|
|
16076
16124
|
const { addedKeys } = await pushStageSecrets(workspace, options.stage, { profile: options.profile });
|
|
16077
16125
|
for (const key of addedKeys) console.log(` + ${key} (reconciled)`);
|
|
@@ -16086,7 +16134,7 @@ program.command("secrets:pull").description("Bring a deployed stage's secrets fr
|
|
|
16086
16134
|
const globalOptions = program.opts();
|
|
16087
16135
|
if (globalOptions.cwd) process.chdir(globalOptions.cwd);
|
|
16088
16136
|
const { loadWorkspaceConfig } = await Promise.resolve().then(() => require("./config.cjs"));
|
|
16089
|
-
const { pullStageSecrets } = await Promise.resolve().then(() => require("./transfer-
|
|
16137
|
+
const { pullStageSecrets } = await Promise.resolve().then(() => require("./transfer-D59M4aaR.cjs")).then((n) => n.transfer_exports);
|
|
16090
16138
|
const { workspace } = await loadWorkspaceConfig();
|
|
16091
16139
|
const { addedKeys } = await pullStageSecrets(workspace, options.stage, { profile: options.profile });
|
|
16092
16140
|
for (const key of addedKeys) console.log(` + ${key} (reconciled)`);
|
|
@@ -16110,7 +16158,7 @@ program.command("secrets:reconcile").description("Backfill missing custom secret
|
|
|
16110
16158
|
console.error(`No secrets found for stage "${stage}". Run "gkm secrets:init --stage ${stage}" first.`);
|
|
16111
16159
|
process.exit(1);
|
|
16112
16160
|
}
|
|
16113
|
-
const { derivedContainers } = await Promise.resolve().then(() => require("./credentials-
|
|
16161
|
+
const { derivedContainers } = await Promise.resolve().then(() => require("./credentials-Dbyccl3A.cjs")).then((n) => n.workspace_exports);
|
|
16114
16162
|
const result = reconcileMissingSecrets(secrets, workspace, await derivedContainers(workspace, stage));
|
|
16115
16163
|
if (!result) {
|
|
16116
16164
|
console.log(`\n✓ Secrets for stage "${stage}" are up-to-date`);
|
|
@@ -16169,7 +16217,7 @@ program.command("deploy:github").description("Let GitHub Actions deploy a stage:
|
|
|
16169
16217
|
try {
|
|
16170
16218
|
const globalOptions = program.opts();
|
|
16171
16219
|
if (globalOptions.cwd) process.chdir(globalOptions.cwd);
|
|
16172
|
-
const { deployGithubCommand } = await Promise.resolve().then(() => require("./github-
|
|
16220
|
+
const { deployGithubCommand } = await Promise.resolve().then(() => require("./github-CUjW3G91.cjs"));
|
|
16173
16221
|
await deployGithubCommand(options);
|
|
16174
16222
|
} catch (error) {
|
|
16175
16223
|
console.error(formatError(error));
|