Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
245 changes: 245 additions & 0 deletions docs/design-esm-core-module.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
# Design: ESM Support for `@azure/functions-core`

## Overview

Add ESM `import` support for the virtual `@azure/functions-core` module so that
ESM-based code (Library's future `.mjs` bundle, user code with `"type": "module"`)
can use `import { ... } from '@azure/functions-core'` at runtime.

## Current Architecture

### CJS Interception (today)

```
setupCoreModule() // called from Worker.ts at startup
└─ Module.prototype.require // Proxy wrapping Node's require()
└─ if (arg === '@azure/functions-core') → return coreApi
```

- Works for all CJS `require('@azure/functions-core')` calls
- ESM `import '@azure/functions-core'` bypasses this entirely → runtime error

### Worker Lifecycle

```
nodejsWorker.ts
└─ require('./worker-bundle.js') // CJS entry
└─ Worker.ts
├─ setupEventStream()
├─ setupCoreModule() // ← installs CJS Proxy
└─ startStream → Host connection
└─ eventually loads user code via loadScriptFile()
└─ ESM: eval('import(fileUrl.href)')
└─ CJS: require(filePath)
```

### Key Constraint

`setupCoreModule()` is called **before** any user code or Library code is loaded.
This means we can register an ESM loader hook at this point and it will be active
for all subsequent `import()` calls.

## Design Options

### Option A: `module.register()` with Custom Loader Hooks (Node.js >= 20.6)

Node.js provides `module.register()` to install ESM loader hooks. These hooks run
in a **separate loader thread** and can intercept module resolution and loading.

**Loader hooks file** (`esm-core-loader.mjs` or inline via `data:` URL):

```javascript
// resolve hook: intercept '@azure/functions-core' specifier
export function resolve(specifier, context, nextResolve) {
if (specifier === '@azure/functions-core') {
return { url: 'azure-functions-core://virtual', shortCircuit: true };
}
return nextResolve(specifier, context);
}

// load hook: return the coreApi object as a module
export function load(url, context, nextLoad) {
if (url === 'azure-functions-core://virtual') {
return {
format: 'module',
shortCircuit: true,
source: `
// Re-export from globalThis where the worker placed the coreApi
const api = globalThis.__azureFunctionsCoreApi;
export const version = api.version;
export const hostVersion = api.hostVersion;
export const registerHook = api.registerHook;
// ... etc
export default api;
`,
};
}
return nextLoad(url, context);
}
```

**Challenge:** Loader hooks run in a **separate thread**. They cannot directly access
the `coreApi` object from the main thread. Communication requires either:
- `globalThis` (shared in Node.js 20+ for loader hooks — **CONFIRMED: works**)
- `port.postMessage()` / `transferList` (for more complex data)

**Approach:**
1. `setupCoreModule()` sets `globalThis.__azureFunctionsCoreApi = coreApi`
2. `module.register()` loads the hook file
3. ESM hook's `load()` reads from `globalThis.__azureFunctionsCoreApi`
4. Hook returns synthetic ESM source that re-exports the coreApi properties

**Pros:**
- Official Node.js API, stable in Node.js >= 20.6
- Clean separation — hook file is small and self-contained
- Works for both static `import` and dynamic `import()`

**Cons:**
- Requires generating synthetic ESM source code (string template)
- The named exports must be enumerated in the source string at registration time
- `module.register()` needs a file URL or `data:` URL for the hook

### Option B: CJS Proxy + `--experimental-loader` (legacy)

Use `--experimental-loader` CLI flag passed to the Node.js process.

**Rejected:** The Worker process is launched by the Functions Host. We don't
control CLI flags. Also, `--experimental-loader` is deprecated in favor of
`module.register()`.

### Option C: Write a `.mjs` file to disk at startup

At `setupCoreModule()` time, write a temporary `.mjs` file that acts as a shim
for `@azure/functions-core`, then add its parent directory to `NODE_PATH`.

**Rejected:** Writing to disk is fragile (permissions, temp dir cleanup, race
conditions). Also, `NODE_PATH` doesn't affect ESM resolution.

## Recommended: Option A

### Implementation Plan

#### Files to Change

| File | Change |
|------|--------|
| `src/setupCoreModule.ts` | Add `globalThis.__azureFunctionsCoreApi = coreApi` and call `module.register()` |
| `src/esm-core-loader.mjs` (NEW) | ESM loader hooks (`resolve` + `load`) |
| `webpack.config.js` | Exclude the new `.mjs` file from bundling (must ship as separate file) |

#### setupCoreModule.ts Changes

```typescript
export function setupCoreModule(): void {
const coreApi = { /* ... existing code ... */ };

// 1. CJS interception (existing — unchanged)
Module.prototype.require = new Proxy(Module.prototype.require, {
apply(target, thisArg, argArray) {
if (argArray[0] === '@azure/functions-core') {
return coreApi;
} else {
return Reflect.apply(target, thisArg, argArray);
}
},
});

// 2. ESM interception (NEW)
// Expose coreApi on globalThis so the ESM loader hook can access it
(globalThis as any).__azureFunctionsCoreApi = coreApi;

// Register ESM loader hooks (Node.js >= 20.6)
// module.register() is available since Node.js 20.6 (LTS)
if (typeof module !== 'undefined' && 'register' in module) {
const { register } = require('node:module');
register('./esm-core-loader.mjs', import.meta.url);
// NOTE: import.meta.url not available in CJS.
// Use pathToFileURL(__filename) instead.
}
}
```

**Important:** Since `setupCoreModule.ts` is compiled to CJS, `import.meta.url` is
not available. Use `url.pathToFileURL(__filename)` as the `parentURL` parameter for
`module.register()`.

#### esm-core-loader.mjs

```javascript
const VIRTUAL_URL = 'azure-functions-core://virtual';

export function resolve(specifier, context, nextResolve) {
if (specifier === '@azure/functions-core') {
return { url: VIRTUAL_URL, shortCircuit: true };
}
return nextResolve(specifier, context);
}

export function load(url, context, nextLoad) {
if (url === VIRTUAL_URL) {
const api = globalThis.__azureFunctionsCoreApi;
if (!api) {
throw new Error(
'@azure/functions-core is not available. ' +
'This module can only be used at runtime inside the Azure Functions worker.'
);
}
// Build named exports from the coreApi object
const exportNames = Object.keys(api);
const namedExports = exportNames
.map(name => `export const ${name} = api.${name};`)
.join('\n');

return {
format: 'module',
shortCircuit: true,
source: `
const api = globalThis.__azureFunctionsCoreApi;
${namedExports}
export default api;
`,
};
}
return nextLoad(url, context);
}
```

#### webpack.config.js Changes

The ESM loader file must **not** be bundled into `worker-bundle.js` — it needs to
exist as a separate `.mjs` file next to the bundle. Add it to `externals` or use
`CopyWebpackPlugin`.

#### Node.js Version Compatibility

| Node.js | `module.register()` | Status |
|---------|---------------------|--------|
| 18.x | Not available | Feature-gate: skip ESM hook, CJS-only |
| 20.6+ | Available (stable) | Full ESM support |
| 22.x | Available (stable) | Full ESM support |

The `engines` field in package.json doesn't specify a minimum, but Azure Functions
v4 requires Node.js >= 18. We should feature-gate the `module.register()` call.

### Testing Strategy

1. **Unit test:** Verify `setupCoreModule()` sets `globalThis.__azureFunctionsCoreApi`
2. **Integration test:** ESM user function that does `import { registerHook } from '@azure/functions-core'`
3. **Backward compat:** Existing CJS tests remain unchanged and pass
4. **Version guard:** On Node.js 18.x, ESM import falls back gracefully with clear error message

### Risk Assessment

| Risk | Mitigation |
|------|------------|
| `globalThis` pollution | Use a Symbol key instead of string: `globalThis[Symbol.for('azure-functions-core')]` |
| Loader hook thread isolation | Confirmed: `globalThis` is shared with loader thread in Node.js 20+ |
| Breaking CJS users | CJS path is completely unchanged — zero risk |
| webpack bundling the loader | Explicitly exclude via externals config |

## Open Questions

1. Should the `globalThis` key use a Symbol for hygiene?
2. Should we support Node.js 18.x at all for ESM `@azure/functions-core` import?
(CJS `require` always works regardless; this only affects ESM `import`)
3. How should the `.mjs` loader file be packaged in the `.nupkg` (Worker.nuspec)?
43 changes: 43 additions & 0 deletions src/esm-core-loader.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License.

// ESM loader hooks for the virtual '@azure/functions-core' module.
// Registered via module.register() in setupCoreModule.ts.
// See docs/design-esm-core-module.md for design details.

const VIRTUAL_URL = 'azure-functions-core://virtual';
const CORE_MODULE_SPECIFIER = '@azure/functions-core';
const GLOBAL_KEY = Symbol.for('azure-functions-core');

export function resolve(specifier, context, nextResolve) {
if (specifier === CORE_MODULE_SPECIFIER) {
return { url: VIRTUAL_URL, shortCircuit: true };
}
return nextResolve(specifier, context);
}

export function load(url, context, nextLoad) {
if (url === VIRTUAL_URL) {
const api = globalThis[GLOBAL_KEY];
if (!api) {
throw new Error(
'@azure/functions-core is not available. ' +
'This module can only be used at runtime inside the Azure Functions worker.'
);
}

const exportNames = Object.keys(api);
const namedExports = exportNames
.map(name => `export const ${name} = api['${name}'];`)
.join('\n');

const source = [
`const api = globalThis[Symbol.for('azure-functions-core')];`,
namedExports,
`export default api;`,
].join('\n');

return { format: 'module', shortCircuit: true, source };
}
return nextLoad(url, context);
}
67 changes: 67 additions & 0 deletions src/esmCoreLoaderHooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License.

/**
* ESM loader hooks for the virtual '@azure/functions-core' module.
*
* These hooks are registered via `module.register()` in `setupCoreModule.ts` and
* intercept ESM `import` calls for '@azure/functions-core', returning a synthetic
* module backed by the coreApi object on globalThis.
*
* This file is also imported directly by unit tests for testability.
*/

const VIRTUAL_URL = 'azure-functions-core://virtual';
const CORE_MODULE_SPECIFIER = '@azure/functions-core';
const GLOBAL_KEY = Symbol.for('azure-functions-core');

/**
* ESM resolve hook. Intercepts resolution of '@azure/functions-core' and
* maps it to a virtual URL that the load hook will recognize.
*/
export function resolve(
specifier: string,
context: Record<string, unknown>,
nextResolve: (specifier: string, context?: Record<string, unknown>) => { url: string }
): { url: string; shortCircuit?: boolean } {
if (specifier === CORE_MODULE_SPECIFIER) {
return { url: VIRTUAL_URL, shortCircuit: true };
}
return nextResolve(specifier, context);
}

/**
* ESM load hook. When the virtual URL is requested, builds a synthetic ESM
* source that re-exports properties from the coreApi object stored on globalThis.
*/
export function load(
url: string,
context: Record<string, unknown>,
nextLoad: (url: string, context?: Record<string, unknown>) => { format: string; source: string }
): { format: string; source: string; shortCircuit?: boolean } {
if (url === VIRTUAL_URL) {
const api = (globalThis as any)[GLOBAL_KEY];
if (!api) {
throw new Error(
'@azure/functions-core is not available. ' +
'This module can only be used at runtime inside the Azure Functions worker.'
);
}

const exportNames = Object.keys(api);
const namedExports = exportNames.map((name) => `export const ${name} = api['${name}'];`).join('\n');

const source = [
`const api = globalThis[Symbol.for('azure-functions-core')];`,
namedExports,
`export default api;`,
].join('\n');

return {
format: 'module',
shortCircuit: true,
source,
};
}
return nextLoad(url, context);
}
Loading
Loading