diff --git a/src/optimizer/api-extractor.json b/src/optimizer/api-extractor.json new file mode 100644 index 00000000000..921eb89dec8 --- /dev/null +++ b/src/optimizer/api-extractor.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "extends": "../api-extractor.json", + "mainEntryPointFilePath": "/dist-dev/tsc-out/src/optimizer/index.d.ts", + "apiReport": { + "enabled": true, + "reportFileName": "api.md", + "reportFolder": "/src/optimizer/", + "reportTempFolder": "/dist-dev/api-extractor/optimizer/" + }, + "dtsRollup": { + "enabled": true, + "untrimmedFilePath": "/dist-dev/@builder.io-qwik/optimizer.d.ts" + } +} diff --git a/src/optimizer/api.md b/src/optimizer/api.md new file mode 100644 index 00000000000..314fb04583a --- /dev/null +++ b/src/optimizer/api.md @@ -0,0 +1,118 @@ +## API Report File for "@builder.io/qwik" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import type { BuildOptions } from 'esbuild'; + +// @alpha (undocumented) +export function createClientEsbuildOptions(optimizer: Optimizer): Promise; + +// @alpha (undocumented) +export function createEsbuilder(opts: { + outDir: string; + clientOpts?: BuildOptions; + serverOpts?: BuildOptions; +}): { + build: () => Promise; + dispose: () => void; +}; + +// @alpha (undocumented) +export function createServerEsbuildOptions(optimizer: Optimizer): Promise; + +// @alpha +export function createTimer(): () => number; + +// @alpha +export function getQwikLoaderScript(opts?: { events?: string[]; debug?: boolean }): string; + +// @alpha +export class Optimizer { + // Warning: (ae-forgotten-export) The symbol "OptimizerOptions" needs to be exported by the entry point index.d.ts + constructor(opts?: OptimizerOptions); + // (undocumented) + enableCache(useCache: boolean): void; + // (undocumented) + getBaseUrl(): string; + // Warning: (ae-forgotten-export) The symbol "EntryPointOptions" needs to be exported by the entry point index.d.ts + // + // (undocumented) + getEntryInputs(opts: EntryPointOptions): string[]; + // (undocumented) + getMode(): Mode; + // (undocumented) + getRootDir(): string; + // (undocumented) + getSourceMapOption(): SourceMapOption; + // (undocumented) + getTsconfig(): Promise; + // (undocumented) + getTsconfigSync(): any; + // (undocumented) + getTypeScript(): Promise; + // (undocumented) + getTypeScriptSync(): any; + // (undocumented) + isCacheEnabled(): boolean; + // (undocumented) + isDev(): boolean; + // (undocumented) + postBuild(outFile: OutputFile): OutputFile; + // Warning: (ae-forgotten-export) The symbol "ResolveModuleOptions" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "ResolveModuleResult" needs to be exported by the entry point index.d.ts + // + // (undocumented) + resolveModuleSync(opts: ResolveModuleOptions): ResolveModuleResult; + // (undocumented) + setBaseUrl(baseUrl: string): void; + // (undocumented) + setEntryInputs(entryInputs: string[]): void; + // Warning: (ae-forgotten-export) The symbol "Mode" needs to be exported by the entry point index.d.ts + // + // (undocumented) + setMode(mode: Mode): void; + // (undocumented) + setRootDir(rootDir: string): void; + // Warning: (ae-forgotten-export) The symbol "SourceMapOption" needs to be exported by the entry point index.d.ts + // + // (undocumented) + setSourceMapOption(sourceMapOpt: SourceMapOption): void; + // (undocumented) + setTsconfig(tsconfig: any): void; + // (undocumented) + setTypeScript(ts: any): void; + // Warning: (ae-forgotten-export) The symbol "TransformModuleOptions" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "TransformModuleResult" needs to be exported by the entry point index.d.ts + // + // (undocumented) + transformModule(opts: TransformModuleOptions): Promise; + // (undocumented) + transformModuleSync(opts: TransformModuleOptions): TransformModuleResult; +} + +// @public (undocumented) +export interface OutputFile { + // (undocumented) + path: string; + // Warning: (ae-forgotten-export) The symbol "OutputPlatform" needs to be exported by the entry point index.d.ts + // + // (undocumented) + platform?: OutputPlatform; + // (undocumented) + text: string; +} + +// @public +export function writeOutput(opts: { + dir: string; + files: OutputFile[]; + emptyDir?: boolean; +}): Promise; + +// Warnings were encountered during analysis: +// +// dist-dev/tsc-out/src/optimizer/esbuild/builder.d.ts:11:5 - (ae-forgotten-export) The symbol "EsbuildResult" needs to be exported by the entry point index.d.ts + +// (No @packageDocumentation comment for this package) +``` diff --git a/src/optimizer/esbuild/builder.ts b/src/optimizer/esbuild/builder.ts new file mode 100644 index 00000000000..e923692ca58 --- /dev/null +++ b/src/optimizer/esbuild/builder.ts @@ -0,0 +1,162 @@ +import type { Diagnostic, OutputFile, OutputPlatform } from '../types'; +import type { EsbuildResult } from './types'; +import type { BuildOptions, BuildResult, OutputFile as ESOutputFile, Message } from 'esbuild'; +import { createTimer } from '../utils'; + +/** + * @alpha + */ +export function createEsbuilder(opts: { + outDir: string; + clientOpts?: BuildOptions; + serverOpts?: BuildOptions; +}) { + const results: EsBuildPluginData = {}; + + const builder = { + build: async () => { + const buildTime = createTimer(); + + const buildResult: EsbuildResult = { + outputFiles: [], + diagnostics: [], + timers: { + clientBuild: 0, + serverBuild: 0, + totalBuild: 0, + }, + }; + + try { + if (opts.clientOpts) { + opts.clientOpts.outdir = opts.outDir; + const clientTime = createTimer(); + results.client = await runEsBuild(opts.clientOpts, results.client); + convertOutFile( + opts.outDir, + buildResult.outputFiles, + 'client', + results.client.outputFiles + ); + convertMessages(buildResult, results.client, 'client'); + buildResult.timers.clientBuild = clientTime(); + } + + if (opts.serverOpts) { + opts.serverOpts.outdir = opts.outDir; + const serverTime = createTimer(); + results.server = await runEsBuild(opts.serverOpts, results.server); + convertOutFile( + opts.outDir, + buildResult.outputFiles, + 'server', + results.server.outputFiles + ); + convertMessages(buildResult, results.server, 'server'); + buildResult.timers.serverBuild = serverTime(); + } + } catch (e) { + if (Array.isArray(e.errors)) { + for (const err of e.errors as Message[]) { + buildResult.diagnostics.push({ + type: 'error', + message: err.text, + location: err.location, + }); + } + } else { + buildResult.diagnostics.push({ type: 'error', message: String(e) }); + } + } + + if (buildResult.diagnostics.length > 0) { + buildResult.diagnosticsSummary = diagnosticsSummary(buildResult.diagnostics); + } + + buildResult.timers.totalBuild = buildTime(); + return buildResult; + }, + dispose: () => { + if (results.client) { + results.client.rebuild?.dispose(); + results.client = undefined; + } + if (results.server) { + results.server.rebuild?.dispose(); + results.server = undefined; + } + }, + }; + + return builder; +} + +async function runEsBuild(buildOpts: BuildOptions, buildResults: BuildResult | undefined) { + if (buildResults?.rebuild) { + return buildResults.rebuild(); + } + const esbuild = await import('esbuild'); + return esbuild.build(buildOpts); +} + +function convertOutFile( + rootDir: string, + outFiles: OutputFile[], + platform: OutputPlatform, + esbuildFiles?: ESOutputFile[] +) { + if (Array.isArray(esbuildFiles)) { + for (const esbuildFile of esbuildFiles) { + const out: OutputFile = { + path: esbuildFile.path.replace(rootDir, ''), + text: esbuildFile.text, + platform, + }; + if (out.path.startsWith('/') || out.path.startsWith('\\')) { + out.path = out.path.substring(1); + } + outFiles.push(out); + } + } + return []; +} + +function convertMessages( + esbuildResult: EsbuildResult, + result: BuildResult, + platform: OutputPlatform +) { + for (const err of result.errors) { + esbuildResult.diagnostics.push({ + type: 'error', + message: err.text, + location: err.location, + platform, + }); + } + for (const warn of result.warnings) { + esbuildResult.diagnostics.push({ + type: 'warn', + message: warn.text, + location: warn.location, + platform, + }); + } +} + +function diagnosticsSummary(diagnostics: Diagnostic[]) { + return diagnostics + .map((d) => { + let m = d.message; + if (d.location) { + m += `\nFile: ${d.location.file}\n${d.location.lineText}`; + } + return m; + }) + .join('\n\n'); +} + +interface EsBuildPluginData { + client?: BuildResult; + server?: BuildResult; +} diff --git a/src/optimizer/esbuild/index.ts b/src/optimizer/esbuild/index.ts new file mode 100644 index 00000000000..0a46aa59909 --- /dev/null +++ b/src/optimizer/esbuild/index.ts @@ -0,0 +1,2 @@ +export { createEsbuilder } from './builder'; +export { createClientEsbuildOptions, createServerEsbuildOptions } from './options'; diff --git a/src/optimizer/esbuild/options.ts b/src/optimizer/esbuild/options.ts new file mode 100644 index 00000000000..70e441f7713 --- /dev/null +++ b/src/optimizer/esbuild/options.ts @@ -0,0 +1,53 @@ +import type { Optimizer } from '../types'; +import type { BuildOptions } from 'esbuild'; +import { clientEsbuildPlugin, serverEsbuildPlugin } from './plugins'; + +/** + * @alpha + */ +export async function createClientEsbuildOptions(optimizer: Optimizer): Promise { + await optimizer.getTsconfig(); + + const clientBuildOpts: BuildOptions = { + entryPoints: optimizer.getEntryInputs({ platform: 'client' }), + outdir: optimizer.getRootDir(), + plugins: [clientEsbuildPlugin(optimizer)], + format: 'esm', + bundle: true, + splitting: true, + incremental: true, + write: false, + }; + + if (optimizer.isDev()) { + clientBuildOpts.sourcemap = 'inline'; + } else { + clientBuildOpts.sourcemap = 'external'; + clientBuildOpts.minify = true; + clientBuildOpts.define = { + qDev: false as any, + }; + } + + return clientBuildOpts; +} + +/** + * @alpha + */ +export async function createServerEsbuildOptions(optimizer: Optimizer): Promise { + await optimizer.getTsconfig(); + + const serverBuildOpts: BuildOptions = { + entryPoints: optimizer.getEntryInputs({ platform: 'server' }), + outdir: optimizer.getRootDir(), + plugins: [serverEsbuildPlugin(optimizer)], + format: 'cjs', + platform: 'node', + incremental: true, + write: false, + sourcemap: 'external', + }; + + return serverBuildOpts; +} diff --git a/src/optimizer/esbuild/plugins.ts b/src/optimizer/esbuild/plugins.ts new file mode 100644 index 00000000000..39be2599c83 --- /dev/null +++ b/src/optimizer/esbuild/plugins.ts @@ -0,0 +1,53 @@ +import type { Optimizer, OutputPlatform } from '../types'; +import type { BuildResult, Plugin } from 'esbuild'; + +export function clientEsbuildPlugin(optimizer: Optimizer) { + const plugin: Plugin = { + name: 'qwikEsbuildClientPlugin', + setup(build) { + if (!optimizer.isDev()) { + build.onResolve({ filter: /@builder\.io\/qwik/ }, (args) => { + const m = optimizer.resolveModuleSync({ + moduleName: args.path, + containingFile: args.importer, + }); + if (m.isExternalLibraryImport && m.resolvedFileName?.endsWith('core.mjs')) { + return { + path: m.resolvedFileName.replace('core.mjs', 'core.min.mjs'), + }; + } + return null; + }); + } + build.onEnd((result) => esbuildPostBuild(optimizer, result, 'client')); + }, + }; + return plugin; +} + +export function serverEsbuildPlugin(optimizer: Optimizer) { + const plugin: Plugin = { + name: 'qwikEsbuildServerPlugin', + setup(build) { + build.onEnd((result) => esbuildPostBuild(optimizer, result, 'server')); + }, + }; + return plugin; +} + +function esbuildPostBuild(optimizer: Optimizer, result: BuildResult, platform: OutputPlatform) { + if (Array.isArray(result.outputFiles)) { + const encoder = new TextEncoder(); + + for (const out of result.outputFiles) { + const postBuild = optimizer.postBuild({ + path: out.path, + text: out.text, + platform, + }); + if (postBuild) { + out.contents = encoder.encode(postBuild.text); + } + } + } +} diff --git a/src/optimizer/esbuild/types.ts b/src/optimizer/esbuild/types.ts new file mode 100644 index 00000000000..9f62cd8c85b --- /dev/null +++ b/src/optimizer/esbuild/types.ts @@ -0,0 +1,12 @@ +import type { Diagnostic, OutputFile } from '../types'; + +export interface EsbuildResult { + outputFiles: OutputFile[]; + diagnostics: Diagnostic[]; + diagnosticsSummary?: string; + timers: { + clientBuild: number; + serverBuild: number; + totalBuild: number; + }; +} diff --git a/src/optimizer/index.ts b/src/optimizer/index.ts new file mode 100644 index 00000000000..f6505802e7a --- /dev/null +++ b/src/optimizer/index.ts @@ -0,0 +1,6 @@ +export { createClientEsbuildOptions, createEsbuilder, createServerEsbuildOptions } from './esbuild'; +export { createTimer } from './utils'; +export { getQwikLoaderScript } from './loader'; +export { Optimizer } from './optimizer'; +export type { OutputFile } from './types'; +export { writeOutput } from './write-output'; diff --git a/src/optimizer/loader.ts b/src/optimizer/loader.ts new file mode 100644 index 00000000000..7c33b434857 --- /dev/null +++ b/src/optimizer/loader.ts @@ -0,0 +1,20 @@ +const QWIK_LOADER_DEFAULT_MINIFIED: string = (globalThis as any).QWIK_LOADER_DEFAULT_MINIFIED; +const QWIK_LOADER_DEFAULT_DEBUG: string = (globalThis as any).QWIK_LOADER_DEFAULT_DEBUG; +const QWIK_LOADER_EVENTS_MINIFIED: string = (globalThis as any).QWIK_LOADER_EVENTS_MINIFIED; +const QWIK_LOADER_EVENTS_DEBUG: string = (globalThis as any).QWIK_LOADER_EVENTS_DEBUG; + +/** + * Provides the qwikloader.js file as a string. Useful for tooling to inline the qwikloader + * script into HTML. + * @alpha + */ +export function getQwikLoaderScript(opts: { events?: string[]; debug?: boolean } = {}) { + if (Array.isArray(opts.events) && opts.events.length > 0) { + // inject exact known events used + const loader = opts.debug ? QWIK_LOADER_EVENTS_DEBUG : QWIK_LOADER_EVENTS_MINIFIED; + return loader.replace('globalThis.qEvents', JSON.stringify(opts.events)); + } + + // default script selector behavior + return opts.debug ? QWIK_LOADER_DEFAULT_DEBUG : QWIK_LOADER_DEFAULT_MINIFIED; +} diff --git a/src/optimizer/optimizer.ts b/src/optimizer/optimizer.ts new file mode 100644 index 00000000000..32ad1d957a1 --- /dev/null +++ b/src/optimizer/optimizer.ts @@ -0,0 +1,173 @@ +import type { + EntryPointOptions, + Mode, + InternalCache, + OptimizerOptions, + OutputFile, + SourceMapOption, + ResolveModuleOptions, + ResolveModuleResult, + TransformModuleOptions, + TransformModuleResult, +} from './types'; +import { + resolveModuleSync, + getTsconfig, + getTypeScript, + getTypeScriptSync, +} from './typescript-platform'; +import { getEntryPoints, normalizeOptions, normalizeUrl, platform } from './utils'; +import { transformModule } from './transform'; +import type TypeScript from 'typescript'; +import { postBuild } from './post-build'; + +/** + * Optimizer which provides utility functions to be used by external tooling. + * @alpha + */ +export class Optimizer { + private baseUrl = normalizeUrl('/'); + private rootDir: string | null = null; + private sourceMapOpt: SourceMapOption = true; + private enabledCache = true; + private internalCache: InternalCache = { + modules: [], + resolved: new Map(), + }; + private mode: Mode = 'development'; + private ts: any = null; + private tsconfig: any = null; + private entryInputs: string[] | null = null; + private moduleResolveCache: TypeScript.ModuleResolutionCache | null = null; + + constructor(opts?: OptimizerOptions) { + normalizeOptions(this, opts); + } + + setEntryInputs(entryInputs: string[]) { + this.entryInputs = entryInputs; + } + + getEntryInputs(opts: EntryPointOptions) { + if (Array.isArray(this.entryInputs)) { + return this.entryInputs; + } + const tsconfig: TypeScript.ParsedCommandLine = this.getTsconfigSync(); + return getEntryPoints(opts, tsconfig.fileNames); + } + + postBuild(outFile: OutputFile) { + return postBuild(outFile); + } + + setBaseUrl(baseUrl: string) { + this.baseUrl = normalizeUrl(baseUrl); + } + + getBaseUrl() { + return this.baseUrl; + } + + setRootDir(rootDir: string) { + this.rootDir = rootDir; + } + + getRootDir() { + let rootDir = this.rootDir as string; + if (typeof rootDir !== 'string') { + if (platform === 'node') { + rootDir = process.cwd(); + } else { + rootDir = '/'; + } + } + return rootDir; + } + + enableCache(useCache: boolean) { + this.enabledCache = useCache; + } + + isCacheEnabled() { + return this.enabledCache; + } + + setMode(mode: Mode) { + this.mode = mode === 'development' ? 'development' : 'production'; + } + + getMode() { + return this.mode; + } + + isDev() { + return this.mode === 'development'; + } + + setSourceMapOption(sourceMapOpt: SourceMapOption) { + this.sourceMapOpt = sourceMapOpt; + } + + getSourceMapOption() { + return this.sourceMapOpt; + } + + resolveModuleSync(opts: ResolveModuleOptions): ResolveModuleResult { + const ts: typeof TypeScript = this.getTypeScriptSync(); + const tsconfig: TypeScript.ParsedCommandLine = this.getTsconfigSync(); + if (!this.moduleResolveCache) { + this.moduleResolveCache = ts.createModuleResolutionCache(this.getRootDir(), (s) => + s.toLowerCase() + ); + } + return resolveModuleSync(ts, tsconfig.options, this.moduleResolveCache, opts); + } + + async transformModule(opts: TransformModuleOptions): Promise { + const ts = await this.getTypeScript(); + const tsconfig: TypeScript.ParsedCommandLine = await this.getTsconfig(); + return transformModule(this, this.internalCache, opts, ts, tsconfig.options); + } + + transformModuleSync(opts: TransformModuleOptions): TransformModuleResult { + const ts = this.getTypeScriptSync(); + const tsconfig: TypeScript.ParsedCommandLine = this.getTsconfigSync(); + return transformModule(this, this.internalCache, opts, ts, tsconfig.options); + } + + async getTsconfig() { + if (!this.tsconfig) { + this.tsconfig = getTsconfig(await this.getTypeScript(), this.getRootDir()); + } + return this.tsconfig; + } + + getTsconfigSync() { + if (!this.tsconfig) { + this.tsconfig = getTsconfig(this.getTypeScriptSync(), this.getRootDir()); + } + return this.tsconfig; + } + + setTsconfig(tsconfig: any) { + this.tsconfig = tsconfig; + } + + async getTypeScript() { + if (!this.ts) { + this.ts = await getTypeScript(this.getRootDir()); + } + return this.ts; + } + + getTypeScriptSync() { + if (!this.ts) { + this.ts = getTypeScriptSync(); + } + return this.ts; + } + + setTypeScript(ts: any) { + this.ts = ts; + } +} diff --git a/src/optimizer/post-build.ts b/src/optimizer/post-build.ts new file mode 100644 index 00000000000..5751552c467 --- /dev/null +++ b/src/optimizer/post-build.ts @@ -0,0 +1,5 @@ +import type { OutputFile } from './types'; + +export function postBuild(outFile: OutputFile) { + return outFile; +} diff --git a/src/optimizer/transform.ts b/src/optimizer/transform.ts new file mode 100644 index 00000000000..a7b967004b5 --- /dev/null +++ b/src/optimizer/transform.ts @@ -0,0 +1,145 @@ +import type { + Optimizer, + InternalCache, + TransformModuleOptions, + TransformModuleResult, +} from './types'; +import type TypeScript from 'typescript'; +import { isJsxFile, toBase64 } from './utils'; + +export function transformModule( + optimizer: Optimizer, + c: InternalCache, + opts: TransformModuleOptions, + ts: typeof TypeScript, + tsCompilerOptions: TypeScript.CompilerOptions +) { + const compilerOptions = getCompilerOptions(ts, tsCompilerOptions, optimizer, opts); + + const cacheKey = + optimizer.isCacheEnabled() && typeof opts.createCacheKey === 'function' + ? opts.createCacheKey( + JSON.stringify({ + f: opts.filePath, + x: opts.text, + t: ts.version, + ...compilerOptions, + }) + ) + : null; + + if (cacheKey) { + const inMemoryCache = c.modules.find((c) => c.cacheKey === cacheKey); + if (inMemoryCache) { + return inMemoryCache; + } + + if (typeof opts.readFromCacheSync === 'function') { + const cachedResult = opts.readFromCacheSync(cacheKey); + if (cachedResult) { + setInMemoryCache(c, cachedResult); + return cachedResult; + } + } + } + + const tsResult = ts.transpileModule(opts.text, { + compilerOptions, + fileName: opts.filePath, + }); + + const result: TransformModuleResult = { + filePath: opts.filePath, + text: tsResult.outputText, + map: tsResult.sourceMapText, + cacheKey, + }; + + if (typeof result.text === 'string' && opts.sourcemap === 'inline' && result.map) { + try { + const sourceMap = JSON.parse(result.map); + sourceMap.file = opts.filePath; + sourceMap.sources = [opts.filePath]; + delete sourceMap.sourceRoot; + + const base64Map = toBase64(JSON.stringify(sourceMap)); + if (base64Map !== '') { + const sourceMapInlined = `data:application/json;charset=utf-8;base64,${base64Map}`; + const commentPos = result.text.lastIndexOf('//#'); + result.text = result.text.slice(0, commentPos) + '//# sourceMappingURL=' + sourceMapInlined; + result.map = undefined; + } + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + } + } + + if (Array.isArray(tsResult.diagnostics) && tsResult.diagnostics.length > 0) { + return result; + } + + if (cacheKey) { + setInMemoryCache(c, result); + + if (typeof opts.writeToCacheSync === 'function') { + opts.writeToCacheSync(cacheKey, result); + } + } + + return result; +} + +function getCompilerOptions( + ts: typeof TypeScript, + tsconfigCompilerOpts: TypeScript.CompilerOptions, + optimizer: Optimizer, + opts: TransformModuleOptions +) { + const compilerOpts: TypeScript.CompilerOptions = { + ...ts.getDefaultCompilerOptions(), + ...tsconfigCompilerOpts, + declaration: false, + isolatedModules: true, + skipLibCheck: true, + suppressOutputPathCheck: true, + allowNonTsExtensions: true, + noLib: true, + noResolve: true, + }; + + if (typeof compilerOpts.esModuleInterop !== 'boolean') { + compilerOpts.esModuleInterop = true; + } + + compilerOpts.module = opts.module === 'cjs' ? ts.ModuleKind.CommonJS : ts.ModuleKind.ES2020; + + if (typeof compilerOpts.target === 'undefined') { + compilerOpts.target = ts.ScriptTarget.ES2017; + } + + if (opts.sourcemap === true || opts.sourcemap === 'inline') { + compilerOpts.sourceMap = true; + } else if (compilerOpts.sourceMap !== false) { + const sourceMapOpt = optimizer.getSourceMapOption(); + compilerOpts.sourceMap = sourceMapOpt === true || sourceMapOpt === 'inline'; + } + + if (isJsxFile(opts.filePath)) { + compilerOpts.jsx = ts.JsxEmit.ReactJSX; + compilerOpts.jsxImportSource = '@builder.io/qwik'; + } + + return compilerOpts; +} + +function setInMemoryCache(c: InternalCache, result: TransformModuleResult) { + const currentIndex = c.modules.findIndex((c) => c.filePath === result.filePath); + if (currentIndex > -1) { + c.modules.splice(currentIndex, 1); + } + c.modules.push(result); + while (c.modules.length > 300) { + c.modules.shift(); + } +} diff --git a/src/optimizer/types.ts b/src/optimizer/types.ts new file mode 100644 index 00000000000..04c42157e88 --- /dev/null +++ b/src/optimizer/types.ts @@ -0,0 +1,106 @@ +import type { Optimizer } from './optimizer'; + +export interface OptimizerOptions { + cache?: boolean; + entryInputs?: EntryInput[]; + mode?: Mode; + rootDir?: string; + sourcemap?: SourceMapOption; + ts?: any; + tsconfig?: any; +} + +export interface TransformModuleOptions { + filePath: string; + text: string; + module?: 'es' | 'cjs'; + sourcemap?: SourceMapOption; + createCacheKey?: (content: string) => string; + readFromCacheSync?: (cacheKey: string) => TransformModuleResult | null | undefined; + writeToCacheSync?: (cacheKey: string, result: TransformModuleResult) => void; +} + +export interface TransformModuleResult { + filePath: string; + text: string; + map: any; + cacheKey: string | null; +} + +export type Mode = 'development' | 'production'; + +export type OutputPlatform = 'client' | 'server'; + +export type SourceMapOption = boolean | 'inline'; + +export type { Optimizer }; + +export interface InternalCache { + modules: TransformModuleResult[]; + resolved: Map; + tsResolveCache?: any; +} + +export interface EntryInput { + filePath: string; +} + +/** + * @public + */ +export interface OutputFile { + path: string; + text: string; + platform?: OutputPlatform; +} + +export interface Diagnostic { + type: 'error' | 'warn'; + message: string; + platform?: OutputPlatform; + location?: { + column: number; + file: string; + length: number; + line: number; + lineText: string; + } | null; +} + +export interface EntryPointOptions { + platform?: OutputPlatform; +} + +export interface ResolveModuleOptions { + moduleName: string; + containingFile: string; + host?: ResolveModuleHost; +} + +export interface ResolveModuleHost { + fileExists(fileName: string): boolean; + readFile(fileName: string): string | undefined; + directoryExists?(directoryName: string): boolean; + realpath?(path: string): string; + getDirectories?(path: string): string[]; +} + +export interface ResolveModuleResult { + /** Path of the file the module was resolved to. */ + resolvedFileName?: string; + /** True if `resolvedFileName` comes from `node_modules`. */ + isExternalLibraryImport?: boolean; + /** + * Name of the package. + * Should not include `@types`. + * If accessing a non-index file, this should include its name e.g. "foo/bar". + */ + name?: string; + /** + * Name of a submodule within this package. + * May be "". + */ + subModuleName?: string; + /** Version of the package, e.g. "1.2.3" */ + version?: string; +} diff --git a/src/optimizer/typescript-platform.ts b/src/optimizer/typescript-platform.ts new file mode 100644 index 00000000000..2dd2c1cf808 --- /dev/null +++ b/src/optimizer/typescript-platform.ts @@ -0,0 +1,106 @@ +import type TypeScript from 'typescript'; +import type { ResolveModuleOptions, ResolveModuleResult } from './types'; +import { platform } from './utils'; + +export function getTsconfig( + ts: typeof TypeScript, + rootDir: string +): TypeScript.ParsedCommandLine | null { + if (ts && ts.sys) { + const tsconfigPath = ts.findConfigFile(rootDir, ts.sys.fileExists); + if (tsconfigPath) { + const tsconfigResults = ts.readConfigFile(tsconfigPath, ts.sys.readFile); + if (!tsconfigResults.error) { + return ts.parseJsonConfigFileContent( + tsconfigResults.config, + ts.sys, + rootDir, + undefined, + tsconfigPath + ); + } + } + } + return null; +} + +export async function getTypeScript(rootDir: string): Promise { + if (platform === 'node' && typeof require !== 'function') { + const module = await import('module'); + const require = module.Module.createRequire(rootDir); + return require(getTypeScriptPath()); + } + return getTypeScriptSync(); +} + +export function getTypeScriptSync(): typeof TypeScript | null { + if (platform === 'node') { + if (typeof require === 'function') { + // NodeJs (CJS) + return require(getTypeScriptPath()); + } else { + throw new Error('NodeJs require() not available'); + } + } else if (platform === 'browser-webworker') { + // Browser (Web Worker) + if (!self.ts) { + (self as any).importScripts(getTypeScriptPath()); + } + return self.ts as any; + } else if (platform === 'browser-main') { + // Browser (Main) + if (!window.ts) { + throw new Error(`Browser main must already have TypeScript loaded on "window.ts"`); + } + } + return null; +} + +function getTypeScriptPath() { + if (platform === 'node') { + return `typescript`; + } + if (platform === 'browser-main' || platform === 'browser-webworker') { + return `https://cdn.jsdelivr.net/npm/typescript@__TYPESCRIPT__/lib/typescript.js`; + } + throw Error(`unsupported platform`); +} + +export function resolveModuleSync( + ts: typeof TypeScript, + compilerOpts: TypeScript.CompilerOptions, + moduleResolveCache: TypeScript.ModuleResolutionCache, + opts: ResolveModuleOptions +) { + if (!opts.host) { + if (platform === 'node') { + opts.host = ts.sys; + } + } + const tsResult = ts.resolveModuleName( + opts.moduleName, + opts.containingFile, + compilerOpts, + opts.host!, + moduleResolveCache + ); + const result: ResolveModuleResult = {}; + if (tsResult?.resolvedModule) { + if (tsResult.resolvedModule.packageId) { + result.name = tsResult.resolvedModule.packageId.name; + result.subModuleName = tsResult.resolvedModule.packageId.subModuleName; + result.version = tsResult.resolvedModule.packageId.version; + } + result.isExternalLibraryImport = tsResult.resolvedModule.isExternalLibraryImport; + result.resolvedFileName = tsResult.resolvedModule.resolvedFileName; + } + return result; +} + +declare const window: { + ts?: typeof TypeScript; +}; + +declare const self: { + ts?: typeof TypeScript; +}; diff --git a/src/optimizer/utils.ts b/src/optimizer/utils.ts new file mode 100644 index 00000000000..8defae5b77f --- /dev/null +++ b/src/optimizer/utils.ts @@ -0,0 +1,104 @@ +import type { Optimizer, OptimizerOptions, EntryPointOptions } from './types'; + +export function normalizeOptions(optimizer: Optimizer, opts?: OptimizerOptions) { + if (opts) { + if (typeof opts.cache === 'boolean') optimizer.enableCache(opts.cache); + if (typeof opts.sourcemap === 'string' || typeof opts.sourcemap === 'boolean') + optimizer.setSourceMapOption(opts.sourcemap); + if (typeof opts.mode === 'string') optimizer.setMode(opts.mode); + if (typeof opts.rootDir === 'string') optimizer.setRootDir(opts.rootDir); + if (opts.ts) optimizer.setTypeScript(opts.ts); + if (opts.tsconfig) optimizer.setTsconfig(opts.tsconfig); + } +} + +export const platform = (() => { + if (typeof process !== 'undefined' && process.nextTick && !(process as any).browser) { + return 'node'; + } + if (typeof self !== 'undefined' && typeof (self as any).importScripts === 'function') { + return 'browser-webworker'; + } + if (typeof window !== 'undefined' && typeof document !== 'undefined') { + return 'browser-main'; + } + throw Error(`unsupported platform`); +})(); + +const queryRE = /\?.*$/; +const hashRE = /#.*$/; + +export function pathExtname(path: string) { + if (typeof path === 'string') { + const basename = pathBasename(path); + const pos = basename.lastIndexOf('.'); + if (pos > 0) { + return basename.slice(pos).toLowerCase().replace(hashRE, '').replace(queryRE, ''); + } + } + return ''; +} + +export function pathBasename(path: string) { + if (typeof path === 'string') { + const last = path.split(/[\\/]/).pop(); + if (typeof last === 'string') { + return last.replace(hashRE, '').replace(queryRE, ''); + } + } + return ''; +} + +export function isJsxFile(filePath: string) { + const extname = pathExtname(filePath); + return extname === '.tsx' || extname === '.jsx'; +} + +export function toBase64(content: any) { + if (content) { + try { + if (typeof Buffer === 'function' && typeof Buffer.from === 'function') { + return Buffer.from(content, 'utf8').toString('base64'); + } + } catch (e) { + // eslint-disable-next-line no-console + console.error('toBase64', e); + } + } + return ''; +} + +export function normalizeUrl(url: string) { + return new URL(url, 'http://app.qwik.dev/').href; +} + +const TEST_FILE_REG = new RegExp('(/__tests__/.*|\\.(test|spec|unit))\\.(tsx|ts)$'); +const TS_FILE_REG = new RegExp('\\.(tsx|ts)$'); +const DTS_FILE_REG = new RegExp('\\.d\\.ts$'); +const SERVER_FILE_REG = new RegExp('\\.server\\.(tsx|ts)$'); + +export function getEntryPoints(opts: EntryPointOptions, files: string[]) { + return files.filter((f) => { + if (!TS_FILE_REG.test(f) || TEST_FILE_REG.test(f) || DTS_FILE_REG.test(f)) { + return false; + } + if (opts.platform === 'client') { + if (SERVER_FILE_REG.test(f)) { + return false; + } + } + return true; + }); +} + +/** + * Utility timer function for performance profiling. + * @alpha + */ +export function createTimer() { + const start = process.hrtime(); + return () => { + const end = process.hrtime(start); + return (end[0] * 1000000000 + end[1]) / 1000000; + }; +} diff --git a/src/optimizer/utils.unit.ts b/src/optimizer/utils.unit.ts new file mode 100644 index 00000000000..19869131bc2 --- /dev/null +++ b/src/optimizer/utils.unit.ts @@ -0,0 +1,27 @@ +import type { EntryPointOptions } from './types'; +import { getEntryPoints } from './utils'; + +describe('optimizer utils', () => { + const files = [ + '/mustang.ts', + '/camaro.tsx', + '/nova.server.ts', + '/challenger.d.ts', + '/cuda.unit.ts', + '/charger.spec.ts', + '/amx.test.ts', + '/__tests__/chevelle.ts', + ]; + + it('getEntryPoints, client options', () => { + const opts: EntryPointOptions = { platform: 'client' }; + const entries = getEntryPoints(opts, files); + expect(entries).toEqual(['/mustang.ts', '/camaro.tsx']); + }); + + it('getEntryPoints, no options', () => { + const opts: EntryPointOptions = {}; + const entries = getEntryPoints(opts, files); + expect(entries).toEqual(['/mustang.ts', '/camaro.tsx', '/nova.server.ts']); + }); +}); diff --git a/src/optimizer/write-output.ts b/src/optimizer/write-output.ts new file mode 100644 index 00000000000..52d60de57db --- /dev/null +++ b/src/optimizer/write-output.ts @@ -0,0 +1,35 @@ +import type { OutputFile } from './types'; +import { platform } from './utils'; + +/** + * Utility function to async write files to disk. + * @public + */ +export async function writeOutput(opts: { dir: string; files: OutputFile[]; emptyDir?: boolean }) { + if (platform === 'node') { + const path = await import('path'); + const fs = await import('fs/promises'); + + if (opts.emptyDir) { + await fs.rm(opts.dir, { recursive: true, force: true }); + } + + const files = opts.files.map((o) => ({ + ...o, + filePath: path.join(opts.dir, o.path), + })); + + const ensureDirs = Array.from(new Set(files.map((f) => path.dirname(f.filePath)))); + for (const dir of ensureDirs) { + try { + await fs.mkdir(dir, { recursive: true }); + } catch (e) { + /**/ + } + } + + await Promise.all(files.map((f) => fs.writeFile(f.filePath, f.text))); + } else { + throw new Error(`Unsupported platform to write files with`); + } +}