diff --git a/docs/design-esm-core-module.md b/docs/design-esm-core-module.md new file mode 100644 index 00000000..17e43507 --- /dev/null +++ b/docs/design-esm-core-module.md @@ -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)? diff --git a/src/esm-core-loader.mjs b/src/esm-core-loader.mjs new file mode 100644 index 00000000..6ff616a3 --- /dev/null +++ b/src/esm-core-loader.mjs @@ -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); +} diff --git a/src/esmCoreLoaderHooks.ts b/src/esmCoreLoaderHooks.ts new file mode 100644 index 00000000..36d71b17 --- /dev/null +++ b/src/esmCoreLoaderHooks.ts @@ -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, + nextResolve: (specifier: string, context?: Record) => { 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, + nextLoad: (url: string, context?: Record) => { 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); +} diff --git a/src/setupCoreModule.ts b/src/setupCoreModule.ts index 67e409bf..4ef678d4 100644 --- a/src/setupCoreModule.ts +++ b/src/setupCoreModule.ts @@ -1,6 +1,8 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. +import * as path from 'path'; +import * as url from 'url'; import { version } from './constants'; import { coreApiLog } from './coreApi/coreApiLog'; import { registerFunction } from './coreApi/registerFunction'; @@ -10,6 +12,8 @@ import { registerHook } from './hooks/registerHook'; import { worker } from './WorkerContext'; import Module = require('module'); +const GLOBAL_KEY = Symbol.for('azure-functions-core'); + /** * Intercepts the default "require" method so that we can provide our own "built-in" module * This module is essentially the publicly accessible API for our worker @@ -31,6 +35,7 @@ export function setupCoreModule(): void { Disposable, }; + // 1. CJS interception via require Proxy (existing behavior) Module.prototype.require = new Proxy(Module.prototype.require, { apply(target, thisArg, argArray) { if (argArray[0] === '@azure/functions-core') { @@ -41,6 +46,23 @@ export function setupCoreModule(): void { }, }); + // 2. ESM interception via module.register() (Node.js >= 20.6) + // Expose coreApi on globalThis so the ESM loader hook can access it + (globalThis as any)[GLOBAL_KEY] = coreApi; + + try { + const nodeModule = require('node:module'); + if (typeof nodeModule.register === 'function') { + const loaderPath = path.resolve(__dirname, 'esm-core-loader.mjs'); + nodeModule.register(url.pathToFileURL(loaderPath).href, { + parentURL: url.pathToFileURL(__filename).href, + }); + } + } catch { + // module.register() not available (Node.js < 20.6) — ESM import of + // @azure/functions-core won't work, but CJS require() still does. + } + // Set default programming model shipped with the worker // This has to be imported dynamically _after_ we setup the core module since it will almost certainly reference the core module // eslint-disable-next-line @typescript-eslint/no-var-requires diff --git a/test/setupCoreModule.test.ts b/test/setupCoreModule.test.ts new file mode 100644 index 00000000..6c711dcb --- /dev/null +++ b/test/setupCoreModule.test.ts @@ -0,0 +1,145 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +import 'mocha'; +import { expect } from 'chai'; +import * as Module from 'module'; + +describe('setupCoreModule', () => { + const originalRequire = Module.prototype.require; + + afterEach(() => { + Module.prototype.require = originalRequire; + delete (globalThis as any)[Symbol.for('azure-functions-core')]; + }); + + describe('CJS interception', () => { + it('intercepts require for @azure/functions-core', () => { + const fakeCoreApi = { version: '1.0.0', registerHook: () => {} }; + + Module.prototype.require = new Proxy(Module.prototype.require, { + apply(target: any, thisArg: any, argArray: any[]) { + if (argArray[0] === '@azure/functions-core') { + return fakeCoreApi; + } + return Reflect.apply(target, thisArg, argArray); + }, + }); + + const result = Module.prototype.require.apply({}, ['@azure/functions-core']); + expect(result).to.equal(fakeCoreApi); + expect(result.version).to.equal('1.0.0'); + }); + + it('does not intercept require for other modules', () => { + const fakeCoreApi = { version: '1.0.0' }; + + Module.prototype.require = new Proxy(Module.prototype.require, { + apply(target: any, thisArg: any, argArray: any[]) { + if (argArray[0] === '@azure/functions-core') { + return fakeCoreApi; + } + return Reflect.apply(target, thisArg, argArray); + }, + }); + + const path = Module.prototype.require.apply({}, ['path']); + expect(path).to.have.property('join'); + }); + }); + + describe('ESM interception - globalThis exposure', () => { + it('exposes coreApi on globalThis via Symbol key', () => { + const fakeCoreApi = { + version: '1.0.0', + registerHook: () => {}, + registerFunction: () => {}, + }; + + const key = Symbol.for('azure-functions-core'); + (globalThis as any)[key] = fakeCoreApi; + + const retrieved = (globalThis as any)[Symbol.for('azure-functions-core')]; + expect(retrieved).to.equal(fakeCoreApi); + expect(retrieved.version).to.equal('1.0.0'); + }); + }); + + describe('ESM loader hooks', () => { + // Import the loader logic (exported as testable functions from a .ts file) + let esmLoader: typeof import('../src/esmCoreLoaderHooks'); + + before(async () => { + esmLoader = await import('../src/esmCoreLoaderHooks'); + }); + + describe('resolve', () => { + it('returns virtual URL for @azure/functions-core', () => { + const nextResolve = (specifier: string) => ({ + url: `file:///node_modules/${specifier}/index.js`, + }); + + const result = esmLoader.resolve('@azure/functions-core', {}, nextResolve); + expect(result).to.deep.equal({ + url: 'azure-functions-core://virtual', + shortCircuit: true, + }); + }); + + it('passes through other modules', () => { + const nextResolve = (specifier: string) => ({ + url: `file:///node_modules/${specifier}/index.js`, + }); + + const result = esmLoader.resolve('some-other-module', {}, nextResolve); + expect(result).to.deep.equal({ + url: 'file:///node_modules/some-other-module/index.js', + }); + }); + }); + + describe('load', () => { + it('returns module source for virtual URL', () => { + const fakeCoreApi = { + version: '2.0.0', + hostVersion: '4.0.0', + registerHook: () => {}, + }; + (globalThis as any)[Symbol.for('azure-functions-core')] = fakeCoreApi; + + const nextLoad = () => { + throw new Error('should not be called'); + }; + + const result = esmLoader.load('azure-functions-core://virtual', {}, nextLoad); + expect(result.format).to.equal('module'); + expect(result.shortCircuit).to.be.true; + expect(result.source).to.be.a('string'); + expect(result.source).to.include('export default'); + expect(result.source).to.include('version'); + expect(result.source).to.include('hostVersion'); + expect(result.source).to.include('registerHook'); + }); + + it('passes through other URLs', () => { + const expectedResult = { format: 'module', source: 'export default 42;' }; + const nextLoad = () => expectedResult; + + const result = esmLoader.load('file:///some/other/module.mjs', {}, nextLoad); + expect(result).to.equal(expectedResult); + }); + + it('throws if coreApi not available on globalThis', () => { + delete (globalThis as any)[Symbol.for('azure-functions-core')]; + + const nextLoad = () => { + throw new Error('should not be called'); + }; + + expect(() => esmLoader.load('azure-functions-core://virtual', {}, nextLoad)).to.throw( + '@azure/functions-core is not available' + ); + }); + }); + }); +}); diff --git a/webpack.config.js b/webpack.config.js index 023cdd7e..04b4b38f 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -19,6 +19,14 @@ module.exports = { commonjsMagicComments: true, }, }, + rules: [ + { + // Exclude .mjs files from bundling (ESM loader must remain a separate file) + test: /\.mjs$/, + type: 'javascript/auto', + resolve: { fullySpecified: false }, + }, + ], }, plugins: [], };