I think breaking changes can be avoided. All Meteor APIs are already exposed globally via Package.*
. By implementing an ESM customization hook, the Package.*
references can simply be exported to the import
statements of any native modules.
This will be very similar to what I’ve done with native client-side ESM: my import map simply maps the specifiers to a file that exports stuff from Package.*
. (Note, import maps are on the Node.js roadmap as an alternative standards-aligned way to achieve this later).
Customization hooks also allow transpiling content (f.e. they can read a .css
file (from the specifier of an import statement), and make the default export be a string containing that CSS, and we can do similar with TypeScript, etc).
File caching would be needed to avoid compilation every single restart if files haven’t changed. A customization hook could also communicate with a dev server that provides cached results.
Here’s the Node’s example on how to begin to add URL imports like Deno: Modules: node:module API | Node.js v21.5.0 Documentation
Here’s a sample of what implementing meteor/foo
modules might look like:
// meteor-hooks.mjs
export function load(url, context, nextLoad) {
// Handle 'meteor/*' specifiers
if (url.startsWith('meteor/')) {
const lib = url.split('/')[1] // f.e. "tracker" in "meteor/tracker"
return { format: 'module', source: `export default Package['${lib}']` }
}
// Let Node.js handle all other URLs.
return nextLoad(url);
}
User code for that implementation pattern would need to get the default instead of a named export, and type defs would need to be adjusted, but it would work:
import tracker from 'meteor/tracker'
const {Tracker} = tracker
Alternatively, if we ensure that meteor packages all export the Uppercase name of the package, we can make it a named export:
// meteor-hooks.mjs
export function load(url, context, nextLoad) {
// Handle 'meteor/*' specifiers
if (url.startsWith('meteor/')) {
const lib = url.split('/')[1] // f.e. "tracker" in "meteor/tracker"
const pascalCased = dashToPascalCase(lib) // f.e. tracker to Tracker or foo-bar to FooBar
return { format: 'module', source: `export const ${pascalCased} = Package['${lib}']['${pascalCased}']` }
}
// Let Node.js handle all other URLs.
return nextLoad(url);
}
And then user code would be more like today’s:
import {Tracker} from 'meteor/tracker'
Doing it on native ESM with customization hooks (and/or with import maps later) you won’t have to worry about this. The native module resolution algo will handle that.
No longer resolve hooks (CommonJS), but customization hooks.
That’s a good point. I’ve yet to experience a problem big enough where I needed to bundle my app (with native CommonJS or with native ESM), but it imagine it could certainly be needed.
Bundling would be an optimization for that case, but I think it should be built anew, not based on the ecmascript
package, but perhaps on something like esbuild
or Vite if not something custom. It will need to support standards like import.meta.url
which, when bundled, should not change meaning (console.log(import.meta.url)
should log the same both with no bundling or with bundling).
I think the first step will be to get the native ESM mode working, without any such optimizations up front.
Trying to do it all at once will be far too much work, and having a plain ESM mode will already be ideal to begin with: people can start to use their own build tools to target native ESM mode, f.e. compile their own TS to JS, while existing users with existing apps can, for now, continue to use ecmascript
and typescript
packages.
An initial native ESM mode will usable for new Meteor apps, while existing apps may have migration work to do, but at least it would be a good start until optimzation features come later.
Even starting with no customization hooks, and just global Package
for Meteor APIs, and user code as plain ES modules, would be great. Any step that gets us onto on the native ESM path will be great.
It won’t be needed with native ESM, the native loader read exports fields.