15:03
<jakebailey>
this is a good discussion topic, I'd suggest we bring it up in our next meeting if you'd like to join
Nobody's here, after opening the agenda doc I guess I was supposed to have added something to the schedule?
15:06
<jakebailey>
That or TC39 is still happening and "next meeting" didn't mean next week 😄
15:09
<nicolo-ribaudo>
Whips sorry 😅
15:09
<nicolo-ribaudo>
Yes, TC39 is still happening
15:10
<jakebailey>
I rushed out of bed for nothing! 😄 (oops)
15:10
<ljharb>
(TC39 meetings tend to all be cancelled during TC39 plenary week)
15:29
<littledan>
sorry, we'll try to update the calendar for accuracy in the future
15:29
<jakebailey>
No worries.
15:36
<nicolo-ribaudo>
Also btw, if you have something to talk about please make sure it's on the agenda! We usually consider meeting to be automatically cancelled if 12 hours before the agenda is empty, so that you don't have to wake up early just to discover that the meeting is cancelled
15:36
<nicolo-ribaudo>
Oh the agenda says "30 minutes"
15:37
<nicolo-ribaudo>
I always assumed it was something like "the evening before for the west coast" 😅
16:15
<joyee>
Yeah. So on one hand, I'd be happy with import.meta.require being re-proposed, especially now that Node has import.meta.dirname and such, but there's the more general solution too!
It seems to me what you need that is missing from the spec is the ability to do lazy resolution/more lenient resolution, so that you don't get an error by importing from an id that doesn't resolve
16:16
<joyee>
(Or maybe there can be an error, but one you can catch, though that's not necessarily good for perf)
16:17
<jakebailey>
It'd be at the top level, so there's nothing to catch. Technically speaking an import that is allowed to be there but not resolve and not error if not touched is fine, but my impression was that the deferred import proposal still errored on a failed import
16:17
<littledan>
It seems to me what you need that is missing from the spec is the ability to do lazy resolution/more lenient resolution, so that you don't get an error by importing from an id that doesn't resolve
This is a good thread to pull on. browsers are also wondering whether we could let some other types of early errors to come later.
16:17
<jakebailey>
so, we'd end up breaking in browsers if we had node:fs
16:17
<joyee>
Yes, the import defer proposal is deferring evaluation, not resolution
16:18
<littledan>
WebAssembly started with eager validation, but later permitted lazy
16:18
<jakebailey>
But, at some level I think that the abiliy to conditionally require synchronously is a useful construct for some libraries who can't go async, and a sync import now seems like a possible thing without being in CJS
16:19
<joyee>
But it does sound like a generally useful thing to have, for example for modules that want to work on both the browser and server side runtimes, otherwise, everyone just puts everything on the global
16:21
<littledan>
But it does sound like a generally useful thing to have, for example for modules that want to work on both the browser and server side runtimes, otherwise, everyone just puts everything on the global
it's true, this is a sort of missing capability from CJS
16:21
<joyee>
CJS require() couples resolution + evaluation in one go. ESM import decouples them. In the use case we are talking about, the conditional part is resolution, which currently in ESM is only possible in dynamic import()
16:23
<jakebailey>
I guess I don't see those as separate for this use case, since I want the whole thing to be conditional; we precheck and only do this on a "node like" system
16:25
<joyee>
My understanding of the history of ESM is fairly limited, but I think the decoupling in ESM is part of the design (personally, I love it, because that unlocks things like module snapshot and tree shaking, and the coupling in CJS makes me sad). My guess is, making the resolution lazy/conditional/more leinient via syntax is still possible while preserving the decoupling in ESM (I could be too naive, too)
16:27
<jakobjingleheimer>
That's my understanding too 🙂
16:38
<joyee>
Or maybe, it doesn't need new syntax, just special, standardized import attributes across the platforms that allow weak imports, like what's described in https://lea.verou.me/blog/2020/11/the-case-for-weak-dependencies-in-js/, but also for import
16:39
<joyee>
(With the potential downside of, what happens if you need to support older versions of browsers/runtimes? đŸ˜”â€đŸ’«)
16:41
<guybedford>
jakebailey: Sorry for the meeting confusion. I missed that the meeting was cancelled this week myself, and thought we were on a bi-weekly schedule for some reason!
16:42
<joyee>
import fs from "node:fs" with { weak: true };

// 1. In Node.js or other runtimes with Node.js compat layer, fs is fs,
// also applies to named exports for APIs that are supported.
// 2. In runtimes that don't support it/browsers, fs is undefined..or a symbol? Or something else?
16:46
<jakebailey>
The complexity here was more or less why I was re-proposing import.meta.require / import.meta.importSync or something, a la import.meta.dirname and so on which are Node specific constructs
16:48
<jakebailey>
With require(ESM), it really felt like we could finally make TypeScript be ESM without breaking the ecosystem, but then I realized that I couldn't without some way to conditionally handle node, so it became self defeating... Without resorting to import conditions anyway, which is where things get super frustrating internally for us
16:52
<ljharb>
conditional static imports is definitely something we need. there used to be a proposal but it doesn't seem to even be in the proposals table
16:53
<jakobjingleheimer>
Why?
16:54
<ljharb>
i'm not sure, i'll have to track that down
16:55
<jakobjingleheimer>
To be clear, i meant why do we need them?
16:56
<joyee>
There are some more pragmatic pros to import.meta.require, like being (significantly?) faster than import cjs or import builtin on Node.js, or easier to feature detect. Cons are...one more thing on the import.meta? I think some consider that evil?
16:56
<kriskowal>
CJS require() couples resolution + evaluation in one go. ESM import decouples them. In the use case we are talking about, the conditional part is resolution, which currently in ESM is only possible in dynamic import()
I think it’s useful to distinguish Node.js loader from abstract CJS loaders (which is a tent with Node.js, bundlers, and others). That makes it clear that the choice to couple resolution and evaluation is a Node.js-specific constraint. CJS and ESM were both designed to cover the web’s need to resolve specifiers for transitive dependencies before evaluation. CJS doesn’t even mandate that evaluation ever occur on the stack of require. I could buy the case for an import.now in the language (and importNowHook on virtual Module instances)
16:56
<ljharb>
ah! well, for one, platform-specific deps - like node:fs for example. you might want to sync-import a polyfill, but only when a feature test fails.
16:57
<kriskowal>
The Node.js case is interesting historically. Ryan Dahl was reluctant to embrace CommonJS. When I talked to Bryan Cantril about it in 2010, his take was that it didn’t matter, even though it ran against the “everything async” grain of the platform, because linking a dynamic library is never going to be async.
16:58
<ljharb>
or at least, you need it to be sync the vast majority of the time
16:59
<kriskowal>
In the CommonJS mailing list days, there was a great deal of talk about a require.async that would return a promise like dynamic import, but there wasn’t agreement on the design of promises yet.
17:00
<kriskowal>
One of the other pressures to make Node.js resolution synchronous was that the CJS loader doesn’t opportunistically discover the package.json tree, as the ESM does. Embracing the package tree is interesting because resolution can be synchronous if you know every reachable path, and is analogous to having an import-map on the web.
17:02
<kriskowal>
History aside, I think we should have import.now, deferred exports, and with both of these, deferring resolution to conditional use is possible.
17:03
<ljharb>
node's ESM doesn't allow knowing every reachable path tho, because of conditional exports, and because of subpath patterns (unless you include the filesystem and treat it as immutable, in which case CJS provides the exact same property)
17:03
<joyee>
What does opportunistically discover the package.json tree mean?
17:12
<kriskowal>
I think I mis-inferred that changing import.meta.resolve from async to sync on both the web and in node meant that it no longer required sync I/O to answer for all paths.
17:14
<kriskowal>
On the web, the import-map makes it possible to answer for all specifiers without I/O. On Node.js, static resolution makes it sync I/O-free most of the time, but yeah, if Node.js doesn’t locate every package reachable from a module before evaluating a module, it would have to fall through to sync I/O sometimes.
17:14
<kriskowal>
And of course the extension search path isn’t captured in package.json anyway, so yeee.
17:16
<Richard Gibson>

related: https://github.com/tc39/proposal-import-attributes/issues/153#issuecomment-1981354653

// Import config with type "wasm" if possible, otherwise with type "json".
import '//test.local/config' with { type: "wasm" },
       '//test.local/config' with { type: "json" }

// Import an empty module if `0m` is valid syntax, otherwise import a BigDecimal polyfill.
import '/empty.mjs' with { condition: { "validSyntax": "0m" } },
       '/polyfills/BigDecimal.mjs'

// Import CSS if possible, otherwise JS.
import styles from './styles.css' with { type: "css" },
                   './styles.mjs'
17:17
<kriskowal>
(follow-up on main thread)
17:20
<joyee>
Not quite sure if I am following but synchronicity of resolution in Node.js is more of an implementation detail that can be changed
17:21
<joyee>
For CJS and require(esm) it does synchronous I/O all the way, querying the nearest the package.json until it reaches the root directory
17:21
<joyee>
(That's actually faster than the async I/O a full import esm is doing)
17:22
<kriskowal>
Yeah, understood on the performance front. That was the grounds for implementing CJS with sync I/O on Node.js.
17:22
<joyee>
And the resolution result is also cached, so import.meta.resolve can just throw cache entries in there, or get cached entries
17:23
<kriskowal>
And I was mistaken that Node.js can avoid I/O for sync ESM. I hadn’t throught through all the cases.
17:23
<joyee>
If it's cached, then no I/O. If it isn't, at least some I/O is always needed to discover package.json, especially when people use .js then Node.js need to check type field
17:24
<kriskowal>
Right, I’d hoped (but was mistaken) that the import.meta.resolve memo could be proven to be complete before a module evaluates.
17:24
<jakebailey>
Largely though, isn't this unrelated? Like, node ESM stuff is now off-thread, and "looks" synchronous, but internally can be sync or async however it feels
17:24
<joyee>
Node.js ESM is not off thread
17:24
<kriskowal>
Blocking on off-thread work is synchronous from the JS point-of-view.
17:24
<jakebailey>
Hm, I guess I am misunderstanding about the work that went into making import.meta.resolve synchronous
17:25
<kriskowal>
What makes it synchronous is that it starves progress on the event loop until complete.
17:25
<joyee>
Only custom loaders are, and we are proposing to add something that allows you to do it in-thread, because the off-thread thing is basically in-thread hooks + worker + block on Atomics.wait
17:26
<joyee>
Still it's only off thread when you customize the loader. It's in thread if you are not doing that
17:26
<kriskowal>
Deadlock enters the chat :|
17:27
<joyee>
Yes, the off thread hooks are suffering from deadlocks, another reason to provide in thread hooks
17:28
<joyee>
But then, the default ESM loader without customization is still in the same thread.
17:28
<joyee>
And no workers etc.
17:29
<joyee>
And default ESM loader + future in-thread hooks are also in the same thread. No locks, no worker, not even event loop ticks, everything is synchronous, until you deliberately throw something async in the graph (TLA, network imports, etc.)
17:32
<jakobjingleheimer>
Deadlock is happening in only 1 scenario that we know of, right joyee ?
17:32
<joyee>
Currently, yes, but I suspect not being able to control the worker can lead to more
17:33
<joyee>
And in-thread hooks can allow users to control the worker and work around it
17:36
<kriskowal>
Would Node.js be in a good position to exploit a ModuleSource constructor that has an internal slot with an immutable representation of a compiled module that can be safely shared, without locking, between threads?
17:36
<joyee>

Hm, I guess I am misunderstanding about the work that went into making import.meta.resolve synchronous

The ESM loader is currently doing unconditional async resolution for import esm but that is an implementation detail. It can be conditionally async (only when someone does network imports). That'll make import.meta.resolve() a lot more performant too

17:37
<joyee>
(Which I suspect is part of why require(esm) is 1.2x faster than import esm, even, because require(esm) is doing a fully synchronous resolution)
17:45
<joyee>
For Node.js at least, which mostly deal with fs I/O, not network, synchronous I/O never shows up in the performance profile of loading any non-trivial app, encoding the UTF8 file content into a V8 string can be like 10x more expensive than I/O
17:51
<joyee>

Would Node.js be in a good position to exploit a ModuleSource constructor that has an internal slot with an immutable representation of a compiled module that can be safely shared, without locking, between threads?

From what I can tell, that looks like an abstraction of source code (+ origin and all?) of source text module. I think that can be useful, but whether it needs to be sharable across threads would only matter to those who customize the ESM loader AND use off-thread hooks. For in-thread cases (default, or using in-thread hooks), it's something Node.js can already internally create (I think that's basically our ModuleWrap, or what's underneath vm.SourceTextModule)

18:01
<guybedford>
since we're discussing Node.js here, it's worth mentioning we do want to avoid precluding network imports as being first-class in future though
18:03
<joyee>
That can just be conditionally async. You pay for the cost when you actually have network import in the graph. But no need to make everyone slow when there's nothing async in the graph
18:09
<joyee>
Actually with a cache layer, everything can be synchronous again with the cache even if you have network imports
and you don’t need to pay for the async overhead if the conditional asynchronicity is enforced
19:37
<Jan Olaf Martin>
For Node.js at least, which mostly deal with fs I/O, not network, synchronous I/O never shows up in the performance profile of loading any non-trivial app, encoding the UTF8 file content into a V8 string can be like 10x more expensive than I/O
That may depend on the app. Sync I/O (or maybe rather the amount of stats) is causing seconds of slow down for some of our apps during startup. If I/O wouldn’t be a bottleneck, I assume bundling wouldn’t be such a big win in some setups.
19:37
<Jan Olaf Martin>
Cold startup times was a major reason why I pushed against the super I/O heavy CommonJS resolution style for ESM in node
19:39
<joyee>
That may depend on the app. Sync I/O (or maybe rather the amount of stats) is causing seconds of slow down for some of our apps during startup. If I/O wouldn’t be a bottleneck, I assume bundling wouldn’t be such a big win in some setups.
I suspect you are talking about fs.read*, not actual syscall
19:40
<joyee>
Bundling is a win not because it avoids I/O, but because it avoids resolution (computation heavy)
19:42
<joyee>
And in Node.js most of the startup time is spent on compilation and

string encoding (which might mislead people into thinking I/O is slow - it’s actually the Node.js module loader using the fs API that also does the string encoding 😅)
19:42
<Jan Olaf Martin>
From our profiling, it was specifically the stat syscalls. I’ll happily yield that it might be edge cases because we run a lot of things on file systems that are backed by network access and can have cold caches. Adding x2/x3 the amount of disk cache entries that need to be warmed up isn’t free
19:44
<joyee>
Doing it asynchronously can’t save you that, either, if the application still needs to wait for those calls to complete to do anything interesting
19:45
<Jan Olaf Martin>
Doing it async means that the disk cache can be warmed in parallel which makes a huge difference in those scenarios. But I was mostly talking about the I/O cost in general, not specifically about sync vs async I/O.
19:47
<joyee>
The syscall can still be done in parallel, just in native threads
19:48
<joyee>
But also I am sensing you are talking about CJS loading performance, not ESM in Node.js, because that’s already parallel
19:49
<Jan Olaf Martin>
Yes, I’m talking about what I saw as a “lesson learned from CJS’ resolution system” when we made the ESM decisions
19:53
<joyee>
This is stat call which historically suffer from improper cache misses, so it could still be computation problems
19:57
<joyee>
Unless the graph structure you have invalidates cache in the module loader a lot, which sounds like the case if you are backing them with network access already. Most Node.js applications don’t do that, though.