Skip to content

Commit

Permalink
feat: first-class Deno installs (#146)
Browse files Browse the repository at this point in the history
  • Loading branch information
guybedford authored Jul 18, 2022
1 parent 142b859 commit 8f89dc8
Show file tree
Hide file tree
Showing 6 changed files with 110 additions and 40 deletions.
28 changes: 21 additions & 7 deletions src/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,16 @@ export interface GeneratorOptions {
* New providers can be provided via the `customProviders` option. PRs to merge in providers are welcome as well.
*/
defaultProvider?: string;
/**
* The default registry to use when no registry is provided to an install.
* Defaults to 'npm:'.
*
* Registries are separated from providers because multiple providers can serve
* any public registry.
*
* Internally, the default providers for registries are handled by the providers object
*/
defaultRegistry?: string;
/**
* The conditional environment resolutions to apply.
*
Expand Down Expand Up @@ -159,13 +169,14 @@ export interface GeneratorOptions {
/**
* A map of custom scoped providers.
*
* The provider map allows setting custom providers for specific package names or package scopes.
* The provider map allows setting custom providers for specific package names, package scopes or registries.
* For example, an organization with private packages with names like `npmpackage` and `@orgscope/...` can define the custom providers to reference these from a custom source:
*
* ```js
* providers: {
* 'npmpackage': 'nodemodules',
* '@orgscope': 'nodemodules'
* '@orgscope': 'nodemodules',
* 'npm:': 'nodemodules'
* }
* ```
*
Expand Down Expand Up @@ -309,6 +320,7 @@ export class Generator {
* }
* },
* defaultProvider: 'jspm',
* defaultRegistry: 'npm',
* providers: {
* '@orgscope': 'nodemodules'
* },
Expand All @@ -325,8 +337,9 @@ export class Generator {
inputMap = undefined,
env = ['browser', 'development', 'module'],
defaultProvider = 'jspm',
defaultRegistry = 'npm',
customProviders = undefined,
providers = {},
providers,
resolutions = {},
cache = true,
stdlib = '@jspm/core',
Expand Down Expand Up @@ -392,6 +405,7 @@ export class Generator {
stdlib,
env,
defaultProvider,
defaultRegistry,
providers,
inputMap,
ignore,
Expand Down Expand Up @@ -644,7 +658,7 @@ export class Generator {
this.traceMap.startInstall();
await this.traceMap.processInputMap;
try {
const { alias, target, subpath } = await installToTarget.call(this, install);
const { alias, target, subpath } = await installToTarget.call(this, install, this.traceMap.installer.defaultRegistry);
await this.traceMap.add(alias, target);
await this.traceMap.visit(alias + subpath.slice(1), { mode: 'new' });
this.traceMap.pin(alias + subpath.slice(1));
Expand Down Expand Up @@ -800,7 +814,7 @@ export async function fetch (url: string, opts: any = {}) {
*/
export async function lookup (install: string | Install, { provider, cache }: LookupOptions = {}) {
const generator = new Generator({ cache: !cache, defaultProvider: provider });
const { target, subpath, alias } = await installToTarget.call(generator, install);
const { target, subpath, alias } = await installToTarget.call(generator, install, generator.traceMap.installer.defaultRegistry);
if (target instanceof URL)
throw new Error('URL lookups not supported');
const resolved = await generator.traceMap.resolver.resolveLatestTarget(target, true, generator.traceMap.installer.defaultProvider);
Expand Down Expand Up @@ -892,14 +906,14 @@ export async function getPackageBase (url: string | URL, { provider, cache }: Lo
return generator.traceMap.resolver.getPackageBase(typeof url === 'string' ? url : url.href);
}

async function installToTarget (this: Generator, install: Install | string) {
async function installToTarget (this: Generator, install: Install | string, defaultRegistry: string) {
if (typeof install === 'string')
install = { target: install };
if (typeof install.target !== 'string')
throw new Error('All installs require a "target" string.');
if (install.subpath !== undefined && (typeof install.subpath !== 'string' || (install.subpath !== '.' && !install.subpath.startsWith('./'))))
throw new Error(`Install subpath "${install.subpath}" must be a string equal to "." or starting with "./".${typeof install.subpath === 'string' ? `\nTry setting the subpath to "./${install.subpath}"` : ''}`);
const { alias, target, subpath } = await toPackageTarget(this.traceMap.resolver, install.target, this.baseUrl.href);
const { alias, target, subpath } = await toPackageTarget(this.traceMap.resolver, install.target, this.baseUrl.href, defaultRegistry);
return {
alias: install.alias || alias,
target,
Expand Down
21 changes: 14 additions & 7 deletions src/install/installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { JspmError, throwInternalError } from "../common/err.js";
import { nodeBuiltinSet } from '../providers/node.js';
import { parseUrlPkg } from '../providers/jspm.js';
import { getResolution, LockResolutions, pruneResolutions, setResolution, stringResolution } from './lock.js';
import { registryProviders } from '../providers/index.js';

export interface PackageProvider {
provider: string;
Expand Down Expand Up @@ -77,6 +78,7 @@ export interface InstallOptions {
resolutions?: Record<string, string>;

defaultProvider?: string;
defaultRegistry?: string;
providers?: Record<string, string>;
}

Expand All @@ -92,6 +94,7 @@ export class Installer {
added = new Map<string, InstallTarget>();
hasLock = false;
defaultProvider = { provider: 'jspm', layer: 'default' };
defaultRegistry = 'npm';
providers: Record<string, string>;
resolutions: Record<string, string>;
log: Log;
Expand All @@ -105,12 +108,16 @@ export class Installer {
this.opts = opts;
this.hasLock = !!opts.lock;
this.installs = opts.lock || {};
if (opts.defaultRegistry)
this.defaultRegistry = opts.defaultRegistry;
if (opts.defaultProvider)
this.defaultProvider = {
provider: opts.defaultProvider.split('.')[0],
layer: opts.defaultProvider.split('.')[1] || 'default'
};
this.providers = opts.providers || {};
this.providers = Object.assign({}, registryProviders);
if (opts.providers)
Object.assign(this.providers, opts.providers);

if (opts.stdlib) {
if (isURL(opts.stdlib) || opts.stdlib[0] === '.') {
Expand All @@ -119,7 +126,7 @@ export class Installer {
this.stdlibTarget.pathname = this.stdlibTarget.pathname.slice(0, -1);
}
else {
this.stdlibTarget = newPackageTarget(opts.stdlib, this.installBaseUrl);
this.stdlibTarget = newPackageTarget(opts.stdlib, this.installBaseUrl, this.defaultRegistry);
}
}
}
Expand Down Expand Up @@ -212,7 +219,7 @@ export class Installer {

let provider = this.defaultProvider;
for (const name of Object.keys(this.providers)) {
if (target.name.startsWith(name) && (target.name.length === name.length || target.name[name.length] === '/')) {
if (name.endsWith(':') && target.registry === name.slice(0, -1) || target.name.startsWith(name) && (target.name.length === name.length || target.name[name.length] === '/')) {
provider = { provider: this.providers[name], layer: 'default' };
const layerIndex = provider.provider.indexOf('.');
if (layerIndex !== -1) {
Expand All @@ -236,7 +243,7 @@ export class Installer {

// resolutions are authoritative at the top-level
if (this.resolutions[pkgName]) {
const resolutionTarget = newPackageTarget(this.resolutions[pkgName], this.opts.baseUrl.href, pkgName);
const resolutionTarget = newPackageTarget(this.resolutions[pkgName], this.opts.baseUrl.href, this.defaultRegistry, pkgName);
if (JSON.stringify(target) !== JSON.stringify(resolutionTarget))
return this.installTarget(pkgName, resolutionTarget, mode, pkgScope, pjsonPersist, subpath, parentUrl);
}
Expand Down Expand Up @@ -274,7 +281,7 @@ export class Installer {
}

if (this.resolutions[pkgName]) {
return this.installTarget(pkgName, newPackageTarget(this.resolutions[pkgName], this.opts.baseUrl.href, pkgName), mode, pkgUrl, false, null, parentUrl);
return this.installTarget(pkgName, newPackageTarget(this.resolutions[pkgName], this.opts.baseUrl.href, this.defaultRegistry, pkgName), mode, pkgUrl, false, null, parentUrl);
}

// resolution scope cascading for existing only
Expand All @@ -296,7 +303,7 @@ export class Installer {
// package dependencies
const installTarget = pcfg.dependencies?.[pkgName] || pcfg.peerDependencies?.[pkgName] || pcfg.optionalDependencies?.[pkgName] || pkgUrl === this.installBaseUrl && pcfg.devDependencies?.[pkgName];
if (installTarget) {
const target = newPackageTarget(installTarget, pkgUrl, pkgName);
const target = newPackageTarget(installTarget, pkgUrl, this.defaultRegistry, pkgName);
return this.installTarget(pkgName, target, mode, pkgUrl, false, null, parentUrl);
}

Expand All @@ -305,7 +312,7 @@ export class Installer {
return this.installs[this.installBaseUrl][pkgName];

// global install fallback
const target = newPackageTarget('*', pkgUrl, pkgName);
const target = newPackageTarget('*', pkgUrl, this.defaultRegistry, pkgName);
const exactInstall = await this.installTarget(pkgName, target, mode, pkgUrl, true, null, parentUrl);
return exactInstall;
}
Expand Down
10 changes: 5 additions & 5 deletions src/install/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export async function parseUrlTarget (resolver: Resolver, targetStr: string, par
}

// ad-hoc determination of local path v remote package for eg "jspm deno react" v "jspm deno react@2" v "jspm deno ./react.ts" v "jspm deno react.ts"
const supportedRegistries = ['npm', 'github', 'deno', 'nest'];
const supportedRegistries = ['npm', 'github', 'deno', 'nest', 'denoland'];
export function isPackageTarget (targetStr: string): boolean {
if (isRelative(targetStr))
return false;
Expand Down Expand Up @@ -100,7 +100,7 @@ export function pkgUrlToNiceString (resolver: Resolver, pkgUrl: string) {
return pkgUrl;
}

export async function toPackageTarget (resolver: Resolver, targetStr: string, parentPkgUrl: string): Promise<{ alias: string, target: InstallTarget, subpath: '.' | `./${string}` }> {
export async function toPackageTarget (resolver: Resolver, targetStr: string, parentPkgUrl: string, defaultRegistry: string): Promise<{ alias: string, target: InstallTarget, subpath: '.' | `./${string}` }> {
const urlTarget = await parseUrlTarget(resolver, targetStr, parentPkgUrl);
if (urlTarget)
return urlTarget;
Expand All @@ -125,20 +125,20 @@ export async function toPackageTarget (resolver: Resolver, targetStr: string, pa

return {
alias,
target: newPackageTarget(pkg.pkgName, parentPkgUrl),
target: newPackageTarget(pkg.pkgName, parentPkgUrl, defaultRegistry),
subpath: pkg.subpath as '.' | `./{string}`
};
}

export function newPackageTarget (target: string, parentPkgUrl: string, depName?: string): InstallTarget {
export function newPackageTarget (target: string, parentPkgUrl: string, defaultRegistry: string, depName?: string): InstallTarget {
let registry: string, name: string, ranges: any[];

const registryIndex = target.indexOf(':');

if (target.startsWith('./') || target.startsWith('../') || target.startsWith('/') || registryIndex === 1)
return new URL(target, parentPkgUrl);

registry = registryIndex < 1 ? 'npm' : target.substr(0, registryIndex);
registry = registryIndex < 1 ? defaultRegistry : target.slice(0, registryIndex);

if (registry === 'file')
return new URL(target.slice(registry.length + 1), parentPkgUrl);
Expand Down
46 changes: 34 additions & 12 deletions src/providers/denoland.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,34 @@ import { Resolver } from "../trace/resolver.js";
import { fetch } from '#fetch';

const cdnUrl = 'https://deno.land/x/';
const stdlibUrl = 'https://deno.land/std';

export function pkgToUrl (pkg: ExactPackage) {
return cdnUrl + pkg.name + '@v' + pkg.version + '/';
if (pkg.registry === 'deno')
return stdlibUrl + '@' + pkg.version + '/' + pkg.name + '/';
if (pkg.registry === 'denoland')
return cdnUrl + pkg.name + '@v' + pkg.version + '/';
throw new Error(`Deno provider does not support the ${pkg.registry} registry`);
}

export function parseUrlPkg (url: string): ExactPackage | undefined {
if (!url.startsWith(cdnUrl))
return;
const path = url.slice(cdnUrl.length);
const versionIndex = path.indexOf('@v');
if (versionIndex === -1)
return;
const sepIndex = path.indexOf('/', versionIndex);
return { registry: 'deno', name: path.slice(0, versionIndex), version: path.slice(versionIndex + 2, sepIndex === -1 ? path.length : sepIndex) };
if (url.startsWith(stdlibUrl) && url[stdlibUrl.length] === '@') {
const version = url.slice(stdlibUrl.length + 1, url.indexOf('/', stdlibUrl.length + 1));
let name = url.slice(stdlibUrl.length + version.length + 2);
if (name.endsWith('/mod.ts'))
name = name.slice(0, -7);
else if (name.endsWith('.ts'))
name = name.slice(0, -3);
return { registry: 'deno', name, version };
}
else if (url.startsWith(cdnUrl)) {
const path = url.slice(cdnUrl.length);
const versionIndex = path.indexOf('@v');
if (versionIndex === -1)
return;
const sepIndex = path.indexOf('/', versionIndex);
return { registry: 'denoland', name: path.slice(0, versionIndex), version: path.slice(versionIndex + 2, sepIndex === -1 ? path.length : sepIndex) };
}
}

export async function resolveLatestTarget (this: Resolver, target: LatestPackageTarget, unstable: boolean, _layer: string, parentUrl: string): Promise<ExactPackage | null> {
Expand All @@ -29,9 +43,17 @@ export async function resolveLatestTarget (this: Resolver, target: LatestPackage
if (!range.isWildcard)
throw new Error(`Version ranges are not supported looking up in the Deno registry currently, until an API is available.`);

const fetchOpts = { ...this.fetchOpts, headers: Object.assign({}, this.fetchOpts.headers || {}, { 'accept': 'text/html' }) };
const res = await fetch(cdnUrl + name, fetchOpts);
const fetchOpts = {
...this.fetchOpts,
headers: Object.assign({}, this.fetchOpts.headers || {}, {
// For some reason, Deno provides different redirect behaviour for the server
// Which requires us to use the text/html accept
'accept': typeof document === 'undefined' ? 'text/html' : 'text/javascript'
})
};
// "mod.ts" addition is necessary for the browser otherwise not resolving an exact module gives a CORS error
const res = await fetch((registry === 'denoland' ? cdnUrl : stdlibUrl + '/') + name + '/mod.ts', fetchOpts);
if (!res.ok)
throw new Error(`Deno: Unable to lookup ${cdnUrl + name}`);
throw new Error(`Deno: Unable to lookup ${(registry === 'denoland' ? cdnUrl : stdlibUrl + '/') + name}`);
return parseUrlPkg(res.url);
}
9 changes: 7 additions & 2 deletions src/providers/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as deno from './denoland.js';
import * as denoland from './denoland.js';
import * as jspm from './jspm.js';
import * as skypack from './skypack.js';
import * as jsdelivr from './jsdelivr.js';
Expand All @@ -18,7 +18,7 @@ export interface Provider {
}

export const defaultProviders: Record<string, Provider> = {
deno,
denoland,
jsdelivr,
jspm,
node,
Expand All @@ -33,3 +33,8 @@ export function getProvider (name: string, providers: Record<string, Provider> =
return provider;
throw new Error('No ' + name + ' provider is defined.');
}

export const registryProviders: Record<string, string> = {
'denoland:': 'denoland',
'deno:': 'denoland'
};
36 changes: 29 additions & 7 deletions test/providers/deno.test.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,35 @@
import { Generator } from '@jspm/generator';
import assert from 'assert';

const generator = new Generator({
mapUrl: new URL('../../', import.meta.url),
defaultProvider: 'deno'
});
{
const generator = new Generator({
mapUrl: new URL('../../', import.meta.url),
defaultRegistry: 'denoland'
});

await generator.install('oak@10.6.0');
await generator.install('oak@10.6.0');

const json = generator.getMap();
const json = generator.getMap();

assert.strictEqual(json.imports['oak'], 'https://deno.land/x/oak@v10.6.0/mod.ts');
assert.strictEqual(json.imports['oak'], 'https://deno.land/x/oak@v10.6.0/mod.ts');
}

{
const generator = new Generator();

await generator.install('denoland:oak');

const json = generator.getMap();

assert.strictEqual(json.imports['oak'], 'https://deno.land/x/oak@v10.6.0/mod.ts');
}

{
const generator = new Generator();

await generator.install('deno:path');

const json = generator.getMap();

assert.strictEqual(json.imports['path'], 'https://deno.land/std@0.148.0/path/mod.ts');
}

0 comments on commit 8f89dc8

Please sign in to comment.