almostnode 0.2.7 → 0.2.8

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.
Files changed (59) hide show
  1. package/README.md +1 -1
  2. package/dist/__sw__.js +80 -84
  3. package/dist/assets/{runtime-worker-B8_LZkBX.js → runtime-worker-D8VYeuKv.js} +1448 -1121
  4. package/dist/assets/runtime-worker-D8VYeuKv.js.map +1 -0
  5. package/dist/frameworks/code-transforms.d.ts.map +1 -1
  6. package/dist/frameworks/next-config-parser.d.ts +16 -0
  7. package/dist/frameworks/next-config-parser.d.ts.map +1 -0
  8. package/dist/frameworks/next-dev-server.d.ts +6 -6
  9. package/dist/frameworks/next-dev-server.d.ts.map +1 -1
  10. package/dist/frameworks/next-html-generator.d.ts +35 -0
  11. package/dist/frameworks/next-html-generator.d.ts.map +1 -0
  12. package/dist/frameworks/next-shims.d.ts +79 -0
  13. package/dist/frameworks/next-shims.d.ts.map +1 -0
  14. package/dist/index.cjs +2895 -2454
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.ts +3 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.mjs +3208 -2782
  19. package/dist/index.mjs.map +1 -1
  20. package/dist/runtime.d.ts +20 -0
  21. package/dist/runtime.d.ts.map +1 -1
  22. package/dist/server-bridge.d.ts +2 -0
  23. package/dist/server-bridge.d.ts.map +1 -1
  24. package/dist/shims/crypto.d.ts +2 -0
  25. package/dist/shims/crypto.d.ts.map +1 -1
  26. package/dist/shims/esbuild.d.ts.map +1 -1
  27. package/dist/shims/fs.d.ts.map +1 -1
  28. package/dist/shims/http.d.ts +29 -0
  29. package/dist/shims/http.d.ts.map +1 -1
  30. package/dist/shims/path.d.ts.map +1 -1
  31. package/dist/shims/stream.d.ts.map +1 -1
  32. package/dist/shims/vfs-adapter.d.ts.map +1 -1
  33. package/dist/shims/ws.d.ts +2 -0
  34. package/dist/shims/ws.d.ts.map +1 -1
  35. package/dist/utils/binary-encoding.d.ts +13 -0
  36. package/dist/utils/binary-encoding.d.ts.map +1 -0
  37. package/dist/virtual-fs.d.ts.map +1 -1
  38. package/package.json +4 -4
  39. package/src/convex-app-demo-entry.ts +229 -35
  40. package/src/frameworks/code-transforms.ts +5 -1
  41. package/src/frameworks/next-config-parser.ts +140 -0
  42. package/src/frameworks/next-dev-server.ts +76 -1675
  43. package/src/frameworks/next-html-generator.ts +597 -0
  44. package/src/frameworks/next-shims.ts +1050 -0
  45. package/src/frameworks/tailwind-config-loader.ts +1 -1
  46. package/src/index.ts +2 -0
  47. package/src/runtime.ts +94 -15
  48. package/src/server-bridge.ts +61 -28
  49. package/src/shims/crypto.ts +13 -0
  50. package/src/shims/esbuild.ts +4 -1
  51. package/src/shims/fs.ts +9 -11
  52. package/src/shims/http.ts +309 -3
  53. package/src/shims/path.ts +6 -13
  54. package/src/shims/stream.ts +12 -26
  55. package/src/shims/vfs-adapter.ts +5 -2
  56. package/src/shims/ws.ts +92 -2
  57. package/src/utils/binary-encoding.ts +43 -0
  58. package/src/virtual-fs.ts +7 -15
  59. package/dist/assets/runtime-worker-B8_LZkBX.js.map +0 -1
@@ -516,8 +516,11 @@ async function deployToConvex(adminKey: string): Promise<void> {
516
516
  vfs.writeFileSync('/package.json', packageJson);
517
517
 
518
518
  // Create convex.json in /project
519
+ // codegen.fileType: "ts" ensures codegen creates .ts files that esbuild can resolve
520
+ // (default is "js/dts" which creates .js + .d.ts, but our source imports expect .ts)
519
521
  vfs.writeFileSync('/project/convex.json', JSON.stringify({
520
- functions: "convex/"
522
+ functions: "convex/",
523
+ codegen: { fileType: "ts" }
521
524
  }, null, 2));
522
525
 
523
526
  // Clean up /project/convex/ completely to ensure fresh state
@@ -550,19 +553,12 @@ async function deployToConvex(adminKey: string): Promise<void> {
550
553
  }
551
554
  vfs.mkdirSync('/project/convex', { recursive: true });
552
555
 
553
- // Also clean /convex/_generated to ensure fresh generation
554
- if (vfs.existsSync('/convex/_generated')) {
555
- log('Cleaning /convex/_generated directory...');
556
- try {
557
- const files = vfs.readdirSync('/convex/_generated');
558
- for (const file of files) {
559
- vfs.unlinkSync(`/convex/_generated/${file}`);
560
- }
561
- vfs.rmdirSync('/convex/_generated');
562
- } catch (e) {
563
- log(`Warning: Could not remove /convex/_generated: ${e}`, 'warn');
564
- }
556
+ // Remove /project/.env.local before CLI runs so we can detect when the new deployment creates it.
557
+ if (vfs.existsSync('/project/.env.local')) {
558
+ vfs.unlinkSync('/project/.env.local');
565
559
  }
560
+ // NOTE: Do NOT delete /project/convex/_generated/ — esbuild needs those files to resolve
561
+ // imports like `from "./_generated/server"` in user code. The CLI's codegen will update them.
566
562
 
567
563
  // Create convex config files (BOTH .ts and .js required!)
568
564
  const convexConfig = `import { defineApp } from "convex/server";
@@ -580,14 +576,17 @@ export default app;
580
576
  for (const file of convexFiles) {
581
577
  const srcPath = `/convex/${file}`;
582
578
  const destPath = `/project/convex/${file}`;
583
- // Skip _generated directory and only copy files (not directories)
584
- if (file === '_generated') continue;
585
579
  try {
586
580
  const stat = vfs.statSync(srcPath);
587
581
  if (stat.isFile()) {
588
582
  const content = vfs.readFileSync(srcPath, 'utf8');
589
583
  vfs.writeFileSync(destPath, content);
590
- log(` Copied ${file}`);
584
+ log(` Copied ${file} (${content.length}b)`);
585
+ // For todos.ts, show the mutation handler to verify modifications
586
+ if (file === 'todos.ts') {
587
+ const handlerMatch = content.match(/title:\s*args\.title[^\n]*/);
588
+ log(` → todos.ts mutation: ${handlerMatch ? handlerMatch[0] : 'handler not found'}`);
589
+ }
591
590
  }
592
591
  } catch (e) {
593
592
  log(` Warning: Could not copy ${srcPath}: ${e}`, 'warn');
@@ -635,11 +634,14 @@ export default app;
635
634
  ];
636
635
  for (const file of requiredFiles) {
637
636
  if (vfs.existsSync(file)) {
638
- // For convex source files, show content preview to verify it's fresh
639
637
  if (file.includes('/project/convex/') && (file.endsWith('.ts') || file.endsWith('.js'))) {
640
638
  const content = vfs.readFileSync(file, 'utf8');
641
- const preview = content.substring(0, 60).replace(/\n/g, '\\n');
642
- log(` ✓ ${file} (${content.length}b): "${preview}..."`, 'success');
639
+ log(` ✓ ${file} (${content.length}b)`, 'success');
640
+ // For todos.ts, verify the mutation content the CLI will push
641
+ if (file.endsWith('todos.ts')) {
642
+ const titleLine = content.match(/title:\s*args\.title[^\n]*/);
643
+ log(` → CLI will push: ${titleLine ? titleLine[0].trim() : 'title line not found'}`, 'info');
644
+ }
643
645
  } else {
644
646
  log(` ✓ ${file}`, 'success');
645
647
  }
@@ -648,7 +650,159 @@ export default app;
648
650
  }
649
651
  }
650
652
 
651
- // Match working example exactly
653
+ // Patch CLI bundle: stub fetchDeploymentCanonicalSiteUrl
654
+ // This function was added in convex v1.31.7 and calls envGetInDeployment() to fetch CONVEX_SITE_URL
655
+ // from the deployment. The envGetInDeployment call hangs in our browser runtime because it uses
656
+ // a deployment API endpoint we can't handle. We derive the site URL from the deployment URL instead.
657
+ {
658
+ const cliBundlePath = '/project/node_modules/convex/dist/cli.bundle.cjs';
659
+ let cliSrc = vfs.readFileSync(cliBundlePath, 'utf8');
660
+
661
+ const fetchCanonSearch = [
662
+ 'async function fetchDeploymentCanonicalSiteUrl(ctx, options) {',
663
+ ' const result = await envGetInDeployment(ctx, options, "CONVEX_SITE_URL");',
664
+ ' if (typeof result !== "string") {',
665
+ ' return await ctx.crash({',
666
+ ' exitCode: 1,',
667
+ ' errorType: "invalid filesystem or env vars",',
668
+ ' printedMessage: "Invalid process.env.CONVEX_SITE_URL"',
669
+ ' });',
670
+ ' }',
671
+ ' return result;',
672
+ '}',
673
+ ].join('\n');
674
+ const fetchCanonReplace = [
675
+ 'async function fetchDeploymentCanonicalSiteUrl(ctx, options) {',
676
+ ' // Stubbed: derive site URL from deployment URL (.convex.cloud -> .convex.site)',
677
+ ' var siteUrl = (options?.deploymentUrl || "").replace(".convex.cloud", ".convex.site");',
678
+ ' return siteUrl || "https://placeholder.convex.site";',
679
+ '}',
680
+ ].join('\n');
681
+
682
+ let patched = cliSrc.replace(fetchCanonSearch, fetchCanonReplace);
683
+ const patch1Applied = patched !== cliSrc;
684
+ log(` Patch 1 (fetchDeploymentCanonicalSiteUrl): ${patch1Applied ? 'APPLIED' : 'already patched or not found'}`);
685
+
686
+ // Patch 2: Stub Sentry5.close() in flushAndExit so process.exit() actually fires.
687
+ // Without this patch, Sentry5.close() hangs forever and process.exit() is never called,
688
+ // making it impossible to detect when the CLI has finished pushing functions.
689
+ const flushAndExitSearch = [
690
+ 'async function flushAndExit(exitCode, err) {',
691
+ ' if (err) {',
692
+ ' Sentry5.captureException(err);',
693
+ ' }',
694
+ ' await Sentry5.close();',
695
+ ' return process.exit(exitCode);',
696
+ '}',
697
+ ].join('\n');
698
+ const flushAndExitReplace = [
699
+ 'async function flushAndExit(exitCode, err) {',
700
+ ' // Patched: skip Sentry5.close() which hangs in browser runtime',
701
+ ' var callerStack = new Error("flushAndExit-trace").stack || "";',
702
+ ' globalThis.__cliExitInfo = { code: exitCode, msg: err ? (err.message || String(err)) : null, stack: err ? (err.stack || "").substring(0, 2000) : null, callerStack: callerStack.substring(0, 3000) };',
703
+ ' return process.exit(exitCode);',
704
+ '}',
705
+ ].join('\n');
706
+
707
+ const beforePatch2 = patched;
708
+ patched = patched.replace(flushAndExitSearch, flushAndExitReplace);
709
+ let patch2Applied = patched !== beforePatch2;
710
+
711
+ // Also handle re-patching: if already patched with an older version
712
+ if (!patch2Applied) {
713
+ const oldPatchMarker = '// Patched: skip Sentry5.close()';
714
+ const markerIdx = patched.indexOf(oldPatchMarker);
715
+ if (markerIdx > -1) {
716
+ // Find the function boundaries around our marker
717
+ const funcStart = patched.lastIndexOf('async function flushAndExit(exitCode, err) {', markerIdx);
718
+ const funcEnd = patched.indexOf('\n}', markerIdx);
719
+ if (funcStart > -1 && funcEnd > -1) {
720
+ const oldFunc = patched.substring(funcStart, funcEnd + 2);
721
+ if (oldFunc !== flushAndExitReplace) {
722
+ patched = patched.replace(oldFunc, flushAndExitReplace);
723
+ patch2Applied = patched !== beforePatch2;
724
+ if (patch2Applied) log(' Patch 2: re-patched (upgraded old patch)');
725
+ }
726
+ }
727
+ }
728
+ }
729
+
730
+ log(` Patch 2 (flushAndExit/Sentry5): ${patch2Applied ? 'APPLIED' : 'already up-to-date'}`);
731
+
732
+ // Patch 3: Capture Crash error details in watchAndPush catch block before flushAndExit
733
+ // The Crash object is thrown by runPush() and caught in watchAndPush. It has errorType,
734
+ // printedMessage, message, and stack. We save these to __cliCrashInfo before exiting.
735
+ const watchPushSearch = ' if (cmdOptions.once) {\n await outerCtx.flushAndExit(1, e.errorType);\n }';
736
+ const watchPushReplace = ' if (cmdOptions.once) {\n globalThis.__cliCrashInfo = { errorType: e.errorType, printedMessage: e.printedMessage || null, message: e.message || null, stack: (e.stack || "").substring(0, 2000) };\n await outerCtx.flushAndExit(1, e.errorType);\n }';
737
+ const beforePatch3 = patched;
738
+ patched = patched.replace(watchPushSearch, watchPushReplace);
739
+ const patch3Applied = patched !== beforePatch3;
740
+ log(` Patch 3 (watchAndPush crash details): ${patch3Applied ? 'APPLIED' : 'already patched or not found'}`);
741
+
742
+ // Patch 4: Skip post-esbuild file size mismatch check in doEsbuild
743
+ // The CLI checks that each bundled file's size hasn't changed after esbuild runs.
744
+ // In our browser VFS, stat().size may differ from esbuild's metafile byte count
745
+ // (e.g., UTF-8 encoding differences), causing a false "transient" crash.
746
+ // We skip the size comparison since VFS files can't change from external sources.
747
+ const sizeCheckSearch = ' if (st.size !== input.bytes) {\n logWarning(\n `Bundled file ${absPath} changed right after esbuild invocation`\n );\n return await ctx.crash({\n exitCode: 1,\n errorType: "transient",\n printedMessage: null\n });\n }';
748
+ const sizeCheckReplace = ' // Patched: skip file size check (VFS stat size may differ from esbuild byte count)';
749
+ const beforePatch4 = patched;
750
+ patched = patched.replace(sizeCheckSearch, sizeCheckReplace);
751
+ const patch4Applied = patched !== beforePatch4;
752
+ log(` Patch 4 (skip file size check): ${patch4Applied ? 'APPLIED' : 'already patched or not found'}`);
753
+
754
+
755
+ if (patched !== cliSrc) {
756
+ vfs.writeFileSync(cliBundlePath, patched);
757
+ log('CLI bundle patched and saved');
758
+ } else {
759
+ log('CLI bundle already patched (no changes needed)');
760
+ }
761
+ }
762
+
763
+ // Set up process.exit listener BEFORE running CLI.
764
+ // The CLI calls flushAndExit → process.exit(0) when deployment completes.
765
+ // This is the most reliable completion signal since it fires AFTER the push POST finishes.
766
+ const cliExitPromise = new Promise<number>((resolve) => {
767
+ const proc = cliRuntime!.getProcess();
768
+ proc.on('exit', (code: unknown) => {
769
+ log(`CLI process exited with code ${code}`);
770
+ const crashInfo = (globalThis as any).__cliCrashInfo;
771
+ if (crashInfo) {
772
+ log(`CLI CRASH: [${crashInfo.errorType}] msg=${crashInfo.printedMessage || crashInfo.message || '(no message)'}`, 'error');
773
+ if (crashInfo.stack) log(`CLI CRASH STACK: ${crashInfo.stack.substring(0, 500)}`, 'error');
774
+ }
775
+ const exitInfo = (globalThis as any).__cliExitInfo;
776
+ if (exitInfo) {
777
+ log(`CLI exit: code=${exitInfo.code} err=${exitInfo.msg || 'none'}`, exitInfo.code === 0 ? 'success' : 'error');
778
+ }
779
+ resolve(code as number);
780
+ });
781
+ });
782
+
783
+ // Intercept fetch to log Convex API push calls
784
+ const origFetch = globalThis.fetch;
785
+ globalThis.fetch = async function(input: RequestInfo | URL, init?: RequestInit) {
786
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
787
+ if (url.includes('convex.cloud') || url.includes('convex.site')) {
788
+ const method = init?.method || 'GET';
789
+ log(`[fetch] ${method} ${url.substring(0, 120)}`);
790
+ try {
791
+ const resp = await origFetch.call(globalThis, input, init);
792
+ log(`[fetch] ${method} ${url.substring(0, 80)} → ${resp.status} ${resp.statusText}`);
793
+ if (!resp.ok) {
794
+ const text = await resp.clone().text();
795
+ log(`[fetch] ERROR body: ${text.substring(0, 500)}`, 'error');
796
+ }
797
+ return resp;
798
+ } catch (e) {
799
+ log(`[fetch] ${method} ${url.substring(0, 80)} → NETWORK ERROR: ${(e as Error).message}`, 'error');
800
+ throw e;
801
+ }
802
+ }
803
+ return origFetch.call(globalThis, input, init);
804
+ } as typeof fetch;
805
+
652
806
  const cliCode = `
653
807
  // Set environment for Convex CLI
654
808
  process.env.CONVEX_DEPLOY_KEY = '${adminKey}';
@@ -656,35 +810,51 @@ export default app;
656
810
  // Set CLI arguments
657
811
  process.argv = ['node', 'convex', 'dev', '--once'];
658
812
 
659
- // Run the CLI
813
+ // Load and execute the CLI bundle
660
814
  require('./node_modules/convex/dist/cli.bundle.cjs');
661
815
  `;
662
816
 
817
+ // Capture unhandled rejections from CLI async code (void main())
818
+ const rejectionHandler = (event: PromiseRejectionEvent) => {
819
+ const err = event.reason;
820
+ const msg = err?.message || String(err);
821
+ if (!msg.includes('Process exited with code')) {
822
+ log(`[CLI ASYNC ERROR] ${msg}`, 'error');
823
+ if (err?.stack) log(`[CLI ASYNC STACK] ${err.stack.substring(0, 500)}`, 'error');
824
+ }
825
+ };
826
+ globalThis.addEventListener('unhandledrejection', rejectionHandler);
827
+
663
828
  try {
664
829
  cliRuntime.execute(cliCode, '/project/cli-runner.js');
830
+ log('CLI synchronous execution completed (async work continues in background)');
665
831
  } catch (cliError) {
666
832
  // Some errors are expected (like process.exit or stack overflow in watcher)
667
833
  // The important work (deployment) happens before these errors
668
834
  log(`CLI completed with: ${(cliError as Error).message}`, 'warn');
669
835
  }
670
836
 
671
- // Wait for async operations to complete using smart polling
672
- // Poll for .env.local creation instead of fixed timeout
837
+ // Wait for CLI to finish: either process.exit fires (reliable) or fall back to polling
673
838
  logStatus('WAITING', 'Waiting for deployment to complete...');
674
- const deploymentSucceeded = await waitForDeployment(vfs, 30000, 500);
675
839
 
676
- if (!deploymentSucceeded) {
677
- log('Deployment may still be in progress, waiting additional time...', 'warn');
678
- await new Promise(resolve => setTimeout(resolve, 5000));
679
- } else {
680
- // .env.local was found, now wait for _generated directory
681
- // The CLI creates .env.local first, then bundles functions asynchronously
682
- log('Environment configured, waiting for function bundling...');
683
- const generatedCreated = await waitForGenerated(vfs, 15000, 500);
684
- if (!generatedCreated) {
685
- log('_generated directory not created yet, waiting additional time...', 'warn');
686
- await new Promise(resolve => setTimeout(resolve, 5000));
840
+ const timeoutPromise = new Promise<'timeout'>((resolve) =>
841
+ setTimeout(() => resolve('timeout'), 90000)
842
+ );
843
+
844
+ const result = await Promise.race([cliExitPromise, timeoutPromise]);
845
+
846
+ // Restore original fetch and remove rejection handler
847
+ globalThis.fetch = origFetch;
848
+ globalThis.removeEventListener('unhandledrejection', rejectionHandler);
849
+
850
+ if (result === 'timeout') {
851
+ log('CLI did not exit within 90s, falling back to file polling...', 'warn');
852
+ const envCreated = await waitForDeployment(vfs, 15000, 500);
853
+ if (envCreated) {
854
+ await waitForGenerated(vfs, 15000, 500);
687
855
  }
856
+ } else {
857
+ log(`CLI exited with code ${result}, deployment complete`);
688
858
  }
689
859
 
690
860
  // Check if deployment succeeded by reading .env.local (CLI creates it in /project)
@@ -732,6 +902,29 @@ export default app;
732
902
  log(' WARNING: _generated directory not created - functions may not be deployed!', 'error');
733
903
  }
734
904
 
905
+ // Ensure /convex/_generated/api.ts always exists (fallback if CLI didn't generate it)
906
+ if (!vfs.existsSync('/convex/_generated/api.ts')) {
907
+ log(' Restoring fallback api.ts (CLI did not generate one)');
908
+ vfs.mkdirSync('/convex/_generated', { recursive: true });
909
+ vfs.writeFileSync('/convex/_generated/api.ts', `// Convex API - fallback for browser demo
910
+ export const api = {
911
+ todos: {
912
+ list: "todos:list",
913
+ create: "todos:create",
914
+ toggle: "todos:toggle",
915
+ remove: "todos:remove",
916
+ },
917
+ } as const;
918
+ `);
919
+ }
920
+ if (!vfs.existsSync('/convex/_generated/server.ts')) {
921
+ vfs.mkdirSync('/convex/_generated', { recursive: true });
922
+ vfs.writeFileSync('/convex/_generated/server.ts', `// Server stubs for browser demo
923
+ export function query(config) { return config; }
924
+ export function mutation(config) { return config; }
925
+ `);
926
+ }
927
+
735
928
  // Parse the Convex URL from .env.local
736
929
  const match = envContent.match(/CONVEX_URL=(.+)/);
737
930
  if (match) {
@@ -756,6 +949,7 @@ export default app;
756
949
  convexUrl = parsed.url;
757
950
  log(`Using fallback URL: ${convexUrl}`, 'warn');
758
951
  }
952
+ logStatus('COMPLETE', `Connected to ${convexUrl} (fallback)`);
759
953
  }
760
954
 
761
955
  // Set the env var on the dev server (idiomatic Next.js pattern)
@@ -183,11 +183,15 @@ const EXPLICIT_MAPPINGS: Record<string, string> = {
183
183
  'react-dom/client': 'https://esm.sh/react-dom@18.2.0/client?dev',
184
184
  };
185
185
 
186
- // Packages that are local or have custom shims (NOT npm packages)
186
+ // Packages that are local, have custom shims, or are handled by the HTML import map.
187
+ // These are NOT redirected to esm.sh by redirectNpmImports.
187
188
  const LOCAL_PACKAGES = new Set([
188
189
  'next/link', 'next/router', 'next/head', 'next/navigation',
189
190
  'next/dynamic', 'next/image', 'next/script', 'next/font/google',
190
191
  'next/font/local', 'convex/_generated/api',
192
+ // Convex subpath imports — resolved by the import map in generated HTML
193
+ // (keeps version-pinned URLs consistent with import map)
194
+ 'convex/react', 'convex/server', 'convex/values',
191
195
  ]);
192
196
 
193
197
  /** Check if a package specifier is a bare npm import that should be redirected. */
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Next.js Config Parser
3
+ *
4
+ * Extracts configuration values from next.config.{ts,js,mjs} files
5
+ * using acorn AST parsing with regex fallback.
6
+ */
7
+
8
+ import * as acorn from 'acorn';
9
+ import { stripTypescriptSyntax } from './tailwind-config-loader';
10
+
11
+ /**
12
+ * Parse a string value from a Next.js config file.
13
+ *
14
+ * @param content - Raw file content of next.config.{ts,js,mjs}
15
+ * @param key - Config key to extract (e.g., 'assetPrefix', 'basePath')
16
+ * @param isTypeScript - Whether the content needs TypeScript syntax stripping
17
+ * @returns The string value, or null if not found
18
+ */
19
+ export function parseNextConfigValue(
20
+ content: string,
21
+ key: string,
22
+ isTypeScript: boolean = false
23
+ ): string | null {
24
+ const processed = isTypeScript ? stripTypescriptSyntax(content) : content;
25
+ try {
26
+ return parseNextConfigValueAst(processed, key);
27
+ } catch {
28
+ return parseNextConfigValueRegex(processed, key);
29
+ }
30
+ }
31
+
32
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
33
+ type ASTNode = any;
34
+
35
+ function parseNextConfigValueAst(content: string, key: string): string | null {
36
+ const ast = acorn.parse(content, {
37
+ ecmaVersion: 'latest',
38
+ sourceType: 'module',
39
+ });
40
+
41
+ // Collect top-level variable declarations: name -> init node
42
+ const variables = new Map<string, ASTNode>();
43
+ for (const node of (ast as ASTNode).body) {
44
+ if (node.type === 'VariableDeclaration') {
45
+ for (const decl of node.declarations) {
46
+ if (decl.id?.name && decl.init) {
47
+ variables.set(decl.id.name, decl.init);
48
+ }
49
+ }
50
+ }
51
+ }
52
+
53
+ // Find the exported config object
54
+ let configObject: ASTNode = null;
55
+
56
+ for (const node of (ast as ASTNode).body) {
57
+ // export default { ... } or export default configVar
58
+ if (node.type === 'ExportDefaultDeclaration') {
59
+ configObject = resolveToObject(node.declaration, variables);
60
+ if (configObject) break;
61
+ }
62
+
63
+ // module.exports = { ... } or module.exports = configVar
64
+ if (
65
+ node.type === 'ExpressionStatement' &&
66
+ node.expression.type === 'AssignmentExpression' &&
67
+ node.expression.left.type === 'MemberExpression' &&
68
+ node.expression.left.object?.name === 'module' &&
69
+ node.expression.left.property?.name === 'exports'
70
+ ) {
71
+ configObject = resolveToObject(node.expression.right, variables);
72
+ if (configObject) break;
73
+ }
74
+ }
75
+
76
+ if (!configObject || configObject.type !== 'ObjectExpression') {
77
+ return null;
78
+ }
79
+
80
+ // Find the property matching key
81
+ for (const prop of configObject.properties) {
82
+ if (prop.type !== 'Property') continue;
83
+
84
+ const propName =
85
+ prop.key.type === 'Identifier'
86
+ ? prop.key.name
87
+ : prop.key.type === 'Literal'
88
+ ? String(prop.key.value)
89
+ : null;
90
+
91
+ if (propName !== key) continue;
92
+
93
+ return resolveToString(prop.value, variables);
94
+ }
95
+
96
+ return null;
97
+ }
98
+
99
+ /** Resolve a node to an ObjectExpression, following Identifiers and CallExpressions */
100
+ function resolveToObject(
101
+ node: ASTNode,
102
+ variables: Map<string, ASTNode>
103
+ ): ASTNode | null {
104
+ if (!node) return null;
105
+ if (node.type === 'ObjectExpression') return node;
106
+ if (node.type === 'Identifier') {
107
+ const init = variables.get(node.name);
108
+ return init ? resolveToObject(init, variables) : null;
109
+ }
110
+ // Handle wrapper functions like defineConfig({ ... })
111
+ if (node.type === 'CallExpression' && node.arguments.length > 0) {
112
+ return resolveToObject(node.arguments[0], variables);
113
+ }
114
+ return null;
115
+ }
116
+
117
+ /** Resolve a node to a string value, following Identifiers */
118
+ function resolveToString(
119
+ node: ASTNode,
120
+ variables: Map<string, ASTNode>
121
+ ): string | null {
122
+ if (!node) return null;
123
+ if (node.type === 'Literal' && typeof node.value === 'string') {
124
+ return node.value;
125
+ }
126
+ if (node.type === 'TemplateLiteral' && node.expressions.length === 0) {
127
+ return node.quasis[0]?.value?.cooked ?? null;
128
+ }
129
+ if (node.type === 'Identifier') {
130
+ const init = variables.get(node.name);
131
+ return init ? resolveToString(init, variables) : null;
132
+ }
133
+ return null;
134
+ }
135
+
136
+ function parseNextConfigValueRegex(content: string, key: string): string | null {
137
+ const regex = new RegExp(`${key}\\s*:\\s*["'\`]([^"'\`]+)["'\`]`);
138
+ const match = content.match(regex);
139
+ return match ? match[1] : null;
140
+ }