Skip to content

Block dynamic imports in v8 isolate Convex files at deploy time#379

Open
ghandic wants to merge 1 commit intoget-convex:mainfrom
ghandic:catch-dynamic-imports-early
Open

Block dynamic imports in v8 isolate Convex files at deploy time#379
ghandic wants to merge 1 commit intoget-convex:mainfrom
ghandic:catch-dynamic-imports-early

Conversation

@ghandic
Copy link
Copy Markdown

@ghandic ghandic commented Feb 26, 2026

Summary

  • Add deploy-time validation that rejects import() expressions in Convex files that don't have a "use node" directive. Previously this was only caught at runtime in the V8 isolate, leading to a bad DX.
  • The check runs during bundling in entryPointsByEnvironment(), before esbuild, using @babel/parser AST analysis with a fast string pre-check.
  • Test files (.test.ts, .spec.ts) are already excluded from entry points by existing bundler logic and are unaffected.

Test plan

  • Added unit tests for hasDynamicImport() covering: top-level imports, nested in functions, template literals, TypeScript, static-only imports, comments, and string literals.
  • Added integration tests verifying entryPointsByEnvironment() rejects isolate files with dynamic imports and allows them in "use node" files.

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@ianmacartney
Copy link
Copy Markdown
Member

ianmacartney commented Mar 3, 2026 via email

@ghandic
Copy link
Copy Markdown
Author

ghandic commented Mar 3, 2026

Dynamic imports fail at runtime on convex but don't get caught at build. I've had agents add this in and I normally find it quickly before it matters but people in discord have had similar issues. If it's blocked at runtime why not build time ?

@ianmacartney
Copy link
Copy Markdown
Member

ianmacartney commented Mar 3, 2026 via email

@ghandic
Copy link
Copy Markdown
Author

ghandic commented Mar 3, 2026

Should be low impact as it does a string match first and only runs if there's import(

cursor bot pushed a commit that referenced this pull request Mar 3, 2026
This is a copybara of #379.

Changes:
- Add deploy-time validation that rejects import() expressions in Convex
  files that don't have a "use node" directive
- The check runs during bundling in entryPointsByEnvironment(), using
  @babel/parser AST analysis with a fast string pre-check
- Test files (.test.ts, .spec.ts) are already excluded from entry points
  by existing bundler logic and are unaffected

Performance impact (benchmarked):
- Fast-path optimization: files without 'import(' string skip AST parsing
- Typical overhead: 2-4% of total bundling time (~1-5ms for 50-200 files)
- esbuild dominates total time (20-50ms), so check overhead is negligible

Tests:
- Added unit tests for hasDynamicImport() covering various patterns
- Added integration tests for entryPointsByEnvironment() behavior

Co-authored-by: Ian Macartney <ianmacartney@users.noreply.github.com>
@ianmacartney
Copy link
Copy Markdown
Member

It both works and doesn't work 😅

Building udf test bundle
  Error: Failed to run convex deploy:

  - Deploying to http://127.0.0.1:8000.../

  ✖ Dynamic import (`import()`) is not allowed in "import_tests.ts" because it does not have a "use node" directive. Dynamic imports are only supported in Node.js actions. Add "use node" at the top of the file if this is a Node.js action, or replace the dynamic import with a static import.


  Stack backtrace:
     0: anyhow::error::<impl anyhow::Error>::msg
     1: build_script_build::write_udf_test_bundle
     2: build_script_build::main
     3: core::ops::function::FnOnce::call_once
     4: std::sys::backtrace::__rust_begin_short_backtrace
     5: std::rt::lang_start::{{closure}}
     6: core::ops::function::impls::<impl core::ops::function::FnOnce<A> for &F>::call_once
               at /rustc/bdaba05a953eb5abeba0011cdda2560d157aed2e/library/core/src/ops/function.rs:284:21
     7: std::panicking::catch_unwind::do_call
               at /rustc/bdaba05a953eb5abeba0011cdda2560d157aed2e/library/std/src/panicking.rs:589:40
     8: std::panicking::catch_unwind
               at /rustc/bdaba05a953eb5abeba0011cdda2560d157aed2e/library/std/src/panicking.rs:552:19
     9: std::panic::catch_unwind
               at /rustc/bdaba05a953eb5abeba0011cdda2560d157aed2e/library/std/src/panic.rs:359:14
    10: std::rt::lang_start_internal::{{closure}}
               at /rustc/bdaba05a953eb5abeba0011cdda2560d157aed2e/library/std/src/rt.rs:175:24
    11: std::panicking::catch_unwind::do_call
               at /rustc/bdaba05a953eb5abeba0011cdda2560d157aed2e/library/std/src/panicking.rs:589:40
    12: std::panicking::catch_unwind
               at /rustc/bdaba05a953eb5abeba0011cdda2560d157aed2e/library/std/src/panicking.rs:552:19
    13: std::panic::catch_unwind
               at /rustc/bdaba05a953eb5abeba0011cdda2560d157aed2e/library/std/src/panic.rs:359:14
    14: std::rt::lang_start_internal
               at /rustc/bdaba05a953eb5abeba0011cdda2560d157aed2e/library/std/src/rt.rs:171:5
    15: std::rt::lang_start
    16: main
    17: <unknown>
    18: __libc_start_main
    19: _start
[Clippy] Clippy error
Traceback (most recent call last):
  File "/opt/actions-runner/_work/convex/convex/./scripts/run_rust_lint.py", line 356, in <module>
    run_checks(parse_args())
    ~~~~~~~~~~^^^^^^^^^^^^^^
  File "/opt/actions-runner/_work/convex/convex/./scripts/run_rust_lint.py", line 313, in run_checks
    run_check(lambda: run_clippy(args), "Clippy", violations, fail_fast)
    ~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/actions-runner/_work/convex/convex/./scripts/run_rust_lint.py", line 274, in run_check
    check()
    ~~~~~^^
  File "/opt/actions-runner/_work/convex/convex/./scripts/run_rust_lint.py", line 313, in <lambda>
    run_check(lambda: run_clippy(args), "Clippy", violations, fail_fast)
                      ~~~~~~~~~~^^^^^^
  File "/opt/actions-runner/_work/convex/convex/./scripts/run_rust_lint.py", line 267, in run_clippy
    raise Exception("Clippy error")

this is failing for this file that currently tests that dynamic imports work (perhaps only if they're explicitly imported elsewhere?):

import * as helpersStatic from "./helpers";

export const dynamicImport = action({
  args: {},
  handler: async () => {
    const helpers = await import("./helpers");
    assert.strictEqual(helpers.fibonacci(6), 8.0);
    // The `import * as helpersStatic` repackages the namespace object, so we
    // can't assert helpersStatic === helpers, but we can check that the module
    // was not re-evaluated by checking equality of a field.
    assert.strictEqual(helpersStatic.fibonacci, helpers.fibonacci);
    const helpersAgain = await import("./helpers");
    assert.strictEqual(helpers, helpersAgain);
    const helpersDifferentPath = await import("./helpers");
    assert.strictEqual(helpers, helpersDifferentPath);
  },
});

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants