tc39 / proposal-built-in-modules

BSD 2-Clause "Simplified" License
892 stars 25 forks source link

If we don't go with npm-style module specifiers, what should the syntax be? #20

Open littledan opened 5 years ago

littledan commented 5 years ago

Ideas I've heard so far:

What else are you thinking of?

Splitting into another thread based on https://github.com/tc39/proposal-javascript-standard-library/issues/12#issuecomment-447925779 .

Mouvedia commented 5 years ago

There's also obedm503's proposal of simply not using strings. The scope would be implied in that case.

glen-84 commented 5 years ago

This would then be valid:

import x from temporal;
import y from 'temporal';     // an npm package, for example

The difference is very subtle, and could lead to confusion/errors ... and possibly security concerns?

glen-84 commented 5 years ago

Staying with strings, there aren't many options.

Here are two alternatives. Not sure if either of them are "ideal", but perhaps worth mentioning:

import {a} from "std(date)"; 
import {b} from "std(date/sub)";
import {c} from "@some/thing";     // npm

import {a} from "std|date"; 
import {b} from "std|date/sub";
import {c} from "@some/thing";     // npm
devsnek commented 5 years ago

i'm a fan of import { Instant } from time;. i can't really even imagine a case where we need more than a single identifier there.

if we wanted to really make things distinct i'd offer up import { Instant } from standard.time;

LinusU commented 5 years ago

How about just importing from a known URL, then we could also serve those modules over standard http and instantly be backward-compatible with all current runtimes.

import { Instant } from 'https://ecma-international.org/tc39/stdlib/time.js'

This is how Deno does it, which means that the stdlib would also be instantly usable from Deno.

Then runtimes can ship the stdlib preloaded right into the browsers if they want to...

Mouvedia commented 5 years ago

How about just importing from a known URL

If the modules fetched from reserved domains are automatically cached by the browsers with a long TTL, I would agree. If not I think it's a bad idea.

LinusU commented 5 years ago

Since versioning (#17) isn't decided yet, it's hard to give an exact proposal but it should absolutely be cached.

In fact, if the browsers wanted to, they could ship their own custom implementation of the stdlib that gets loaded when a load from https://ecma-international.org/tc39/stdlib is detected.


If we are going to use SemVer, I'm suggesting that full version URLs would use Cache-Control: immutable headers so that they would be cached forever, and that semver-specifier would simply 3xx-redirect with an appropriate cache timeout:

glen-84 commented 5 years ago
  1. It's too verbose IMO, easy to introduce typos (even with copy/paste) without specialized linting, etc.
  2. Including a version number in the module name would require you to update that name in many files when switching versions. IMO, this should be done with a mapping. i.e. a module name written as "example" maps to "example@1.2.3" when loaded. In that way, you only need to change the version number in one place.
martinheidegger commented 5 years ago

Another approach might be to use a new keyword like:

import Date builtin "date"

or

built-in import Crypto as c
gibson042 commented 5 years ago

Copied from https://github.com/tc39/proposal-javascript-standard-library/issues/12#issuecomment-448025757 :

scheme::module is a valid absolute URI, cf. RFC 3986 section 3 and section 3.3 and observe that hier-part (which follows the first ":") can be path-rootless, which starts with segment-nz, which starts with pchar, which can be ":".

URI scheme must start with an alphabetic character and consist only of alphanumerics, "+", "-", and "." (cf. section 3.1), but e.g. @scheme:module is still a valid relative URI reference, and even :scheme:module or %scheme:module or <scheme:module or |scheme:module (although not valid per RFC 3986) are accepted by the WHATWG parsing algorithm—but at least all but the first of those are nonconforming and therefore at least potentially subject to special treatment (likewise for any other format that starts with something other than an ASCII letter, "/", "?", "#", or a URL code point).

If the idea is to differentiate standard library modules from potentially-relative URIs, I think a super-special prefix like "%" would be most prudent.

mattijs commented 5 years ago

I would vote for scheme::module. I think we should stay with strings to avoid confusion (and mistakes?) and complicating the import syntax. To me the :: is a strong enough signal that the module is distinct from other (user land) modules.

Something that looks like an http/https URL does not make sense in all contexts you can find an import statement in and they are also easily mistaken for user modules.

zenparsing commented 5 years ago

I'm not sure I understand how :: will help. I think that from the user's (and readers) point of view, the distinguishing feature won't be the number of colons, but rather the name before the colon.

ljharb commented 5 years ago

If that's the case, then why wouldn't "the name between the @ and the /" provide the same amount of distinguishing?

Mouvedia commented 5 years ago

Here's my summary:

  1. needs to be easily identifiable (amongst other imports)
  2. gotta be updatable/versioned (hopefully it will be perfect)
  3. should enable destructuring of std itself (not a feature for browsers but probably for embedding)
  4. if the separator chosen is : the std scheme will have to be registered
  5. gotta be convenient (e.g. std instead of standard is a convention)
  6. some kind of reflection (e.g. some way to tell if the browser has that module in std, to fallback)

"@std/foo" cannot be considered if you agree with 1. Using URLs directly reminds me of DOCTYPE and will probably facilitate sloppiness and versioning management on the client side. It should be transparent for the developer: the browser should handle the hotfixes/errata. If tree shaking (of the std itself) is really essential for embedding purposes then you gotta keep an explicit name for std: it cannot be implied. For the ones wondering what I mean by 3. you gotta imagine a hypothetic standard library that would be at least as big as lodash/underscore.

  1. is important so that we can dynamically import the polyfill if the browser is outdated. IMHO importing a missing module from std shouldn't fail but return undefined to enable if (!defaultImport) …
isaacs commented 5 years ago

None of this makes the case for why anything else is better than @std/foo, as none of it addresses the cowpath-paving consideration. I like the idea of dropping the string entirely, and just putting a bare identifier in the import statement. But again, people will copy this and then get annoyed that they can't do it natively in platforms that support it for std modules. Why not just do it the easy way people already understand?

But all that being said, I'd like to earnestly suggest:

import now from Std\1.5.4\CoreLibs\Temporality\Time\Date
  1. It will be easily identifiable, because no one would choose to make their userland libs look like this.
  2. A version number can be placed anywhere as needed for versioning.
  3. No url scheme to register
  4. No chance of being confused with a file path (except perhaps on Windows) or url
  5. Successful established precedent with PHP namespaces.
  6. New syntax can have new semantics (1js), and this is not valid JS today.
thysultan commented 5 years ago

In favour if non-string syntax for among other reasons that it feels the most "standard" or as part of the "environment" and not something akin to an external import.

For example assuming a host(the browser) exposes the Window import. It could be written as either of the following:

import document from '@std/window'
import document from 'std:window'
import document from Window

As aforementioned the first case very much conflicts negatively with npms "@scoped packages by means of imitation(it looks like a scoped npm package, but actually isn't), i would imagine avoiding this would be in the over arching proposals best interest similar to the case with pattern matching and an overloaded switch syntax.

The second form is, much like the first, "a magic string" that we have come to associate(implicitly/explicitly) with external packages.

This form also shares some semblance to data url imports: import x from "data:text/javascript,\...".

The third conforms closer to how we already implicitly reference "standard" objects. Object, Number etc.

I can't hope but wonder if this would also play well with inline modules making it easier to polyfil future/current standard library functions that are "not-yet" exposed.

zenparsing commented 5 years ago

@thysultan Wouldn't the importing of bulit-in modules using identifiers actually conflict with possible future inline modules?

module Window { export const x = 1 }
import { x } from Window; // lexical Window or "built-in" Window?

Note that Dart uses scheme-like specifiers (dart: for builtins and package: for userland).

thysultan commented 5 years ago

@zenparsing That's what makes it easier to polyfill without the need for a built-time transpiler.

I was using the term polyfill in both the "imitate future features" and "fix current features/bugs" context, given there are some polyfill that only do the later.

Though we might hold engine implementations to a high standard, there are times when they might be broken or are lacking in the levels of abstraction/features one might desire, introducing the need for an author to extend it to fit the bill. For example:

module Window extends Window {
// extend the Window module
export const head = document.head
}

module Window {
// implement a Window polyfill
export const document = window.document
}

This lays a good foundation for the consumer to never have to worry about changing import identifiers between using the builtin, or polyfill for/if any of the aforementioned reasons arise.

import {head} from Window
martinheidegger commented 5 years ago

By thinking about backwards compatibility what about:

import crypto from std`crypto`

this way it could work in node like

import fs from node`fs`

or in require form

const fs = require(nodejs`fs`)
// ... or ...
const fs = require.builtin('nodejs', 'fs')
obedm503 commented 5 years ago

based off the idea of namespaces and the idea of not using strings/URL, what about?

import crypto from std::crypto

// and in node .mjs
import fs from node::fs

again, node/commonjs can do it's own thing be it require.builtin or require('std::crypto'). I'm thinking of this proposal as an extension to the ES module system.

Mouvedia commented 5 years ago

:: is used in E4X and JScript.

obedm503 commented 5 years ago

@Mouvedia can you provide some more context?

Mouvedia commented 5 years ago

@obedm503 I am just saying that :: may already have an ingrained meaning if you used the E4X extension or the JScript dialect. More recently you had the bind operator proposal.

glen-84 commented 5 years ago

Unquoted module names are not ideal IMO:

  1. It's not easy to differentiate between a quoted and unquoted module with the same name (f.e. an npm package). https://github.com/tc39/proposal-javascript-standard-library/issues/20#issuecomment-447931370
  2. It's difficult to introduce punctuation like :/::/etc. for a namespace, or / for "sub-modules", as it can then conflict with other syntax (either current or future).
  3. It wouldn't work well with dynamic imports (?) ... import(std:crypto); ... is std a named argument? Is crypto an alias?
  4. It's inconsistent with existing imports that use a string.
Mouvedia commented 5 years ago

@glen-84 3. seems like a deal breaker to me.

devsnek commented 5 years ago

why would you ever need to dynamically import standard modules?

littledan commented 5 years ago

@devsnek One common case would be using a standard module from a script.

glen-84 commented 5 years ago

Could the import syntax be extended to allow runtimes to use a keyword after from?

import crypto from std "crypto";
import crypto from nodejs "crypto";

The dynamic loading syntax would be slightly more exotic:

const crypto = await import(std "crypto");
const crypto = await import(nodejs "crypto");

Just thinking out aloud.

Mouvedia commented 5 years ago

@glen-84 that's interesting to me it looks almost like a tag function.

e.g. std`foo`

glen-84 commented 5 years ago

@Mouvedia,

@martinheidegger suggested that above, but I think it's best not to use existing syntax in this position, as it could lead to confusion ... "is that a tagged template literal?", "can I create my own tagged template function for imports?", etc.

obedm503 commented 5 years ago

@littledan maybe I missed this somewhere, but can dynamic import be used outside of type="module"?

ljharb commented 5 years ago

@obedm503 it also can be used in Scripts.

thysultan commented 5 years ago

Babel style bare modules:

import {document} from 'Window'

Inconjuction with inline modules might still pave a happy path for polyfilling modules.

module Window {...}
martinheidegger commented 5 years ago

@glen-84 Using template strings could on the other hand be used as pro-argument: Every built-in library has to be registered as template string in the vm & at build of the vm all the elements are registered the same way. What I like about the template string syntax is that it teaches that there are template strings and how those work.

mcollina commented 5 years ago

I think scopes are necessary because new globals are currently being introduced to add new APIs. If we make those APIs modules, we would have potential naming conflicts. Adding scopes removes this limitation.

TeoTN commented 5 years ago

What if stdlib was not specified as a string but as a Symbol instead? And we could have a template to return them, e.g. import { Map } from std `@js/collections/immutable`; (great idea BTW)?

Where

const std = module => isValid(module) ?
 Symbol.get(module) : throw Error()

(or whatever syntax for error expression wins)

We could have imports "from symbol" reserved to native modules and have it easily versioned + npm independent:

import { api2020 as std } from std `@js/api`;
import { Map } from std `@js/collections/mutable`; // uses ES2020
import { X } from Symbol; // throws error

I'm trying to think of a brand new imports mechanism that would build on top of what we have and look kinda familiar.

Also, I personally don't feel satisfied with the idea of :: or . separators, because as much as I like C++/Java/Scala/whatever, they are not JS and their syntax doesn't have to be copied. That is, we should get inspired by possibilities of other languages but build on top of what we have instead of having hard copy. Many devs know import { Component } from '@angular/core or similar, and as the pattern already had built its neuron paths in devs brains, we should extend that, not build another parallel highway.

martinheidegger commented 5 years ago

@wopian @YurySolovyov @b-strauss The more I think about

import X from std`x`

the more I like the solution, I am curious why you don't/didn't like it.

ljharb commented 5 years ago

That requires std to be in scope, and be a function that’s normally callable - if it’s going to be consistent with existing tagged template literal syntax. Since imports are parsed before code is evaluated, i don’t see how that’s a viable path forward.

martinheidegger commented 5 years ago

@ljharb I see and I kinda get your point: It would pollute the global namespace with a nodejs and/orstd function. But that function could work:

const modules = {
  fs: Symbol('fs')
}
function std(strings) {
  if (strings.length > 1) {
    throw new Error('You can not request a module with a paramter')
  }
  const symbol = modules[strings[0]]
  if (!symbol) {
    throw new Error(`Unknown nodejs module: ${symbol}`)
  }
  return symbol
}

console.log(std`fs`)

Alternatively, you could simply treat the import ... statements as "non-standard-blocks-of-code" .... which they already are, after all: you can not do any string operations in there: import x from "abc"+"def" and any template string takes the template operation from a different API.

Alternatively one could simply namespace, the modules:

import fs from builtin.nodejs`fs`

where builtin is in the global scope.

ljharb commented 5 years ago

@martinheidegger to clarify, it simply wouldn't work because all imports must be resolved, per spec, before the global namespace (and any variables) are available in the first place.

martinheidegger commented 5 years ago

@ljharb If you mean by this that it needs to be added to the es-module specification, then I totally agree.

Pauan commented 5 years ago

@martinheidegger Even if it was added to the es-module spec, the simple fact is that no JavaScript code is evaluated before the modules are resolved.

This isn't like CommonJS where you can do require(someFunction("foo")), that simply is not possible with ES6 modules (unless you use the import(...) syntax).

So even if a std function existed in the spec, it wouldn't be capable of running.

Changing ES6 modules to allow for JS evaluation before resolving would be a huge change, with a lot of ramifications.

martinheidegger commented 5 years ago

@pauan I didn't suggest that the es6 modules allow to execute arbitrary code. I am suggesting that template strings allowed to be used and executed are narrowly defined, but those narrowly defined functions will stay exposed to the general javascript vm for educational purposes (and for dynamic imports)

ljharb commented 5 years ago

I think it would be massively confusing if tagged template literals worked magically differently in import statements than anywhere else - that seems like a nonstarter to me.

Pauan commented 5 years ago

@martinheidegger So you are suggesting that only template literals are allowed, and the template literal function is hardcoded (i.e. it can only be std)? And it does not use JS evaluation?

That would technically work, but what is the advantage of that over "@std/foo", "std:foo", or "std::foo"?

martinheidegger commented 5 years ago

@pauan the advantage is that "@std/foo" or "std:foo" is a namespace that suggest the same lookup operation as is used with any other module, which is not true for those modules.

Pauan commented 5 years ago

@martinheidegger That is true, but there is precedent for that: require("fs") uses a different lookup strategy from require("foo"), since "fs" is a built-in module.

And since std::foo is a new syntax, that also suggests a different lookup strategy.

ljharb commented 5 years ago

Given that import maps and loaders would let you override any of these lookups, it is, in fact, the same lookup operation as is used with any other module, full stop.

martinheidegger commented 5 years ago

@ljharb so you don't have to explain why require('fs') works just like that but that one needs to do something for require('express') to work? (... there is a difference)

@pauan Yes: there is the precedent of fs but it is a rather bad example as it pollutes the module namespace (There is a Node.js discussion ongoing about putting new modules in a namespace as well).

Yes: it is a new syntax and alternatives would be import X from std::something (no strings) or import X from Std\... (also no string quotes) mentioned above. As long as the string-quotes are there, there is the explanation requirement as to why they work differently.

Pauan commented 5 years ago

@martinheidegger I don't think it is necessary to remove the quotes.

Importing "http://foo.com" is very different from "@foo/bar", which in turn is very different from "foo", which in turn is very different from "./foo".

Even though they all use strings, they use different syntax, and they all use different lookup strategies. So it is with "std:foo" or "std::foo" as well.

There is no "explanation requirement", since import strings are URLs, so having the std: protocol automatically makes it different (just like how having the http: protocol automatically makes it different).