Skip to content

feat: support TypeScript files in ${file()} variable resolver#13590

Merged
czubocha merged 3 commits into
mainfrom
claude/brave-wiles-e91de9
May 27, 2026
Merged

feat: support TypeScript files in ${file()} variable resolver#13590
czubocha merged 3 commits into
mainfrom
claude/brave-wiles-e91de9

Conversation

@czubocha
Copy link
Copy Markdown
Contributor

@czubocha czubocha commented May 18, 2026

Summary

Extend the ${file(...)} variable resolver to load TypeScript modules (.ts, .mts, .cts) in addition to the existing JavaScript support (.js, .cjs, .mjs). TypeScript files are compiled on the fly via tsx — the same loader the Framework already uses for serverless.ts — so no separate build step is required.

Before

custom:
  # ✅ worked
  SECRETS: ${file(./scripts/secrets.cjs):getSecrets}

  # ❌ failed: Unsupported file extension
  SECRETS: ${file(./scripts/secrets.ts):getSecrets}

After

Both forms work. From serverless.yml or serverless.ts:

// scripts/secrets.ts
export const getSecrets = async () => ({ apiKey: process.env.API_KEY })
// serverless.ts
custom: {
  SECRETS: '${file(./scripts/secrets.ts):getSecrets}',
}

Details

  • Adds .ts / .mts / .cts cases to the file resolver.
  • Refactors the JS-module export-shape handler into a shared helper so JS and TS code paths are identical: default-object export, async default-function export, named export, named-export function with #property selector, and injected resolveVariable / resolveConfigurationProperty callbacks all behave the same across JS and TS sources.
  • tsx is loaded lazily inside the TS branch so cold-start is unchanged when no TS reference is present.
  • Errors are split into two clearer messages: Cannot load TS module "..." for transpile / import failures, and Cannot execute TS module "..." for runtime throws inside the module body.
  • Adds tsx as an explicit dependency of @serverlessinc/sf-core (previously resolved transitively).
  • Documents both the JavaScript and TypeScript file-reference forms in the file-variables guide — neither was previously documented.

Behavior note

The shared module-shape handler now unwraps the standard CJS↔ESM interop layer (the __esModule: true marker) once before reading exports. Native dynamic import() of a .cjs file already does this collapse implicitly; tsx's tsImport does not, hence the explicit unwrap. As a side effect this also applies to the JS code path:

  • Pre-transpiled .cjs / .js files whose module.exports carry an __esModule: true marker (i.e. anything emitted by Babel / TypeScript / esbuild in CJS mode) now return the user's intended default export from ${file(./compiled.js)} and from ${file(./compiled.js):default} — instead of the transpiler wrapper object that was previously returned. This is a correctness fix; the prior wrapper was an interop leak, not an intended contract.
  • Files without an __esModule marker, default exports that are not objects (e.g. export default 42), and all hand-authored JS / YAML / JSON / raw-text references are unaffected.

Test plan

  • npm run prettier — clean
  • npm run lint — clean
  • npm run test:unit in packages/sf-core — 386 tests pass (23 pre-existing JS resolver tests unchanged + 11 new TS resolver tests)
  • npm run build in packages/sf-core — bundle produces successfully
  • Manual end-to-end via sls print against a TS-only repro covering: default-export object, property selector, async named-export function with env-var read, named export from a .ts file using as const
  • Manual end-to-end verification of both error branches: TS file with syntax error surfaces Cannot load TS module with the underlying esbuild transform error; TS file whose function throws surfaces Cannot execute TS module with the original error message
  • Node 18 compatibility maintained (no new syntax / Node APIs)

Extend the `${file(...)}` resolver to load `.ts`, `.mts`, and `.cts`
modules in addition to the existing `.js`/`.cjs`/`.mjs` support.
TypeScript modules are compiled on the fly via tsx — the same loader
the framework already uses for `serverless.ts` — so no separate build
step is needed.

The JS and TS code paths share a single export-shape handler, so all
supported shapes (default object, async default function, named export,
named export function with property selector, injected
`resolveVariable` / `resolveConfigurationProperty` callbacks) behave
identically across JavaScript and TypeScript sources. tsx is loaded
lazily so cold-start is unchanged when no TS reference is present.

Errors are surfaced as either "Cannot load TS module" (transpile or
import failure) or "Cannot execute TS module" (runtime throw inside
the module body) for clearer diagnostics.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 18, 2026

📝 Walkthrough

Walkthrough

Adds runtime TypeScript loading (via tsx) and unified JS/TS module resolution for variables: resolveLoadedModule normalizes exports, invokes callable exports with resolver context, supports propertyPath selectors, includes tests for routing/interop, and updates documentation.

Changes

TypeScript and JavaScript Module Resolution

Layer / File(s) Summary
Shared module resolution helper
packages/sf-core/src/lib/resolvers/providers/file/file.js
resolveLoadedModule normalizes interpretation of loaded modules: unwraps TSX/ESM interop, selects named or default exports, invokes callable exports with resolver context, and extracts propertyPath values.
File resolver TypeScript and JavaScript routing
packages/sf-core/package.json, packages/sf-core/src/lib/resolvers/providers/file/file.js
Added tsx dependency; resolveVariableFromFile routes .ts/.mts/.cts to resolveTsModule; resolveJsModule imports JS then delegates to resolveLoadedModule; resolveTsModule loads via tsx/esm/api and emits separate "cannot load" vs "cannot execute" errors.
TypeScript module resolution test suite
packages/sf-core/tests/unit/resolvers/file-ts.test.js
Mocks tsx/esm/api; validates extension routing (ts/mts/cts vs js), file:// URL construction, async default-export invocation with resolver context, propertyPath resolution for named and nested exports, CJS-shaped fallback handling, __esModule unwrapping behavior, and load/execute error wrapping.
Documentation: external JavaScript and TypeScript files
docs/sf/guides/variables/file.md
Frontmatter broadened to "External Files"; added a section describing referencing JS/TS modules from serverless.yml, TS on-the-fly compilation, expected export shapes (value or function), :exportName selection, and examples.

🎯 3 (Moderate) | ⏱️ ~25 minutes

🐰 From TypeScript to JavaScript,
Modules now resolve with practiced ease,
Variables hop through files with grace,
Named exports dance, properties please—
A config that runs at breakneck pace! 🚀

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: support TypeScript files in ${file()} variable resolver' is specific, concise, and clearly describes the main change—adding TypeScript support to the file variable resolver.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/brave-wiles-e91de9

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@Mmarzex
Copy link
Copy Markdown
Contributor

Mmarzex commented May 18, 2026

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues
Code Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
docs/sf/guides/variables/file.md (1)

89-96: ⚡ Quick win

Consider adding context for the injected function parameters.

Line 89 mentions that functions receive { options, resolveVariable, resolveConfigurationProperty } but doesn't explain what these are or when they'd be useful. The example function doesn't use any of these parameters, which leaves their purpose unclear.

Consider either:

  • Adding a brief explanation of these parameters (e.g., "resolveVariable lets you resolve other serverless variables from within your function")
  • Or providing a second example that demonstrates using one of them

This would help users understand when and why they might need these capabilities.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/sf/guides/variables/file.md` around lines 89 - 96, The doc mentions that
exported functions receive `{ options, resolveVariable,
resolveConfigurationProperty }` but doesn't explain their purpose; update the
text around the `getSecrets` example to briefly describe each parameter (e.g.,
`options` is the CLI/deployment options, `resolveVariable` lets you
synchronously resolve other serverless variables from inside your function, and
`resolveConfigurationProperty` lets you resolve typed configuration properties)
and either modify the `getSecrets` example or add a second short example showing
use of one of them (for instance call `resolveVariable('env:API_KEY')` inside
`getSecrets`) so readers can see a practical use-case.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@docs/sf/guides/variables/file.md`:
- Around line 89-96: The doc mentions that exported functions receive `{
options, resolveVariable, resolveConfigurationProperty }` but doesn't explain
their purpose; update the text around the `getSecrets` example to briefly
describe each parameter (e.g., `options` is the CLI/deployment options,
`resolveVariable` lets you synchronously resolve other serverless variables from
inside your function, and `resolveConfigurationProperty` lets you resolve typed
configuration properties) and either modify the `getSecrets` example or add a
second short example showing use of one of them (for instance call
`resolveVariable('env:API_KEY')` inside `getSecrets`) so readers can see a
practical use-case.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 14ab977a-5abb-4463-872b-fd1cab5bd236

📥 Commits

Reviewing files that changed from the base of the PR and between 8eb17c5 and ea11ef4.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (4)
  • docs/sf/guides/variables/file.md
  • packages/sf-core/package.json
  • packages/sf-core/src/lib/resolvers/providers/file/file.js
  • packages/sf-core/tests/unit/resolvers/file-ts.test.js

czubocha added 2 commits May 27, 2026 15:05
tsx's tsImport compiles a `.ts` file with `export default X` to CJS and
returns a Module Namespace shaped as:
  { default: { __esModule: true, default: X, ...named } }

The inner namespace has a null prototype. Returning it verbatim from the
file resolver crashed the resolver manager with
`TypeError: Cannot convert object to primitive value` whenever the
result was chained through another placeholder (e.g.
`${self:custom.secrets.apiKey}` reading from
`secrets: ${file(./scripts/secrets.ts)}`) — String.prototype.replace
inside #updateNodePlaceholders cannot coerce a null-prototype Module
Namespace to a primitive.

Native dynamic `import()` of a `.cjs` or `.js` file collapses this layer
for us via the documented `__esModule` interop convention; tsx does
not. Peel one layer in resolveLoadedModule when `module.default.__esModule`
is truthy so the rest of the function operates on the user's intended
module shape regardless of loader. The check is loader-agnostic — Babel,
TypeScript, esbuild, tsx, and any future loader following the convention
are all normalized the same way. Falsy `__esModule` (or a non-object
default, like `export default 42`) is a no-op, preserving existing
behavior for the JS code path and for `.ts` modules whose default is a
plain value.

Adds 7 unit tests covering:
- selector-less default-only TS module (the failing scenario today)
- the result is a plain Object.prototype object AND survives the exact
  String.replace call that crashed the manager (locks in the regression)
- property selector against a named export under the __esModule wrap
- property selector against the default object under the __esModule wrap
- async default-export function under the wrap, invoked with context
- no unwrap when __esModule marker is absent
- no unwrap when default is a non-object
@czubocha
Copy link
Copy Markdown
Contributor Author

@cursor review

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 2b2ace5. Configure here.

@czubocha czubocha merged commit c612af4 into main May 27, 2026
13 checks passed
@czubocha czubocha deleted the claude/brave-wiles-e91de9 branch May 27, 2026 14:08
@github-actions github-actions Bot locked and limited conversation to collaborators May 27, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants