@karmaniverous/smoz 0.2.5 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.cjs
CHANGED
|
@@ -5,10 +5,10 @@ var fs = require('node:fs');
|
|
|
5
5
|
var path = require('node:path');
|
|
6
6
|
var commander = require('commander');
|
|
7
7
|
var packageDirectory = require('package-directory');
|
|
8
|
-
var node_url = require('node:url');
|
|
9
8
|
var chokidar = require('chokidar');
|
|
10
9
|
var node_child_process = require('node:child_process');
|
|
11
10
|
var os = require('node:os');
|
|
11
|
+
var node_url = require('node:url');
|
|
12
12
|
var node_process = require('node:process');
|
|
13
13
|
var promises = require('node:readline/promises');
|
|
14
14
|
|
|
@@ -348,10 +348,19 @@ const spawnOffline = (root, cmd, verbose) => {
|
|
|
348
348
|
env: baseEnv,
|
|
349
349
|
});
|
|
350
350
|
const prefix = '[offline] ';
|
|
351
|
+
let announced = false;
|
|
351
352
|
const emit = (buf) => {
|
|
352
353
|
const text = buf.toString('utf8');
|
|
353
354
|
if (verbose)
|
|
354
355
|
process.stdout.write(prefix + text);
|
|
356
|
+
// Detect the actual listening URL and announce it once to avoid "0" confusion.
|
|
357
|
+
const m = text.match(/listening on (https?:\/\/(?:localhost|127\.0\.0\.1):\d+)/i);
|
|
358
|
+
const url = m?.[1];
|
|
359
|
+
if (url && !announced) {
|
|
360
|
+
announced = true;
|
|
361
|
+
// Print a concise, canonical URL line.
|
|
362
|
+
process.stdout.write(`[dev] offline: ${url}\n`);
|
|
363
|
+
}
|
|
355
364
|
};
|
|
356
365
|
const emitErr = (buf) => {
|
|
357
366
|
const text = buf.toString('utf8');
|
|
@@ -495,13 +504,17 @@ const collect = async (root) => {
|
|
|
495
504
|
buckets.serverless.sort();
|
|
496
505
|
return buckets;
|
|
497
506
|
};
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
507
|
+
/**
|
|
508
|
+
* Build ESM import lines using POSIX-relative specifiers from the generated
|
|
509
|
+
* file directory (app/generated) to each target file. Avoids "@/" alias so
|
|
510
|
+
* dynamic import in the inline server works reliably under tsx.
|
|
511
|
+
*/
|
|
512
|
+
const makeImports = (root, outDir, files) => files.map((abs) => {
|
|
513
|
+
const relFromOut = toPosix$1(path.relative(outDir, abs));
|
|
514
|
+
const noExt = withoutTsExt(relFromOut);
|
|
515
|
+
const spec = noExt.startsWith('.') ? noExt : `./${noExt}`;
|
|
516
|
+
return `import '${spec}';`;
|
|
517
|
+
});
|
|
505
518
|
const buildFile = (imports) => {
|
|
506
519
|
if (imports.length === 0)
|
|
507
520
|
return `${HEADER}\n`;
|
|
@@ -509,10 +522,10 @@ const buildFile = (imports) => {
|
|
|
509
522
|
};
|
|
510
523
|
const runRegister = async (root) => {
|
|
511
524
|
const buckets = await collect(root);
|
|
512
|
-
const lambdaImports = makeImports(root, buckets.lambda);
|
|
513
|
-
const openapiImports = makeImports(root, buckets.openapi);
|
|
514
|
-
const serverlessImports = makeImports(root, buckets.serverless);
|
|
515
525
|
const outDir = path.join(root, 'app', 'generated');
|
|
526
|
+
const lambdaImports = makeImports(root, outDir, buckets.lambda);
|
|
527
|
+
const openapiImports = makeImports(root, outDir, buckets.openapi);
|
|
528
|
+
const serverlessImports = makeImports(root, outDir, buckets.serverless);
|
|
516
529
|
const fnsPath = path.join(outDir, 'register.functions.ts');
|
|
517
530
|
const oaiPath = path.join(outDir, 'register.openapi.ts');
|
|
518
531
|
const srvPath = path.join(outDir, 'register.serverless.ts');
|
|
@@ -529,11 +542,146 @@ const runRegister = async (root) => {
|
|
|
529
542
|
return { wrote };
|
|
530
543
|
};
|
|
531
544
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
545
|
+
const inferDefaultStage = (root, verbose) => {
|
|
546
|
+
// Prefer “dev”; explicit --stage overrides remain available.
|
|
547
|
+
if (verbose)
|
|
548
|
+
console.log('[dev] inferring stage: dev (explicit --stage overrides)');
|
|
549
|
+
return 'dev';
|
|
550
|
+
};
|
|
551
|
+
const seedEnvForStage = async (root, stage, verbose) => {
|
|
552
|
+
// Best effort: import the app config to read declared env keys and concrete values.
|
|
553
|
+
// Preserve existing process.env values; only seed when unset.
|
|
554
|
+
try {
|
|
555
|
+
const appConfigUrl = node_url.pathToFileURL(path.resolve(root, 'app', 'config', 'app.config.ts')).href;
|
|
556
|
+
// Dynamically import the TS module under tsx
|
|
557
|
+
const mod = (await import(appConfigUrl));
|
|
558
|
+
const app = mod.app;
|
|
559
|
+
const stages = mod.stages;
|
|
560
|
+
const globalKeys = Array.isArray(app?.global?.envKeys)
|
|
561
|
+
? app.global.envKeys
|
|
562
|
+
: [];
|
|
563
|
+
const stageKeys = Array.isArray(app?.stage?.envKeys)
|
|
564
|
+
? app.stage.envKeys
|
|
565
|
+
: [];
|
|
566
|
+
const globalParams = (stages?.default).params ?? {};
|
|
567
|
+
const stageParams = (stages?.[stage]).params ?? {};
|
|
568
|
+
const seedPair = (key, from) => {
|
|
569
|
+
if (key in process.env)
|
|
570
|
+
return;
|
|
571
|
+
const val = from[key];
|
|
572
|
+
if (val === undefined)
|
|
573
|
+
return;
|
|
574
|
+
if (typeof val === 'string') {
|
|
575
|
+
process.env[key] = val;
|
|
576
|
+
if (verbose)
|
|
577
|
+
console.log(`[dev] env: ${key}=${val}`);
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
if (typeof val === 'number' || typeof val === 'boolean') {
|
|
581
|
+
const v = String(val);
|
|
582
|
+
process.env[key] = v;
|
|
583
|
+
if (verbose)
|
|
584
|
+
console.log(`[dev] env: ${key}=${v}`);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
// Non-primitive; skip to avoid [object Object] surprise.
|
|
588
|
+
if (verbose)
|
|
589
|
+
console.log(`[dev] env: skip ${key} (non-primitive)`);
|
|
590
|
+
};
|
|
591
|
+
for (const k of globalKeys) {
|
|
592
|
+
if (typeof k === 'string') {
|
|
593
|
+
seedPair(k, globalParams);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
for (const k of stageKeys) {
|
|
597
|
+
if (typeof k === 'string') {
|
|
598
|
+
seedPair(k, stageParams);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
// Ensure STAGE itself is present as a last resort
|
|
602
|
+
if (!process.env.STAGE) {
|
|
603
|
+
process.env.STAGE = stage;
|
|
604
|
+
if (verbose)
|
|
605
|
+
console.log(`[dev] env: STAGE=${stage}`);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
// Fallback: seed STAGE only
|
|
610
|
+
if (!process.env.STAGE) {
|
|
611
|
+
process.env.STAGE = stage;
|
|
612
|
+
if (verbose)
|
|
613
|
+
console.log(`[dev] env: STAGE=${stage}`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
const resolveTsxCommand = (root, tsEntry) => {
|
|
619
|
+
const localCli = path.resolve(root, 'node_modules', 'tsx', 'dist', 'cli.js');
|
|
620
|
+
if (fs.existsSync(localCli)) {
|
|
621
|
+
// Normalize to POSIX separators for cross-platform comparisons (tests)
|
|
622
|
+
const localCliPosix = localCli.split(path.sep).join('/');
|
|
623
|
+
return {
|
|
624
|
+
cmd: process.execPath,
|
|
625
|
+
args: [localCliPosix, tsEntry],
|
|
626
|
+
shell: false,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
const probe = node_child_process.spawnSync(process.platform === 'win32' ? 'tsx.cmd' : 'tsx', ['--version'], { shell: true });
|
|
630
|
+
if (typeof probe.status === 'number' && probe.status === 0) {
|
|
631
|
+
return {
|
|
632
|
+
cmd: process.platform === 'win32' ? 'tsx.cmd' : 'tsx',
|
|
633
|
+
args: [tsEntry],
|
|
634
|
+
shell: true,
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
throw new Error('Inline requires tsx. Install it with "npm i -D tsx", or run "smoz dev -l offline".');
|
|
638
|
+
};
|
|
639
|
+
const launchInline = async (root, opts) => {
|
|
640
|
+
// Resolve the installed smoz package root robustly (works from compiled CLI and tsx ESM):
|
|
641
|
+
// Prefer __dirname when available (CJS), otherwise derive from import.meta.url (ESM).
|
|
642
|
+
const here = typeof __dirname === 'string'
|
|
643
|
+
? __dirname
|
|
644
|
+
: path.dirname(node_url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href))));
|
|
645
|
+
const pkgRoot = packageDirectory.packageDirectorySync({ cwd: here }) ?? process.cwd();
|
|
646
|
+
const tsEntry = path.resolve(pkgRoot, 'src', 'cli', 'local', 'inline.server', 'index.ts');
|
|
647
|
+
const makeTsx = () => {
|
|
648
|
+
const { cmd, args, shell } = resolveTsxCommand(root, tsEntry);
|
|
649
|
+
return node_child_process.spawn(cmd, args, {
|
|
650
|
+
cwd: root,
|
|
651
|
+
stdio: 'inherit',
|
|
652
|
+
shell,
|
|
653
|
+
// Enable tsconfig paths for "@/..." during TS fallback.
|
|
654
|
+
env: {
|
|
655
|
+
...process.env,
|
|
656
|
+
TSX_TSCONFIG_PATHS: '1',
|
|
657
|
+
SMOZ_STAGE: opts.stage,
|
|
658
|
+
SMOZ_PORT: String(opts.port),
|
|
659
|
+
SMOZ_VERBOSE: opts.verbose ? '1' : '',
|
|
660
|
+
},
|
|
661
|
+
});
|
|
662
|
+
};
|
|
663
|
+
let child = makeTsx();
|
|
664
|
+
const close = async () => new Promise((resolve) => {
|
|
665
|
+
// If the process has already exited (exitCode set), resolve immediately.
|
|
666
|
+
if (child.exitCode !== null) {
|
|
667
|
+
resolve();
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
child.once('exit', () => {
|
|
671
|
+
resolve();
|
|
672
|
+
});
|
|
673
|
+
child.kill('SIGTERM');
|
|
674
|
+
setTimeout(() => {
|
|
675
|
+
resolve();
|
|
676
|
+
}, 1500);
|
|
677
|
+
});
|
|
678
|
+
const restart = async () => {
|
|
679
|
+
await close();
|
|
680
|
+
child = makeTsx();
|
|
681
|
+
};
|
|
682
|
+
return { close, restart };
|
|
683
|
+
};
|
|
684
|
+
|
|
537
685
|
const runDev = async (root, opts) => {
|
|
538
686
|
const verbose = !!opts.verbose;
|
|
539
687
|
const stage = typeof opts.stage === 'string'
|
|
@@ -547,7 +695,8 @@ const runDev = async (root, opts) => {
|
|
|
547
695
|
if (verbose)
|
|
548
696
|
console.warn('[dev] env seeding warning:', e.message);
|
|
549
697
|
}
|
|
550
|
-
const
|
|
698
|
+
const modeInitial = opts.local;
|
|
699
|
+
const mode = modeInitial;
|
|
551
700
|
const port = typeof opts.port === 'number' ? opts.port : 0;
|
|
552
701
|
if (verbose) {
|
|
553
702
|
console.log(`[dev] options: register=${String(opts.register)} ` +
|
|
@@ -633,8 +782,10 @@ const runDev = async (root, opts) => {
|
|
|
633
782
|
offline = await launchOffline(root, { stage, port, verbose });
|
|
634
783
|
}
|
|
635
784
|
else if (mode === 'inline') {
|
|
785
|
+
// Hard error on failure; no fallback to offline.
|
|
636
786
|
inlineChild = await launchInline(root, { stage, port, verbose });
|
|
637
|
-
}
|
|
787
|
+
}
|
|
788
|
+
// Watch sources
|
|
638
789
|
const globs = [
|
|
639
790
|
path.join(root, 'app', 'functions', '**', 'lambda.ts'),
|
|
640
791
|
path.join(root, 'app', 'functions', '**', 'openapi.ts'),
|
|
@@ -667,130 +818,6 @@ const runDev = async (root, opts) => {
|
|
|
667
818
|
});
|
|
668
819
|
});
|
|
669
820
|
};
|
|
670
|
-
const inferDefaultStage = (root, verbose) => {
|
|
671
|
-
// Prefer “dev”; if app.config.ts is available and we can inspect it cheaply later, expand behavior.
|
|
672
|
-
// For now, return 'dev' (explicit selection via --stage remains available).
|
|
673
|
-
if (verbose)
|
|
674
|
-
console.log('[dev] inferring stage: dev (explicit --stage overrides)');
|
|
675
|
-
return 'dev';
|
|
676
|
-
};
|
|
677
|
-
const seedEnvForStage = async (root, stage, verbose) => {
|
|
678
|
-
// Best effort: import the app config to read declared env keys and concrete values.
|
|
679
|
-
// Preserve existing process.env values; only seed when unset.
|
|
680
|
-
try {
|
|
681
|
-
const appConfigUrl = node_url.pathToFileURL(path.resolve(root, 'app', 'config', 'app.config.ts')).href;
|
|
682
|
-
// Dynamically import the TS module under tsx
|
|
683
|
-
const mod = (await import(appConfigUrl));
|
|
684
|
-
const app = mod.app;
|
|
685
|
-
const stages = mod.stages;
|
|
686
|
-
const globalKeys = Array.isArray(app?.global?.envKeys)
|
|
687
|
-
? app.global.envKeys
|
|
688
|
-
: [];
|
|
689
|
-
const stageKeys = Array.isArray(app?.stage?.envKeys)
|
|
690
|
-
? app.stage.envKeys
|
|
691
|
-
: [];
|
|
692
|
-
const globalParams = (stages?.default).params ?? {};
|
|
693
|
-
const stageParams = (stages?.[stage]).params ?? {};
|
|
694
|
-
const seedPair = (key, from) => {
|
|
695
|
-
if (key in process.env)
|
|
696
|
-
return;
|
|
697
|
-
const val = from[key];
|
|
698
|
-
if (val === undefined)
|
|
699
|
-
return;
|
|
700
|
-
if (typeof val === 'string') {
|
|
701
|
-
process.env[key] = val;
|
|
702
|
-
if (verbose)
|
|
703
|
-
console.log(`[dev] env: ${key}=${val}`);
|
|
704
|
-
return;
|
|
705
|
-
}
|
|
706
|
-
if (typeof val === 'number' || typeof val === 'boolean') {
|
|
707
|
-
const v = String(val);
|
|
708
|
-
process.env[key] = v;
|
|
709
|
-
if (verbose)
|
|
710
|
-
console.log(`[dev] env: ${key}=${v}`);
|
|
711
|
-
return;
|
|
712
|
-
}
|
|
713
|
-
// Non-primitive; skip to avoid [object Object] surprise.
|
|
714
|
-
if (verbose)
|
|
715
|
-
console.log(`[dev] env: skip ${key} (non-primitive)`);
|
|
716
|
-
};
|
|
717
|
-
for (const k of globalKeys) {
|
|
718
|
-
if (typeof k === 'string') {
|
|
719
|
-
seedPair(k, globalParams);
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
for (const k of stageKeys) {
|
|
723
|
-
if (typeof k === 'string') {
|
|
724
|
-
seedPair(k, stageParams);
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
// Ensure STAGE itself is present as a last resort
|
|
728
|
-
if (!process.env.STAGE) {
|
|
729
|
-
process.env.STAGE = stage;
|
|
730
|
-
if (verbose)
|
|
731
|
-
console.log(`[dev] env: STAGE=${stage}`);
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
catch {
|
|
735
|
-
// Fallback: seed STAGE only
|
|
736
|
-
if (!process.env.STAGE) {
|
|
737
|
-
process.env.STAGE = stage;
|
|
738
|
-
if (verbose)
|
|
739
|
-
console.log(`[dev] env: STAGE=${stage}`);
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
};
|
|
743
|
-
// Inline local runner: spawn tsx to run a TS server script.
|
|
744
|
-
const launchInline = async (root, opts) => {
|
|
745
|
-
const { spawn } = await import('node:child_process');
|
|
746
|
-
const path = await import('node:path');
|
|
747
|
-
const fs = await import('node:fs');
|
|
748
|
-
const tsxCli = path.resolve(root, 'node_modules', 'tsx', 'dist', 'cli.js');
|
|
749
|
-
const entry = path.resolve(root, 'src', 'cli', 'local', 'inline.server.ts');
|
|
750
|
-
if (!fs.existsSync(entry)) {
|
|
751
|
-
throw new Error('inline server entry missing: src/cli/local/inline.server.ts');
|
|
752
|
-
}
|
|
753
|
-
const args = [tsxCli, entry];
|
|
754
|
-
if (!fs.existsSync(tsxCli)) {
|
|
755
|
-
// Fallback to PATH resolution
|
|
756
|
-
args.shift();
|
|
757
|
-
args.unshift(process.platform === 'win32' ? 'tsx.cmd' : 'tsx');
|
|
758
|
-
}
|
|
759
|
-
else {
|
|
760
|
-
args.unshift(process.execPath);
|
|
761
|
-
}
|
|
762
|
-
const spawnChild = () => spawn(args[0], args.slice(1), {
|
|
763
|
-
cwd: root,
|
|
764
|
-
stdio: 'inherit',
|
|
765
|
-
shell: !args[0].endsWith('.js'),
|
|
766
|
-
env: {
|
|
767
|
-
...process.env,
|
|
768
|
-
SMOZ_STAGE: opts.stage,
|
|
769
|
-
SMOZ_PORT: String(opts.port),
|
|
770
|
-
SMOZ_VERBOSE: opts.verbose ? '1' : '',
|
|
771
|
-
},
|
|
772
|
-
});
|
|
773
|
-
let child = spawnChild();
|
|
774
|
-
const close = async () => new Promise((resolve) => {
|
|
775
|
-
// If the process has already exited (exitCode set), resolve immediately.
|
|
776
|
-
if (child.exitCode !== null) {
|
|
777
|
-
resolve();
|
|
778
|
-
return;
|
|
779
|
-
}
|
|
780
|
-
child.once('exit', () => {
|
|
781
|
-
resolve();
|
|
782
|
-
});
|
|
783
|
-
child.kill('SIGTERM');
|
|
784
|
-
setTimeout(() => {
|
|
785
|
-
resolve();
|
|
786
|
-
}, 1500);
|
|
787
|
-
});
|
|
788
|
-
const restart = async () => {
|
|
789
|
-
await close();
|
|
790
|
-
child = spawnChild();
|
|
791
|
-
};
|
|
792
|
-
return { close, restart };
|
|
793
|
-
};
|
|
794
821
|
|
|
795
822
|
const writeIfAbsent = async (outFile, content) => {
|
|
796
823
|
if (fs.existsSync(outFile))
|
|
@@ -1426,7 +1453,7 @@ const main = () => {
|
|
|
1426
1453
|
.option('-l, --local [mode]', 'Local server mode: inline|offline', 'inline')
|
|
1427
1454
|
.option('-s, --stage <name>', 'Stage name (default inferred)')
|
|
1428
1455
|
.option('-p, --port <n>', 'Port (0=random)', (v) => Number(v), 0)
|
|
1429
|
-
.option('-
|
|
1456
|
+
.option('-V, --verbose', 'Verbose logging', false)
|
|
1430
1457
|
.action(async (opts) => {
|
|
1431
1458
|
try {
|
|
1432
1459
|
const cfg = readSmozConfig(root).cliDefaults?.dev ?? {};
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { URL, pathToFileURL } from 'node:url';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const firstVal = (v) => Array.isArray(v) ? v[0] : v;
|
|
6
|
+
const arrVal = (v) => typeof v === 'string' ? [v] : Array.isArray(v) ? v : undefined;
|
|
7
|
+
const toHeaders = (raw) => {
|
|
8
|
+
const single = {};
|
|
9
|
+
const multi = {};
|
|
10
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
11
|
+
const fv = firstVal(v);
|
|
12
|
+
const av = arrVal(v);
|
|
13
|
+
if (typeof fv === 'string')
|
|
14
|
+
single[k] = fv;
|
|
15
|
+
if (Array.isArray(av))
|
|
16
|
+
multi[k] = av;
|
|
17
|
+
}
|
|
18
|
+
return { single, multi };
|
|
19
|
+
};
|
|
20
|
+
const readBody = (req) => new Promise((resolve) => {
|
|
21
|
+
const chunks = [];
|
|
22
|
+
req.on('data', (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(String(c))));
|
|
23
|
+
req.on('end', () => {
|
|
24
|
+
resolve(Buffer.concat(chunks).toString('utf8'));
|
|
25
|
+
});
|
|
26
|
+
req.on('error', () => {
|
|
27
|
+
resolve('');
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
const match = (segs, pathName) => {
|
|
31
|
+
const parts = pathName
|
|
32
|
+
.replace(/^\/+|\/+$/g, '')
|
|
33
|
+
.split('/')
|
|
34
|
+
.filter(Boolean);
|
|
35
|
+
if (parts.length !== segs.length)
|
|
36
|
+
return { ok: false, params: {} };
|
|
37
|
+
const params = {};
|
|
38
|
+
for (let i = 0; i < segs.length; i += 1) {
|
|
39
|
+
const seg = segs[i];
|
|
40
|
+
const p = parts[i];
|
|
41
|
+
if (seg.literal) {
|
|
42
|
+
if (seg.literal !== p)
|
|
43
|
+
return { ok: false, params: {} };
|
|
44
|
+
}
|
|
45
|
+
else if (seg.key) {
|
|
46
|
+
params[seg.key] = p;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { ok: true, params };
|
|
50
|
+
};
|
|
51
|
+
const toEvent = async (req, route, params) => {
|
|
52
|
+
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
|
53
|
+
const { single, multi } = toHeaders(req.headers);
|
|
54
|
+
const method = (req.method ?? '').toUpperCase();
|
|
55
|
+
const stage = process.env.SMOZ_STAGE ?? 'dev';
|
|
56
|
+
const search = new URLSearchParams(url.search);
|
|
57
|
+
const query = {};
|
|
58
|
+
const mquery = {};
|
|
59
|
+
for (const key of Array.from(new Set(search.keys()))) {
|
|
60
|
+
const vals = search.getAll(key);
|
|
61
|
+
if (vals.length > 0) {
|
|
62
|
+
query[key] = vals[0];
|
|
63
|
+
mquery[key] = vals;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
let body = '';
|
|
67
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
68
|
+
body = await readBody(req);
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
httpMethod: method,
|
|
72
|
+
headers: single,
|
|
73
|
+
multiValueHeaders: multi,
|
|
74
|
+
body,
|
|
75
|
+
isBase64Encoded: false,
|
|
76
|
+
path: url.pathname,
|
|
77
|
+
queryStringParameters: Object.keys(query).length ? query : {},
|
|
78
|
+
multiValueQueryStringParameters: Object.keys(mquery).length ? mquery : {},
|
|
79
|
+
pathParameters: Object.keys(params).length ? params : {},
|
|
80
|
+
stageVariables: null,
|
|
81
|
+
resource: route.pattern,
|
|
82
|
+
requestContext: {
|
|
83
|
+
accountId: 'acc',
|
|
84
|
+
apiId: 'inline',
|
|
85
|
+
httpMethod: method,
|
|
86
|
+
identity: {},
|
|
87
|
+
path: url.pathname,
|
|
88
|
+
stage,
|
|
89
|
+
requestId: String(Date.now()),
|
|
90
|
+
requestTimeEpoch: Date.now(),
|
|
91
|
+
resourceId: 'res',
|
|
92
|
+
resourcePath: route.pattern,
|
|
93
|
+
authorizer: {},
|
|
94
|
+
protocol: 'HTTP/1.1',
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
const writeResult = (res, result) => {
|
|
99
|
+
const status = typeof result.statusCode === 'number' ? result.statusCode : 200;
|
|
100
|
+
const headers = result.headers ?? {};
|
|
101
|
+
const body = typeof result.body === 'string' ? result.body : '';
|
|
102
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
103
|
+
if (typeof v === 'string')
|
|
104
|
+
res.setHeader(k, v);
|
|
105
|
+
}
|
|
106
|
+
res.statusCode = status;
|
|
107
|
+
res.end(body);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Load downstream registers to populate the app registry.
|
|
112
|
+
* Tries TS/JS candidates and logs which one was loaded when SMOZ_VERBOSE is set.
|
|
113
|
+
*/
|
|
114
|
+
const loadRegisters = async (root) => {
|
|
115
|
+
const candidates = [
|
|
116
|
+
path.resolve(root, 'app', 'generated', 'register.functions.ts'),
|
|
117
|
+
path.resolve(root, 'app', 'generated', 'register.functions.mts'),
|
|
118
|
+
path.resolve(root, 'app', 'generated', 'register.functions.js'),
|
|
119
|
+
path.resolve(root, 'app', 'generated', 'register.functions.mjs'),
|
|
120
|
+
];
|
|
121
|
+
for (const p of candidates) {
|
|
122
|
+
try {
|
|
123
|
+
const url = pathToFileURL(p).href;
|
|
124
|
+
await import(url);
|
|
125
|
+
if (process.env.SMOZ_VERBOSE) {
|
|
126
|
+
const rel = path.relative(root, p).split(path.sep).join('/');
|
|
127
|
+
console.log('[inline] registers loaded:', rel);
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// try next candidate
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
console.warn('[inline] Could not load app/generated/register.functions.*. Run "npx smoz register" before inline dev.');
|
|
136
|
+
};
|
|
137
|
+
/**
|
|
138
|
+
* Load the App instance (TS source) from the downstream project.
|
|
139
|
+
* Returns only the surface needed by the inline server.
|
|
140
|
+
*/
|
|
141
|
+
const loadApp = async (root) => {
|
|
142
|
+
const p = path.resolve(root, 'app', 'config', 'app.config.ts');
|
|
143
|
+
const url = pathToFileURL(p).href;
|
|
144
|
+
const mod = (await import(url));
|
|
145
|
+
const app = mod.app;
|
|
146
|
+
if (!app || typeof app.buildAllServerlessFunctions !== 'function') {
|
|
147
|
+
throw new Error('Failed to load app/config/app.config.ts (missing export "app").');
|
|
148
|
+
}
|
|
149
|
+
return app;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const splitPattern = (p) => p
|
|
153
|
+
.replace(/\\/g, '/')
|
|
154
|
+
.replace(/^\/+|\/+$/g, '')
|
|
155
|
+
.split('/')
|
|
156
|
+
.filter(Boolean)
|
|
157
|
+
.map((s) => s.startsWith('{') && s.endsWith('}')
|
|
158
|
+
? { key: s.slice(1, -1) }
|
|
159
|
+
: { literal: s });
|
|
160
|
+
/**
|
|
161
|
+
* Build route table from the downstream app's aggregated serverless functions.
|
|
162
|
+
*/
|
|
163
|
+
const loadHandlers = async (root, app) => {
|
|
164
|
+
const fns = app.buildAllServerlessFunctions();
|
|
165
|
+
const routes = [];
|
|
166
|
+
for (const [, defUnknown] of Object.entries(fns)) {
|
|
167
|
+
const def = defUnknown;
|
|
168
|
+
if (typeof def.handler !== 'string' || !Array.isArray(def.events))
|
|
169
|
+
continue;
|
|
170
|
+
const [moduleRel, exportName] = (() => {
|
|
171
|
+
const lastDot = def.handler.lastIndexOf('.');
|
|
172
|
+
if (lastDot < 0)
|
|
173
|
+
return [def.handler, 'handler'];
|
|
174
|
+
return [
|
|
175
|
+
def.handler.slice(0, lastDot),
|
|
176
|
+
def.handler.slice(lastDot + 1),
|
|
177
|
+
];
|
|
178
|
+
})();
|
|
179
|
+
// Resolve TS source module; dev runs via tsx so TS imports are OK
|
|
180
|
+
const candidates = [
|
|
181
|
+
path.resolve(root, `${moduleRel}.ts`),
|
|
182
|
+
path.resolve(root, `${moduleRel}.mts`),
|
|
183
|
+
path.resolve(root, `${moduleRel}.js`),
|
|
184
|
+
path.resolve(root, `${moduleRel}.mjs`),
|
|
185
|
+
];
|
|
186
|
+
// Always try the first candidate (TS is expected in authoring).
|
|
187
|
+
const modUrl = pathToFileURL(candidates[0]).href;
|
|
188
|
+
const mod = (await import(modUrl));
|
|
189
|
+
const handler = mod[exportName];
|
|
190
|
+
if (typeof handler !== 'function')
|
|
191
|
+
continue;
|
|
192
|
+
for (const evt of def.events) {
|
|
193
|
+
const httpEvt = evt
|
|
194
|
+
.http;
|
|
195
|
+
const method = (httpEvt?.method ?? '').toUpperCase();
|
|
196
|
+
const pattern = '/' + (httpEvt?.path ?? '').replace(/^\/+/, '');
|
|
197
|
+
if (!method || !pattern)
|
|
198
|
+
continue;
|
|
199
|
+
routes.push({
|
|
200
|
+
method,
|
|
201
|
+
pattern,
|
|
202
|
+
segs: splitPattern(pattern),
|
|
203
|
+
handlerRef: `${moduleRel}.${exportName}`,
|
|
204
|
+
handler: handler,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return routes;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Inline HTTP dev server (module entry).
|
|
213
|
+
*
|
|
214
|
+
* Decomposed from the original single-file implementation to improve
|
|
215
|
+
* readability and maintainability:
|
|
216
|
+
* - loaders.ts: register/app loaders
|
|
217
|
+
* - routes.ts: route table construction
|
|
218
|
+
* - http.ts: request/event mapping helpers
|
|
219
|
+
*/
|
|
220
|
+
const start = async () => {
|
|
221
|
+
const root = process.cwd();
|
|
222
|
+
// Ensure register side-effects are loaded so the registry has routes
|
|
223
|
+
await loadRegisters(root);
|
|
224
|
+
// Load the App instance from the same TS source as the registers
|
|
225
|
+
const app = await loadApp(root);
|
|
226
|
+
const routes = await loadHandlers(root, app);
|
|
227
|
+
const portEnv = process.env.SMOZ_PORT;
|
|
228
|
+
const port = typeof portEnv === 'string' && portEnv.length > 0 ? Number(portEnv) : 0;
|
|
229
|
+
const server = http.createServer((req, res) => {
|
|
230
|
+
void (async () => {
|
|
231
|
+
try {
|
|
232
|
+
const method = (req.method ?? '').toUpperCase();
|
|
233
|
+
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
|
234
|
+
// Allow HEAD to match GET routes; the wrapped handler/middleware will
|
|
235
|
+
// short-circuit HEAD requests to 200 {} and set Content-Type.
|
|
236
|
+
const searchMethod = method === 'HEAD' ? 'GET' : method;
|
|
237
|
+
const route = routes.find((r) => r.method === searchMethod && match(r.segs, url.pathname).ok);
|
|
238
|
+
if (!route) {
|
|
239
|
+
res.statusCode = 404;
|
|
240
|
+
res.setHeader('Content-Type', 'application/json');
|
|
241
|
+
res.end(JSON.stringify({ error: 'Not Found' }));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const { params } = match(route.segs, url.pathname);
|
|
245
|
+
const evt = await toEvent(req, route, params);
|
|
246
|
+
const result = await route.handler(evt, {
|
|
247
|
+
awsRequestId: String(Date.now()),
|
|
248
|
+
callbackWaitsForEmptyEventLoop: false,
|
|
249
|
+
functionName: 'inline',
|
|
250
|
+
functionVersion: '$LATEST',
|
|
251
|
+
invokedFunctionArn: 'arn:inline',
|
|
252
|
+
logGroupName: 'lg',
|
|
253
|
+
logStreamName: 'ls',
|
|
254
|
+
memoryLimitInMB: '256',
|
|
255
|
+
getRemainingTimeInMillis: () => 30000,
|
|
256
|
+
done: () => undefined,
|
|
257
|
+
fail: () => undefined,
|
|
258
|
+
succeed: () => undefined,
|
|
259
|
+
});
|
|
260
|
+
writeResult(res, result);
|
|
261
|
+
}
|
|
262
|
+
catch (e) {
|
|
263
|
+
res.statusCode = 500;
|
|
264
|
+
res.setHeader('Content-Type', 'application/json');
|
|
265
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
266
|
+
}
|
|
267
|
+
})();
|
|
268
|
+
});
|
|
269
|
+
server.listen(port, () => {
|
|
270
|
+
const addr = server.address();
|
|
271
|
+
const resolved = typeof addr === 'object' && addr && 'port' in addr
|
|
272
|
+
? addr.port
|
|
273
|
+
: port;
|
|
274
|
+
// Print route table
|
|
275
|
+
// Keep output consistent with existing tests:
|
|
276
|
+
// "[inline] listening on http://localhost:<port>"
|
|
277
|
+
console.log('[inline] listening on http://localhost:' + String(resolved));
|
|
278
|
+
const header = '[inline] routes:\n';
|
|
279
|
+
if (routes.length === 0) {
|
|
280
|
+
console.log(header + ' (none found)');
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
const body = routes
|
|
284
|
+
.map((r) => ' ' +
|
|
285
|
+
r.method.padEnd(6) +
|
|
286
|
+
' ' +
|
|
287
|
+
r.pattern +
|
|
288
|
+
' -> ' +
|
|
289
|
+
r.handlerRef)
|
|
290
|
+
.join('\n');
|
|
291
|
+
console.log(header + body);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
};
|
|
295
|
+
// Start immediately when run via tsx
|
|
296
|
+
void start();
|
package/package.json
CHANGED
|
@@ -9,6 +9,18 @@ import { fileURLToPath } from 'url';
|
|
|
9
9
|
const tsconfigRootDir = dirname(fileURLToPath(import.meta.url));
|
|
10
10
|
|
|
11
11
|
export default [
|
|
12
|
+
{
|
|
13
|
+
ignores: [
|
|
14
|
+
'.serverless/**',
|
|
15
|
+
'.stan/**',
|
|
16
|
+
'**/.tsbuild/**',
|
|
17
|
+
'**/generated/**',
|
|
18
|
+
'coverage/**',
|
|
19
|
+
'dist/**',
|
|
20
|
+
'docs/**',
|
|
21
|
+
'node_modules/**',
|
|
22
|
+
],
|
|
23
|
+
},
|
|
12
24
|
eslint.configs.recommended,
|
|
13
25
|
...tseslint.configs.strictTypeChecked,
|
|
14
26
|
prettierConfig,
|
|
@@ -107,9 +107,6 @@ const config: AWS = {
|
|
|
107
107
|
versionFunctions: false,
|
|
108
108
|
},
|
|
109
109
|
functions: app.buildAllServerlessFunctions() as NonNullable<AWS['functions']>,
|
|
110
|
-
// Optional esbuild configuration (parity with /app fixture):
|
|
111
|
-
// These booleans are driven by global params in app.config.ts:
|
|
112
|
-
// ESB_MINIFY, ESB_SOURCEMAP
|
|
113
110
|
build: {
|
|
114
111
|
esbuild: {
|
|
115
112
|
bundle: true,
|
|
@@ -7,12 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
"tsBuildInfoFile": ".tsbuild/tsconfig.tsbuildinfo"
|
|
9
9
|
},
|
|
10
|
-
"exclude": [
|
|
11
|
-
".serverless/**",
|
|
12
|
-
"node_modules/**",
|
|
13
|
-
"app/generated/**",
|
|
14
|
-
"dist/**"
|
|
15
|
-
],
|
|
10
|
+
"exclude": [".serverless/**", "node_modules/**", "app/generated/**"],
|
|
16
11
|
"extends": "./tsconfig.base.json",
|
|
17
12
|
"include": ["**/*", "**/*.json"]
|
|
18
13
|
}
|