Skip to content

Commit 9af438a

Browse files
authored
jsr: imports (#957)
* jsr: imports * comments, prettier * read _jsr cache * consolidate * test @quentinadam/hex * test @oak/oak * transitive jsr: imports * it works! * jsr preloads * it just works * resolve.exports * rewrite all .js * simpler rewriteNpmImports * jsr docs * remove test page * test initializeNpmVersionCache * resolve dependency version * more comments * copy edits * populate cache; handle error * fail on unhandled implicit import * fix getModuleStaticImports for jsr: * test jsr: imports * remove spurious log
1 parent a7578ce commit 9af438a

25 files changed

+1152
-370
lines changed

docs/imports.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,18 @@ Unlike `npm:` imports, Node imports do not support semver ranges: the imported v
9999

100100
Imports from `node_modules` are cached in `.observablehq/cache/_node` within your [source root](./config#root) (typically `src`). You shouldn’t need to clear this cache as it is automatically managed, but feel free to clear it you like.
101101

102+
## JSR imports <a href="https://github.com/observablehq/framework/pulls/957" class="observablehq-version-badge" data-version="prerelease" title="Added in #957"></a>
103+
104+
You can import a package from [JSR (the JavaScript Registry)](https://jsr.io/) using the `jsr:` protocol. When you import using `jsr:`, Framework automatically downloads and self-hosts the package. (As with `npm:` imports, and unlike Node imports, you don’t have to install from `jsr:` manually.) As an example, here the number three is computed using a [pseudorandom number generator](https://jsr.io/@std/random) from the [Deno Standard Library](https://deno.com/blog/std-on-jsr):
105+
106+
```js echo
107+
import {randomIntegerBetween, randomSeeded} from "jsr:@std/random";
108+
109+
const prng = randomSeeded(1n);
110+
111+
display(randomIntegerBetween(1, 10, {prng}));
112+
```
113+
102114
## Local imports
103115

104116
You can import [JavaScript](./javascript) and [TypeScript](./javascript#type-script) modules from local files. This is useful for organizing your code into modules that can be imported across multiple pages. You can also unit test your code and share code with other web applications.

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"test/build/src/bin/",
4444
"test/build/src/client/",
4545
"test/build/src/convert.js",
46+
"test/build/src/jsr.js",
4647
"test/build/src/observableApiConfig.js",
4748
"test/build/src/preview.js"
4849
],
@@ -83,10 +84,12 @@
8384
"minisearch": "^6.3.0",
8485
"open": "^10.1.0",
8586
"pkg-dir": "^8.0.0",
87+
"resolve.exports": "^2.0.2",
8688
"rollup": "^4.6.0",
8789
"rollup-plugin-esbuild": "^6.1.0",
8890
"semver": "^7.5.4",
8991
"send": "^0.18.0",
92+
"tar": "^6.2.0",
9093
"tar-stream": "^3.1.6",
9194
"tsx": "^4.7.1",
9295
"untildify": "^5.0.0",
@@ -105,6 +108,7 @@
105108
"@types/node": "^20.7.1",
106109
"@types/prompts": "^2.4.9",
107110
"@types/send": "^0.17.2",
111+
"@types/tar": "^6.1.11",
108112
"@types/tar-stream": "^3.1.3",
109113
"@types/ws": "^8.5.6",
110114
"@typescript-eslint/eslint-plugin": "^7.2.0",

src/jsr.ts

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import {mkdir, readFile, writeFile} from "node:fs/promises";
2+
import {join} from "node:path/posix";
3+
import {Readable} from "node:stream";
4+
import {finished} from "node:stream/promises";
5+
import {globSync} from "glob";
6+
import {exports as resolveExports} from "resolve.exports";
7+
import {rsort, satisfies} from "semver";
8+
import {x} from "tar";
9+
import type {ImportReference} from "./javascript/imports.js";
10+
import {parseImports} from "./javascript/imports.js";
11+
import type {NpmSpecifier} from "./npm.js";
12+
import {formatNpmSpecifier, parseNpmSpecifier} from "./npm.js";
13+
import {initializeNpmVersionCache, resolveNpmImport, rewriteNpmImports} from "./npm.js";
14+
import {isPathImport} from "./path.js";
15+
import {faint} from "./tty.js";
16+
17+
const jsrVersionCaches = new Map<string, Promise<Map<string, string[]>>>();
18+
const jsrVersionRequests = new Map<string, Promise<string>>();
19+
const jsrPackageRequests = new Map<string, Promise<void>>();
20+
const jsrResolveRequests = new Map<string, Promise<string>>();
21+
22+
function getJsrVersionCache(root: string): Promise<Map<string, string[]>> {
23+
let cache = jsrVersionCaches.get(root);
24+
if (!cache) jsrVersionCaches.set(root, (cache = initializeNpmVersionCache(root, "_jsr")));
25+
return cache;
26+
}
27+
28+
/**
29+
* Resolves the desired version of the specified JSR package, respecting the
30+
* specifier’s range if any. If any satisfying packages already exist in the JSR
31+
* import cache, the greatest satisfying cached version is returned. Otherwise,
32+
* the desired version is resolved via JSR’s API, and then the package and all
33+
* its transitive dependencies are downloaded from JSR (and npm if needed).
34+
*/
35+
async function resolveJsrVersion(root: string, {name, range}: NpmSpecifier): Promise<string> {
36+
const cache = await getJsrVersionCache(root);
37+
const versions = cache.get(name);
38+
if (versions) for (const version of versions) if (!range || satisfies(version, range)) return version;
39+
const href = `https://npm.jsr.io/@jsr/${name.replace(/^@/, "").replace(/\//, "__")}`;
40+
let promise = jsrVersionRequests.get(href);
41+
if (promise) return promise; // coalesce concurrent requests
42+
promise = (async function () {
43+
process.stdout.write(`jsr:${formatNpmSpecifier({name, range})} ${faint("→")} `);
44+
const metaResponse = await fetch(href);
45+
if (!metaResponse.ok) throw new Error(`unable to fetch: ${href}`);
46+
const meta = await metaResponse.json();
47+
let info: {version: string; dist: {tarball: string}} | undefined;
48+
if (meta["dist-tags"][range ?? "latest"]) {
49+
info = meta["versions"][meta["dist-tags"][range ?? "latest"]];
50+
} else if (range) {
51+
if (meta.versions[range]) {
52+
info = meta.versions[range]; // exact match; ignore yanked
53+
} else {
54+
for (const key in meta.versions) {
55+
if (satisfies(key, range) && !meta.versions[key].yanked) {
56+
info = meta.versions[key];
57+
}
58+
}
59+
}
60+
}
61+
if (!info) throw new Error(`unable to resolve version: ${formatNpmSpecifier({name, range})}`);
62+
const {version, dist} = info;
63+
await fetchJsrPackage(root, name, version, dist.tarball);
64+
cache.set(name, versions ? rsort(versions.concat(version)) : [version]);
65+
process.stdout.write(`${version}\n`);
66+
return version;
67+
})();
68+
promise.catch(console.error).then(() => jsrVersionRequests.delete(href));
69+
jsrVersionRequests.set(href, promise);
70+
return promise;
71+
}
72+
73+
/**
74+
* Fetches a package from the JSR registry, as well as its transitive
75+
* dependencies from JSR and npm, rewriting any dependency imports as relative
76+
* paths within the import cache.
77+
*/
78+
async function fetchJsrPackage(root: string, name: string, version: string, tarball: string): Promise<void> {
79+
const dir = join(root, ".observablehq", "cache", "_jsr", formatNpmSpecifier({name, range: version}));
80+
let promise = jsrPackageRequests.get(dir);
81+
if (promise) return promise;
82+
promise = (async () => {
83+
const tarballResponse = await fetch(tarball);
84+
if (!tarballResponse.ok) throw new Error(`unable to fetch: ${tarball}`);
85+
await mkdir(dir, {recursive: true});
86+
await finished(Readable.fromWeb(tarballResponse.body as any).pipe(x({strip: 1, C: dir})));
87+
await rewriteJsrImports(root, dir);
88+
})();
89+
promise.catch(console.error).then(() => jsrPackageRequests.delete(dir));
90+
jsrPackageRequests.set(dir, promise);
91+
return promise;
92+
}
93+
94+
/**
95+
* Resolves the given JSR specifier, such as `@std/bytes@^1.0.0`, returning the
96+
* path to the module such as `/_jsr/@std/bytes@1.0.2/mod.js`. This function
97+
* also allows JSR specifiers with a leading slash indicating an already-
98+
* resolved path such as /@std/bytes@1.0.2/mod.js.
99+
*/
100+
export async function resolveJsrImport(root: string, specifier: string): Promise<string> {
101+
if (specifier.startsWith("/")) return `/_jsr/${specifier.slice("/".length)}`;
102+
let promise = jsrResolveRequests.get(specifier);
103+
if (promise) return promise;
104+
promise = (async function () {
105+
const spec = parseNpmSpecifier(specifier);
106+
const {name} = spec;
107+
const version = await resolveJsrVersion(root, spec);
108+
const dir = join(root, ".observablehq", "cache", "_jsr", formatNpmSpecifier({name, range: version}));
109+
const info = JSON.parse(await readFile(join(dir, "package.json"), "utf8"));
110+
const [path] = resolveExports(info, spec.path === undefined ? "." : `./${spec.path}`, {browser: true})!;
111+
return join("/", "_jsr", `${name}@${version}`, path);
112+
})();
113+
// TODO delete request promise?
114+
jsrResolveRequests.set(specifier, promise);
115+
return promise;
116+
}
117+
118+
/**
119+
* After downloading a package from JSR, this rewrites any transitive JSR and
120+
* Node imports to use relative paths within the import cache. For example, if
121+
* jsr:@std/streams depends on jsr:@std/bytes, this will replace an import of
122+
* @jsr/std__bytes with a relative path to /_jsr/@std/bytes@1.0.2/mod.js.
123+
*/
124+
async function rewriteJsrImports(root: string, dir: string): Promise<void> {
125+
const info = JSON.parse(await readFile(join(dir, "package.json"), "utf8"));
126+
for (const path of globSync("**/*.js", {cwd: dir, nodir: true})) {
127+
const input = await readFile(join(dir, path), "utf8");
128+
const promises = new Map<string, Promise<string>>();
129+
try {
130+
rewriteNpmImports(input, (i) => {
131+
if (i.startsWith("@jsr/")) {
132+
const {name, path} = parseNpmSpecifier(i);
133+
const range = resolveDependencyVersion(info, name);
134+
const specifier = formatNpmSpecifier({name: `@${name.slice("@jsr/".length).replace(/__/, "/")}`, range, path}); // prettier-ignore
135+
if (!promises.has(i)) promises.set(i, resolveJsrImport(root, specifier));
136+
} else if (!isPathImport(i) && !/^[\w-]+:/.test(i)) {
137+
const {name, path} = parseNpmSpecifier(i);
138+
const range = resolveDependencyVersion(info, i);
139+
const specifier = formatNpmSpecifier({name, range, path});
140+
if (!promises.has(i)) promises.set(i, resolveNpmImport(root, specifier));
141+
}
142+
});
143+
} catch {
144+
continue; // ignore syntax errors
145+
}
146+
const resolutions = new Map<string, string>();
147+
for (const [key, promise] of promises) resolutions.set(key, await promise);
148+
const output = rewriteNpmImports(input, (i) => resolutions.get(i));
149+
await writeFile(join(dir, path), output, "utf8");
150+
}
151+
}
152+
153+
type PackageDependencies = Record<string, string>;
154+
155+
interface PackageInfo {
156+
dependencies?: PackageDependencies;
157+
devDependencies?: PackageDependencies;
158+
peerDependencies?: PackageDependencies;
159+
optionalDependencies?: PackageDependencies;
160+
bundleDependencies?: PackageDependencies;
161+
bundledDependencies?: PackageDependencies;
162+
}
163+
164+
// https://docs.npmjs.com/cli/v10/configuring-npm/package-json
165+
function resolveDependencyVersion(info: PackageInfo, name: string): string | undefined {
166+
return (
167+
info.dependencies?.[name] ??
168+
info.devDependencies?.[name] ??
169+
info.peerDependencies?.[name] ??
170+
info.optionalDependencies?.[name] ??
171+
info.bundleDependencies?.[name] ??
172+
info.bundledDependencies?.[name]
173+
);
174+
}
175+
176+
export async function resolveJsrImports(root: string, path: string): Promise<ImportReference[]> {
177+
if (!path.startsWith("/_jsr/")) throw new Error(`invalid jsr path: ${path}`);
178+
return parseImports(join(root, ".observablehq", "cache"), path);
179+
}
180+
181+
/**
182+
* The conversion of JSR specifier (e.g., @std/random) to JSR path (e.g.,
183+
* @std/random@0.1.0/between.js) is not invertible, so we can’t reconstruct the
184+
* JSR specifier from the path; hence this method instead returns a specifier
185+
* with a leading slash such as /@std/random@0.1.0/between.js that can be used
186+
* to avoid re-resolving JSR specifiers.
187+
*/
188+
export function extractJsrSpecifier(path: string): string {
189+
if (!path.startsWith("/_jsr/")) throw new Error(`invalid jsr path: ${path}`);
190+
return path.slice("/_jsr".length);
191+
}

src/npm.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export function formatNpmSpecifier({name, range, path}: NpmSpecifier): string {
3636
}
3737

3838
/** Rewrites /npm/ import specifiers to be relative paths to /_npm/. */
39-
export function rewriteNpmImports(input: string, resolve: (specifier: string) => string = String): string {
39+
export function rewriteNpmImports(input: string, resolve: (s: string) => string | void = () => undefined): string {
4040
const body = parseProgram(input);
4141
const output = new Sourcemap(input);
4242

@@ -63,7 +63,8 @@ export function rewriteNpmImports(input: string, resolve: (specifier: string) =>
6363
function rewriteImportSource(source: StringLiteral) {
6464
const value = getStringLiteralValue(source);
6565
const resolved = resolve(value);
66-
if (value !== resolved) output.replaceLeft(source.start, source.end, JSON.stringify(resolved));
66+
if (resolved === undefined || value === resolved) return;
67+
output.replaceLeft(source.start, source.end, JSON.stringify(resolved));
6768
}
6869

6970
// TODO Preserve the source map, but download it too.
@@ -178,9 +179,9 @@ export async function getDependencyResolver(
178179
};
179180
}
180181

181-
async function initializeNpmVersionCache(root: string): Promise<Map<string, string[]>> {
182+
export async function initializeNpmVersionCache(root: string, dir = "_npm"): Promise<Map<string, string[]>> {
182183
const cache = new Map<string, string[]>();
183-
const cacheDir = join(root, ".observablehq", "cache", "_npm");
184+
const cacheDir = join(root, ".observablehq", "cache", dir);
184185
try {
185186
for (const entry of await readdir(cacheDir)) {
186187
if (entry.startsWith("@")) {
@@ -211,7 +212,7 @@ const npmVersionRequests = new Map<string, Promise<string>>();
211212

212213
function getNpmVersionCache(root: string): Promise<Map<string, string[]>> {
213214
let cache = npmVersionCaches.get(root);
214-
if (!cache) npmVersionCaches.set(root, (cache = initializeNpmVersionCache(root)));
215+
if (!cache) npmVersionCaches.set(root, (cache = initializeNpmVersionCache(root, "_npm")));
215216
return cache;
216217
}
217218

src/preview.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ export class PreviewServer {
139139
} else if (pathname.startsWith("/_observablehq/") && pathname.endsWith(".css")) {
140140
const path = getClientPath(pathname.slice("/_observablehq/".length));
141141
end(req, res, await bundleStyles({path}), "text/css");
142-
} else if (pathname.startsWith("/_node/")) {
142+
} else if (pathname.startsWith("/_node/") || pathname.startsWith("/_jsr/")) {
143143
send(req, pathname, {root: join(root, ".observablehq", "cache")}).pipe(res);
144144
} else if (pathname.startsWith("/_npm/")) {
145145
await populateNpmCache(root, pathname);

0 commit comments

Comments
 (0)