Skip to content

Instantly share code, notes, and snippets.

@mizchi
Created February 7, 2021 09:04
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mizchi/33ba41509f48b253f1d40fe8b7a9ab1b to your computer and use it in GitHub Desktop.
Save mizchi/33ba41509f48b253f1d40fe8b7a9ab1b to your computer and use it in GitHub Desktop.
// esm.sh の x-typescript-types を返すだけのエンドポイントのラッパー
import { getTypes } from "../pages/api/types";
import type * as Monaco from "monaco-editor";
import path from "path";
import { kvsIndexedDB } from "@kvs/indexeddb";
import ts from "typescript";
type Ctx = {
monaco: typeof Monaco;
extractImportSpecifiers: (code: string) => Promise<string[]>;
};
type EntryCache =
| {
code: string;
typeUrl: string;
hasTypes: true;
cachedAt: number;
}
| {
hasTypes: false;
cachedAt: number;
};
type DependencyCache = {
code: string;
cachedAt: number;
};
let _entryCache: {
has: (k: string) => Promise<boolean>;
get: (k: string) => Promise<EntryCache | void>;
set: (k: string, data: EntryCache) => Promise<void>;
delete: (k: string) => Promise<void>;
};
let _depsCache: {
has: (k: string) => Promise<boolean>;
get: (k: string) => Promise<DependencyCache | void>;
set: (k: string, data: DependencyCache) => Promise<void>;
delete: (k: string) => Promise<void>;
};
export async function loadExternalLibTypes({
monaco,
code,
extractImportSpecifiers,
onLog,
}: {
monaco: typeof Monaco;
code: string;
extractImportSpecifiers: (code: string) => Promise<string[]>;
onLog(message: string): void;
}) {
// monaco.editor.
const urls = await extractImportSpecifiers!(code);
const externalModules = urls
// remove relative and cached
.filter((t) => !t.startsWith(".") && !done.has(t))
// add https://esm.sh for non url
.map((t) => {
if (!t.startsWith("https://")) {
return {
url: `https://cdn.esm.sh/${t}`,
registerName: t,
};
} else {
return {
url: t,
registerName: t,
};
}
});
if (externalModules.length === 0) return;
for (const mod of externalModules) {
const ret = await startFetch({
ctx: {
monaco,
extractImportSpecifiers,
},
registerName: mod.registerName,
url: mod.url,
});
onLog(`${mod.registerName} loaded`);
}
}
type TypeQueue =
| { type: "entry"; registerName: string; url: string }
| { type: "dependency"; url: string };
let queue: Array<TypeQueue> = [];
let done = new Set();
async function startFetch({
ctx,
registerName,
url,
}: {
ctx: Ctx;
registerName: string;
url: string;
}) {
let next: TypeQueue | void = {
type: "entry",
registerName,
url,
};
let cycle = 0;
let skipCount = 0;
do {
console.log("cycle", cycle);
let code: string | void;
if (next.type === "entry") {
code = await fetchEntryType({ ctx, registerName, url: url });
} else {
code = await fetchDependencyType({ ctx, url: next.url });
}
if (code == null) {
skipCount++;
console.log("skipped", next.url);
continue;
}
const urls = await ctx.extractImportSpecifiers(code);
urls.forEach((u) => {
if (queue.findIndex((t) => t.url === u) > -1) return;
if (u.startsWith("https://")) {
queue.push({
type: "dependency",
url: u,
});
} else {
queue.push({
type: "entry",
url: `https://cdn.esm.sh/${u}`,
registerName: u,
});
}
});
cycle++;
} while ((next = queue.pop()));
console.log("done cycle", cycle, "skipped", skipCount);
return {
cycle,
skipCount,
};
}
async function fetchEntryType({
ctx,
registerName,
url,
}: {
ctx: Ctx;
registerName: string;
url: string;
}): Promise<string | void> {
if (done.has(url)) return;
done.add(url);
done.add(registerName);
let entry = await ensureEntry(url);
if (entry.hasTypes) {
ctx.monaco.languages.typescript.typescriptDefaults.addExtraLib(
entry.code,
makeSymbol(registerName)
);
console.log("[register entry]", registerName, "=>", entry.typeUrl);
return entry.code;
} else {
console.log(`${url} has no type code`);
return;
}
}
async function fetchDependencyType({
ctx,
url: url,
}: {
ctx: Ctx;
url: string;
}): Promise<string | void> {
if (done.has(url)) return;
done.add(url);
const { pathname, host } = new URL(url);
const localUri = `file:///@types/${host}${pathname}`;
const dependency = await ensureDependency(url);
ctx.monaco.languages.typescript.typescriptDefaults.addExtraLib(
dependency.code,
localUri
);
console.log(`[register deps] ${url}`);
return dependency.code;
}
const makeSymbol = (expr: string) => {
if (expr.startsWith("https://")) {
const { host, pathname } = new URL(expr);
const rel = path.join(host, pathname);
if (rel.endsWith(".d.ts")) {
return `file:///@types/${rel}`;
} else {
return `file:///@types/${rel}.d.ts`;
}
} else {
return `file:///@types/${expr}/index.d.ts`;
}
};
async function ensureEntryCache() {
return (
_entryCache ??
(_entryCache = (await kvsIndexedDB<any>({
name: "type:v4",
version: 1,
})) as any)
);
}
async function ensureDependencyCache() {
return (
_depsCache ??
(_depsCache = (await kvsIndexedDB<any>({
name: "type-deps:v4",
version: 1,
})) as any)
);
}
async function ensureEntry(url: string): Promise<EntryCache> {
const cache = await ensureEntryCache();
let cached = await cache.get(url);
// check expired
if (cached && cached.cachedAt - Date.now() > 86480 * 10000) {
// expired
cache.delete(url);
cached = undefined;
}
if (cached && cached.hasTypes) {
console.log("Use entry cache", url);
return cached;
} else {
console.log("Fetch entry cache", url);
const type = await getTypes({
url,
});
if (type.hasTypes) {
const code = await fetch(type.url).then((res) => res.text());
const transformed = transformWithCdnHost(code, type.url);
const newCache: EntryCache = {
code: transformed,
hasTypes: true,
typeUrl: type.url,
cachedAt: Date.now(),
};
console.log("save new", transformed);
cache.set(url, newCache);
return newCache;
} else {
const newCache: EntryCache = {
hasTypes: false,
cachedAt: Date.now(),
};
await cache.set(url, newCache);
return {
hasTypes: false,
cachedAt: Date.now(),
};
}
}
}
async function ensureDependency(url: string): Promise<DependencyCache> {
const depsCache = await ensureDependencyCache();
const cache = await depsCache.get(url);
if (cache) {
// console.log("Use dep cache", url, cache.code);
return cache;
} else {
console.log("Fetch dep cache", url);
const depCode = await fetch(url).then((res) => res.text());
const rewroteCode = transformWithCdnHost(depCode, url);
const newDepsCache: DependencyCache = {
code: rewroteCode,
cachedAt: Date.now(),
};
await depsCache.set(url, newDepsCache);
return newDepsCache;
}
}
const printer = ts.createPrinter();
function transformWithCdnHost(code: string, url: string): string {
const s = ts.createSourceFile(url, code, ts.ScriptTarget.Latest);
const xs = ts.transform(s, [cdnTransformer(url)]);
const out = printer.printFile(xs.transformed[0]);
return out;
}
const cdnTransformer: (url: string) => ts.TransformerFactory<ts.SourceFile> = (
url: string
) => (_context: ts.TransformationContext) => {
let rootSource: ts.SourceFile;
const visit: ts.Visitor = (node) => {
if (ts.isImportDeclaration(node)) {
if (node.importClause == null) {
return node;
}
const specifierRaw = node.moduleSpecifier.getText(rootSource);
const specifier = specifierRaw.slice(1, specifierRaw.length - 1);
if (specifier.startsWith(".")) {
const { host, pathname, protocol } = new URL(url);
const rel = path.resolve(
pathname.endsWith(".d.ts") ? path.dirname(pathname) : pathname,
specifier
);
const newUrl = `${protocol}//${host}${rel}`;
return ts.factory.createImportDeclaration(
node.decorators,
node.modifiers,
node.importClause,
ts.factory.createStringLiteral(newUrl)
);
}
if (specifier.startsWith("/")) {
return ts.factory.createImportDeclaration(
node.decorators,
node.modifiers,
node.importClause,
ts.factory.createStringLiteral(`https://cdn.esm.sh${specifier}`)
);
}
return node;
}
if (ts.isExportDeclaration(node)) {
if (node.moduleSpecifier == null) return node;
const specifierRaw = node.moduleSpecifier.getText(rootSource);
const specifier = specifierRaw.slice(1, specifierRaw.length - 1);
if (specifier.startsWith(".")) {
const { host, pathname, protocol } = new URL(url);
const rel = path.resolve(
pathname.endsWith(".d.ts") ? path.dirname(pathname) : pathname,
specifier
);
const newUrl = `${protocol}//${host}${rel}`;
return ts.factory.createExportDeclaration(
node.decorators,
node.modifiers,
node.isTypeOnly,
node.exportClause,
ts.factory.createStringLiteral(newUrl)
);
}
if (specifier.startsWith("/")) {
return ts.factory.createExportDeclaration(
node.decorators,
node.modifiers,
node.isTypeOnly,
node.exportClause,
ts.factory.createStringLiteral(`https://cdn.esm.sh${specifier}`)
);
}
return node;
}
return node;
};
return (source: ts.SourceFile) => {
rootSource = source;
return ts.factory.updateSourceFile(
source,
ts.visitNodes(source.statements, visit)
);
};
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment