Skip to main content
Module

x/aleph/server/app.ts

The Full-stack Framework in Deno.
Very Popular
Go to Latest
File
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245
import { buildChecksum, ImportMap, parseExportNames, SourceType, transform, TransformOptions } from '../compiler/mod.ts'import { colors, createHash, ensureDir, path, walk } from '../deps.ts'import { EventEmitter } from '../framework/core/events.ts'import { moduleExts, toPagePath, trimModuleExt } from '../framework/core/module.ts'import { RouteModule, Routing } from '../framework/core/routing.ts'import { defaultReactVersion, minDenoVersion } from '../shared/constants.ts'import { ensureTextFile, existsDirSync, existsFileSync} from '../shared/fs.ts'import log from '../shared/log.ts'import util from '../shared/util.ts'import type { Config, DependencyDescriptor, LoaderPlugin, LoaderTransformResult, Module, RouterURL, ServerApplication, TransformFn} from '../types.ts'import { VERSION } from '../version.ts'import { Bundler, bundlerRuntimeCode } from './bundler.ts'import { defaultConfig, loadConfig, loadImportMap } from './config.ts'import { computeHash, formatBytesWithColor, getAlephPkgUri, getDenoDir, getRelativePath, isLoaderPlugin, reFullVersion, toLocalUrl} from './helper.ts'import { Renderer } from './ssr.ts'
/** The application class for aleph server. */export class Application implements ServerApplication { readonly workingDir: string readonly mode: 'development' | 'production' readonly config: Required<Config> readonly importMap: ImportMap readonly ready: Promise<void>
#dirs: Map<string, string> = new Map() #modules: Map<string, Module> = new Map() #pageRouting: Routing = new Routing({}) #apiRouting: Routing = new Routing({}) #fsWatchListeners: Array<EventEmitter> = [] #bundler: Bundler = new Bundler(this) #renderer: Renderer = new Renderer(this) #renderCache: Map<string, Map<string, [string, any]>> = new Map() #injects: Map<'compilation' | 'hmr' | 'ssr', TransformFn[]> = new Map() #reloading = false
constructor( workingDir = '.', mode: 'development' | 'production' = 'production', reload = false ) { if (Deno.version.deno < minDenoVersion) { log.error(`Aleph.js needs Deno ${minDenoVersion}+, please upgrade Deno.`) Deno.exit(1) } this.workingDir = path.resolve(workingDir) this.mode = mode this.config = { ...defaultConfig } this.importMap = { imports: {}, scopes: {} } this.ready = this.init(reload) }
/** initiate application */ private async init(reload: boolean) { let t = performance.now()
const [config, importMap] = await Promise.all([ loadConfig(this.workingDir), loadImportMap(this.workingDir) ])
log.debug(`load config in ${Math.round(performance.now() - t)}ms`) t = performance.now()
Object.assign(this.config, config) Object.assign(this.importMap, importMap) this.#pageRouting.config(this.config)
// inject env variables Deno.env.set('ALEPH_VERSION', VERSION) Deno.env.set('BUILD_MODE', this.mode)
// inject browser navigator polyfill Object.assign((globalThis as any).navigator, { connection: { downlink: 10, effectiveType: "4g", onchange: null, rtt: 50, saveData: false, }, cookieEnabled: false, language: 'en', languages: ['en'], onLine: true, platform: Deno.build.os, userAgent: `Deno/${Deno.version.deno}`, vendor: 'Deno Land' })
const alephPkgUri = getAlephPkgUri() const buildManifestFile = path.join(this.buildDir, 'build.manifest.json') const configHash = computeHash(JSON.stringify({ ...this.defaultCompileOptions, plugins: this.config.plugins.filter(isLoaderPlugin).map(({ name }) => name) })) let shouldRebuild = !existsFileSync(buildManifestFile) if (!shouldRebuild) { try { const v = JSON.parse(await Deno.readTextFile(buildManifestFile)) shouldRebuild = ( typeof v !== 'object' || v === null || v.compiler !== buildChecksum || v.configHash !== configHash ) } catch (e) { } }
this.#reloading = reload if (reload || shouldRebuild) { if (existsDirSync(this.buildDir)) { await Deno.remove(this.buildDir, { recursive: true }) } await ensureDir(this.buildDir) }
if (shouldRebuild) { log.debug('rebuild...') ensureTextFile(buildManifestFile, JSON.stringify({ aleph: VERSION, compiler: buildChecksum, configHash, deno: Deno.version.deno, }, undefined, 2)) }
// apply server plugins for (const plugin of this.config.plugins) { if (plugin.type === 'server') { await plugin.onInit(this) } }
// init framework const { init } = await import(`../framework/${this.config.framework}/init.ts`) await init(this)
// import framework renderer if (this.config.ssr) { const { jsFile } = await this.compile(`${alephPkgUri}/framework/${this.config.framework}/renderer.ts`) const { render } = await import(`file://${jsFile}`) if (util.isFunction(render)) { this.#renderer.setFrameworkRenderer({ render }) } }
log.info('Compiling...')
// pre-compile framework modules await this.compile(`${alephPkgUri}/framework/${this.config.framework}/bootstrap.ts`) if (this.isDev) { await this.compile(`${alephPkgUri}/framework/core/hmr.ts`) await this.compile(`${alephPkgUri}/framework/core/nomodule.ts`) }
// compile custom components for (const name of ['app', '404', 'loading']) { for (const ext of moduleExts) { if (existsFileSync(path.join(this.srcDir, `${name}.${ext}`))) { await this.compile(`/${name}.${ext}`) break } } }
// update page routing const pagesDir = path.join(this.srcDir, 'pages') const walkOptions = { includeDirs: false, skip: [ /(^|\/|\\)\./, /\.d\.ts$/i, /(\.|_)(test|spec|e2e)\.(tsx?|jsx?|mjs)?$/i ] } for await (const { path: p } of walk(pagesDir, walkOptions)) { const url = util.cleanPath('/pages/' + util.trimPrefix(p, pagesDir)) let validated = moduleExts.some(ext => p.endsWith('.' + ext)) if (!validated) { validated = this.config.plugins.some(p => p.type === 'loader' && p.test.test(url) && p.asPage) } if (validated) { await this.compile(url) this.#pageRouting.update(this.createRouteModule(url)) } }
// update api routing const apiDir = path.join(this.srcDir, 'api') if (existsDirSync(apiDir)) { for await (const { path: p } of walk(apiDir, { ...walkOptions, exts: moduleExts })) { const url = util.cleanPath('/api/' + util.trimPrefix(p, apiDir)) await this.compile(url) this.#apiRouting.update(this.createRouteModule(url)) } }
// pre-bundle if (!this.isDev) { await this.bundle() }
// end reload if (reload) { this.#reloading = false }
log.debug(`init project in ${Math.round(performance.now() - t)}ms`)
if (this.isDev) { this.watch() } }
/** watch file changes, re-compile modules and send HMR signal. */ private async watch() { const w = Deno.watchFs(this.srcDir, { recursive: true }) log.info('Start watching code changes...') for await (const event of w) { for (const p of event.paths) { const url = util.cleanPath(util.trimPrefix(p, this.srcDir)) if (this.isScopedModule(url)) { util.debounceX(url, () => { if (existsFileSync(p)) { let type = 'modify' if (!this.#modules.has(url)) { type = 'add' } log.info(type, url) this.compile(url, { forceCompile: true }).then(mod => { const hmrable = this.isHMRable(mod.url) const update = ({ url }: Module) => { if (trimModuleExt(url) === '/app') { this.#renderCache.clear() } else if (url.startsWith('/pages/')) { this.#renderCache.delete(toPagePath(url)) this.#pageRouting.update(this.createRouteModule(url)) } else if (url.startsWith('/api/')) { this.#apiRouting.update(this.createRouteModule(url)) } } if (hmrable) { if (type === 'add') { this.#fsWatchListeners.forEach(e => e.emit('add', { url: mod.url })) } else { this.#fsWatchListeners.forEach(e => e.emit('modify-' + mod.url)) } } update(mod) this.applyCompilationSideEffect(url, (mod) => { update(mod) if (!hmrable && this.isHMRable(mod.url)) { this.#fsWatchListeners.forEach(w => w.emit('modify-' + mod.url)) } }) }).catch(err => { log.error(`compile(${url}):`, err.message) }) } else if (this.#modules.has(url)) { if (trimModuleExt(url) === '/app') { this.#renderCache.clear() } else if (url.startsWith('/pages/')) { this.#renderCache.delete(toPagePath(url)) this.#pageRouting.removeRoute(toPagePath(url)) } else if (url.startsWith('/api/')) { this.#apiRouting.removeRoute(toPagePath(url)) } this.#modules.delete(url) if (this.isHMRable(url)) { this.#fsWatchListeners.forEach(e => e.emit('remove', url)) } log.info('remove', url) } }, 150) } } } }
private isScopedModule(url: string) { for (const ext of moduleExts) { if (url.endsWith('.' + ext)) { if (url.startsWith('/pages/') || url.startsWith('/api/')) { return true } switch (trimModuleExt(url)) { case '/404': case '/app': return true } } }
// is page module by plugin if (this.config.plugins.some(p => p.type === 'loader' && p.test.test(url) && p.asPage)) { return true }
// is dep for (const { deps } of this.#modules.values()) { if (deps.some(dep => dep.url === url)) { return true } }
return false }
get isDev() { return this.mode === 'development' }
get srcDir() { return this.getDir('src', () => path.join(this.workingDir, this.config.srcDir)) }
get outputDir() { return this.getDir('output', () => path.join(this.workingDir, this.config.outputDir)) }
get buildDir() { return this.getDir('build', () => path.join(this.workingDir, '.aleph', this.mode)) }
/** returns the module by given url. */ getModule(url: string): Module | null { if (this.#modules.has(url)) { return this.#modules.get(url)! } return null }
findModuleByName(name: string): Module | null { for (const ext of moduleExts) { const url = `/${util.trimPrefix(name, '/')}.${ext}` if (this.#modules.has(url)) { return this.#modules.get(url)! } } return null }
getPageRoute(location: { pathname: string, search?: string }): [RouterURL, RouteModule[]] { return this.#pageRouting.createRouter(location) }
getAPIRoute(location: { pathname: string, search?: string }): [RouterURL, Module] | null { const router = this.#apiRouting.createRouter(location) if (router !== null) { const [url, nestedModules] = router const { url: moduleUrl } = nestedModules.pop()! return [url, this.#modules.get(moduleUrl)!] } return null }
/** add a new page module by given path and source code. */ async addModule(url: string, options: { code?: string, once?: boolean } = {}): Promise<Module> { const mod = await this.compile(url, { sourceCode: options.code }) if (url.startsWith('/pages/')) { this.#pageRouting.update(this.createRouteModule(url)) } else if (url.startsWith('/api/')) { this.#apiRouting.update(this.createRouteModule(url)) } return mod }
/** inject code */ injectCode(stage: 'compilation' | 'hmr' | 'ssr', transform: TransformFn): void { if (this.#injects.has(stage)) { this.#injects.get(stage)!.push(transform) } else { this.#injects.set(stage, [transform]) } }
/** get ssr data */ async getSSRData(loc: { pathname: string, search?: string }): Promise<any> { if (!this.isSSRable(loc.pathname)) { return null }
const [router, nestedModules] = this.#pageRouting.createRouter(loc) const { pagePath } = router if (pagePath === '') { return null }
const cacheKey = router.pathname + router.query.toString() const ret = await this.useRenderCache(pagePath, cacheKey, async () => { return await this.#renderer.renderPage(router, nestedModules) }) return ret[1] }
/** get ssr page */ async getPageHTML(loc: { pathname: string, search?: string }): Promise<[number, string]> { const [router, nestedModules] = this.#pageRouting.createRouter(loc) const { pagePath } = router const status = pagePath !== '' ? 200 : 404 const path = router.pathname + router.query.toString()
if (!this.isSSRable(loc.pathname)) { const [html] = await this.useRenderCache('-', 'spa-index', async () => { return [await this.#renderer.renderSPAIndexPage(), null] }) return [status, html] }
if (pagePath === '') { const [html] = await this.useRenderCache('404', path, async () => { return [await this.#renderer.render404Page(router), null] }) return [status, html] }
const [html] = await this.useRenderCache(pagePath, path, async () => { let [html, data] = await this.#renderer.renderPage(router, nestedModules) return [html, data] }) return [status, html] }
createFSWatcher(): EventEmitter { const e = new EventEmitter() this.#fsWatchListeners.push(e) return e }
removeFSWatcher(e: EventEmitter) { e.removeAllListeners() const index = this.#fsWatchListeners.indexOf(e) if (index > -1) { this.#fsWatchListeners.splice(index, 1) } }
isHMRable(url: string) { if (!this.isDev) { return false }
for (const ext of moduleExts) { if (url.endsWith('.' + ext)) { return ( url.startsWith('/pages/') || url.startsWith('/components/') || ['/app', '/404'].includes(util.trimSuffix(url, '.' + ext)) ) } }
return this.config.plugins.some(p => ( p.type === 'loader' && p.test.test(url) && (p.asPage || p.acceptHMR) )) }
/** inject HMR code */ injectHMRCode({ url }: Module, content: string): string { const hmrModuleImportUrl = getRelativePath( path.dirname(toLocalUrl(url)), toLocalUrl(`${getAlephPkgUri()}/framework/core/hmr.js`) ) const lines = [ `import { createHotContext } from ${JSON.stringify(hmrModuleImportUrl)};`, `import.meta.hot = createHotContext(${JSON.stringify(url)});`, '', content, '', 'import.meta.hot.accept();' ]
let code = lines.join('\n') this.#injects.get('hmr')?.forEach(transform => { code = transform(url, code) }) return code }
/** get main code in javascript. */ getMainJS(bundleMode = false): string { const alephPkgUri = getAlephPkgUri() const alephPkgPath = alephPkgUri.replace('https://', '').replace('http://localhost:', 'http_localhost_') const { baseUrl: baseURL, defaultLocale, framework } = this.config const config: Record<string, any> = { baseURL, defaultLocale, locales: [], routes: this.#pageRouting.routes, rewrites: this.config.rewrites, sharedModules: Array.from(this.#modules.values()).filter(({ url }) => { switch (trimModuleExt(url)) { case '/404': case '/app': return true default: return false } }).map(({ url }) => this.createRouteModule(url)), renderMode: this.config.ssr ? 'ssr' : 'spa' }
if (bundleMode) { return [ `__ALEPH.baseURL = ${JSON.stringify(baseURL)};`, `__ALEPH.pack["${alephPkgUri}/framework/${framework}/bootstrap.ts"].default(${JSON.stringify(config)});` ].join('') }
let code = [ `import bootstrap from "./-/${alephPkgPath}/framework/${framework}/bootstrap.js";`, `bootstrap(${JSON.stringify(config, undefined, this.isDev ? 2 : undefined)});` ].filter(Boolean).join('\n') this.#injects.get('compilation')?.forEach(transform => { code = transform('/main.js', code) }) return code }
/** get ssr html scripts */ getSSRHTMLScripts(pagePath?: string) { const { baseUrl } = this.config
if (this.isDev) { return [ { src: util.cleanPath(`${baseUrl}/_aleph/main.js`), type: 'module' }, { src: util.cleanPath(`${baseUrl}/_aleph/-/deno.land/x/aleph/nomodule.js`), nomodule: true }, ] }
return [ bundlerRuntimeCode, ...['polyfill', 'deps', 'shared', 'main', pagePath ? '/pages' + pagePath.replace(/\/$/, '/index') : ''] .filter(name => name !== "" && this.#bundler.getBundledFile(name) !== null) .map(name => ({ src: util.cleanPath(`${baseUrl}/_aleph/${this.#bundler.getBundledFile(name)}`) })) ] }
async resolveModule(url: string) { const { content, contentType } = await this.fetchModule(url) const source = await this.precompile(url, content, contentType) if (source === null) { throw new Error(`Unsupported module '${url}'`) } return source }
/** default compiler options */ private get defaultCompileOptions(): TransformOptions { return { importMap: this.importMap, alephPkgUri: getAlephPkgUri(), reactVersion: defaultReactVersion, isDev: this.isDev, } }
/** build the application to a static site(SSG) */ async build() { const start = performance.now() const outputDir = this.outputDir const distDir = path.join(outputDir, '_aleph')
// wait for app ready await this.ready
// clear previous build if (existsDirSync(outputDir)) { for await (const entry of Deno.readDir(outputDir)) { await Deno.remove(path.join(outputDir, entry.name), { recursive: entry.isDirectory }) } } await ensureDir(distDir)
// copy bundle dist await this.#bundler.copyDist()
// ssg await this.ssg()
// copy public assets const publicDir = path.join(this.workingDir, 'public') if (existsDirSync(publicDir)) { let n = 0 for await (const { path: p } of walk(publicDir, { includeDirs: false, skip: [/(^|\/|\\)\./] })) { const rp = util.trimPrefix(p, publicDir) const fp = path.join(outputDir, rp) const fi = await Deno.lstat(p) await ensureDir(path.dirname(fp)) await Deno.copyFile(p, fp) if (n === 0) { log.info(colors.bold('- Public Assets')) } log.info(' ∆', rp.split('\\').join('/'), colors.dim('•'), formatBytesWithColor(fi.size)) n++ } }
log.info(`Done in ${Math.round(performance.now() - start)}ms`) }
private getDir(name: string, init: () => string) { if (this.#dirs.has(name)) { return this.#dirs.get(name)! }
const dir = init() this.#dirs.set(name, dir) return dir }
private createRouteModule(url: string): RouteModule { let useDeno: true | undefined = undefined if (this.config.ssr !== false) { this.lookupDeps(url, dep => { if (dep.url.startsWith('#useDeno-')) { useDeno = true return false } }) } return { url, useDeno } }
/** apply loaders recurively. */ private async applyLoader( loader: LoaderPlugin, input: { url: string, content: Uint8Array, map?: Uint8Array } ): Promise<Omit<LoaderTransformResult, 'loader'>> { const { code, map, type } = await loader.transform(input) if (type) { for (const plugin of this.config.plugins) { if (plugin.type === 'loader' && plugin.test.test('.' + type) && plugin !== loader) { const encoder = new TextEncoder() return this.applyLoader(plugin, { url: input.url, content: encoder.encode(code), map: map ? encoder.encode(map) : undefined }) } } } return { code, map } }
/** fetch module content */ private async fetchModule(url: string): Promise<{ content: Uint8Array, contentType: string | null }> { for (const plugin of this.config.plugins) { if (plugin.type === 'loader' && plugin.test.test(url) && plugin.resolve !== undefined) { const ret = plugin.resolve(url) let content: Uint8Array if (ret instanceof Promise) { content = (await ret).content } else { content = ret.content } if (content instanceof Uint8Array) { return { content, contentType: null } } } }
if (!util.isLikelyHttpURL(url)) { const filepath = path.join(this.srcDir, util.trimPrefix(url, 'file://')) const content = await Deno.readFile(filepath) return { content, contentType: null } }
const u = new URL(url) if (url.startsWith('https://esm.sh/')) { if (this.isDev && !u.searchParams.has('dev')) { u.searchParams.set('dev', '') u.search = u.search.replace('dev=', 'dev') } }
const { protocol, hostname, port, pathname, search } = u const versioned = reFullVersion.test(pathname) const reload = this.#reloading || !versioned const isLocalhost = url.startsWith('http://localhost:') const cacheDir = path.join( await getDenoDir(), 'deps', util.trimSuffix(protocol, ':'), hostname + (port ? '_PORT' + port : '') ) const hash = createHash('sha256').update(pathname + search).toString() const contentFile = path.join(cacheDir, hash) const metaFile = path.join(cacheDir, hash + '.metadata.json')
if (!reload && !isLocalhost && existsFileSync(contentFile) && existsFileSync(metaFile)) { const [content, meta] = await Promise.all([ Deno.readFile(contentFile), Deno.readTextFile(metaFile), ]) try { const { headers } = JSON.parse(meta) return { content, contentType: headers['content-type'] || null } } catch (e) { } }
// download dep when deno cache failed let err = new Error('Unknown') for (let i = 0; i < 15; i++) { if (i === 0) { if (!isLocalhost) { log.info('Download', url) } } else { log.debug('Download error:', err) log.warn(`Download ${url} failed, retrying...`) } try { const resp = await fetch(u.toString()) if (resp.status >= 400) { return Promise.reject(new Error(resp.statusText)) } const buffer = await resp.arrayBuffer() const content = await Deno.readAll(new Deno.Buffer(buffer)) if (!isLocalhost) { await ensureDir(cacheDir) Deno.writeFile(contentFile, content) Deno.writeTextFile(metaFile, JSON.stringify({ headers: Array.from(resp.headers.entries()).reduce((m, [k, v]) => { m[k] = v return m }, {} as Record<string, string>), url }, undefined, 2)) } return { content, contentType: resp.headers.get('content-type') } } catch (e) { err = e } }
return Promise.reject(err) }
private async precompile( url: string, sourceContent: Uint8Array, contentType: string | null ): Promise<[string, SourceType] | null> { let sourceCode = (new TextDecoder).decode(sourceContent) let sourceType: SourceType = SourceType.Unknown
if (contentType !== null) { switch (contentType.split(';')[0].trim()) { case 'application/javascript': case 'text/javascript': sourceType = SourceType.JS break case 'text/typescript': sourceType = SourceType.TS break case 'text/jsx': sourceType = SourceType.JSX break case 'text/tsx': sourceType = SourceType.TSX break } }
for (const plugin of this.config.plugins) { if (plugin.type === 'loader' && plugin.test.test(url)) { const { code, type = 'js' } = await this.applyLoader( plugin, { url, content: sourceContent } ) sourceCode = code switch (type) { case 'js': sourceType = SourceType.JS break case 'jsx': sourceType = SourceType.JSX break case 'ts': sourceType = SourceType.TS break case 'tsx': sourceType = SourceType.TSX break } break } }
if (sourceType === SourceType.Unknown) { switch (path.extname(url).slice(1).toLowerCase()) { case 'mjs': case 'js': sourceType = SourceType.JS break case 'jsx': sourceType = SourceType.JSX break case 'ts': sourceType = SourceType.TS break case 'tsx': sourceType = SourceType.TSX break default: return null } }
return [sourceCode, sourceType] }
/** compile a moudle by given url, then cache on the disk. */ private async compile( url: string, options: { /* use source code string instead of source from IO */ sourceCode?: string, /* drop pervious complation */ forceCompile?: boolean, /* don't record the complation */ once?: boolean, } = {} ): Promise<Module> { const isRemote = util.isLikelyHttpURL(url) const localUrl = toLocalUrl(url) const name = trimModuleExt(path.basename(localUrl)) const saveDir = path.join(this.buildDir, path.dirname(localUrl)) const metaFile = path.join(saveDir, `${name}.meta.json`) const { sourceCode, forceCompile, once } = options
let mod: Module if (this.#modules.has(url)) { mod = this.#modules.get(url)! if (!forceCompile && !sourceCode) { return mod } } else { mod = { url, deps: [], sourceHash: '', hash: '', jsFile: '', } if (!once) { this.#modules.set(url, mod) } try { if (existsFileSync(metaFile)) { const { url: __url, sourceHash, deps } = JSON.parse(await Deno.readTextFile(metaFile)) if (__url === url && util.isNEString(sourceHash) && util.isArray(deps)) { mod.sourceHash = sourceHash mod.deps = deps } else { log.warn(`removing invalid metadata '${name}.meta.json'`) Deno.remove(metaFile) } } } catch (e) { } }
let sourceContent = new Uint8Array() let contentType: null | string = null let jsContent = '' let jsSourceMap: null | string = null let shouldCompile = false let fsync = false
if (sourceCode) { sourceContent = (new TextEncoder).encode(sourceCode) const sourceHash = computeHash(sourceContent) if (mod.sourceHash === '' || mod.sourceHash !== sourceHash) { mod.sourceHash = sourceHash shouldCompile = true } } else { let shouldFetch = true if ( !this.#reloading && (isRemote && !url.startsWith('http://localhost:')) && reFullVersion.test(url) && mod.sourceHash !== '' ) { const jsFile = path.join(saveDir, name + '.js') if (existsFileSync(jsFile)) { shouldFetch = false } } if (shouldFetch) { const { content, contentType: ctype } = await this.fetchModule(url) const sourceHash = computeHash(content) sourceContent = content contentType = ctype if (mod.sourceHash === '' || mod.sourceHash !== sourceHash) { mod.sourceHash = sourceHash shouldCompile = true } } }
// compile source code if (shouldCompile) { const source = await this.precompile(url, sourceContent, contentType) if (source === null) { log.warn(`Unsupported module '${url}'`) this.#modules.delete(url) return mod }
const t = performance.now() const [sourceCode, sourceType] = source const { code, deps, starExports, map } = await transform(url, sourceCode, { ...this.defaultCompileOptions, swcOptions: { target: 'es2020', sourceType }, // workaround for https://github.com/denoland/deno/issues/9849 resolveStarExports: !this.isDev && Deno.version.deno.replace(/\.\d+$/, '') === '1.8', sourceMap: this.isDev, loaders: this.config.plugins.filter(isLoaderPlugin) })
fsync = true jsContent = code if (map) { jsSourceMap = map }
// workaround for https://github.com/denoland/deno/issues/9849 if (starExports && starExports.length > 0) { for (let index = 0; index < starExports.length; index++) { const url = starExports[index] const [sourceCode, sourceType] = await this.resolveModule(url) const names = await parseExportNames(url, sourceCode, { sourceType }) jsContent = jsContent.replace(`export * from "${url}:`, `export {${names.filter(name => name !== 'default').join(',')}} from "`) } }
mod.deps = deps.map(({ specifier, isDynamic }) => { const dep: DependencyDescriptor = { url: specifier, hash: '' } if (isDynamic) { dep.isDynamic = true } if (dep.url.startsWith('#useDeno-') && !this.config.ssr) { log.warn(`use 'useDeno' hook in SPA mode: ${url}`) } return dep })
log.debug(`compile '${url}' in ${Math.round(performance.now() - t)}ms`) }
mod.jsFile = util.cleanPath(`${saveDir}/${name}.js`)
// compile deps for (const dep of mod.deps) { if (!dep.url.startsWith('#')) { const depMod = await this.compile(dep.url, { once }) if (dep.hash === '' || dep.hash !== depMod.hash) { dep.hash = depMod.hash if (!util.isLikelyHttpURL(dep.url)) { if (jsContent === '') { jsContent = await Deno.readTextFile(mod.jsFile) } jsContent = this.replaceDepHash(jsContent, dep) if (!fsync) { fsync = true } } } } }
if (mod.deps.length > 0) { mod.hash = computeHash(mod.sourceHash + mod.deps.map(({ hash }) => hash).join('')) } else { mod.hash = mod.sourceHash }
if (fsync) { await Promise.all([ ensureTextFile(metaFile, JSON.stringify({ url, sourceHash: mod.sourceHash, deps: mod.deps, }, undefined, 2)), ensureTextFile(mod.jsFile, jsContent + (jsSourceMap ? `//# sourceMappingURL=${path.basename(mod.jsFile)}.map` : '')), jsSourceMap ? ensureTextFile(mod.jsFile + '.map', jsSourceMap) : Promise.resolve(), ]) }
return mod }
/** apply compilation side-effect caused by dependency graph breaking. */ private async applyCompilationSideEffect(url: string, callback: (mod: Module) => void) { const { hash } = this.#modules.get(url)!
for (const mod of this.#modules.values()) { for (const dep of mod.deps) { if (dep.url === url) { const jsContent = this.replaceDepHash( await Deno.readTextFile(mod.jsFile), { url, hash } ) await Deno.writeTextFile(mod.jsFile, jsContent) mod.hash = computeHash(mod.sourceHash + mod.deps.map(({ hash }) => hash).join('')) callback(mod) log.debug('compilation side-effect:', mod.url, colors.dim('<-'), url) this.applyCompilationSideEffect(mod.url, callback) break } } } }
/** create bundle chunks for production. */ private async bundle() { const sharedEntryMods = new Set<string>() const entryMods = new Map<string[], boolean>() const refCounter = new Set<string>() const concatAllEntries = () => [ Array.from(entryMods.entries()).map(([urls, shared]) => urls.map(url => ({ url, shared }))), Array.from(sharedEntryMods).map(url => ({ url, shared: true })), ].flat(2)
// add framwork bootstrap module as shared entry entryMods.set( [`${getAlephPkgUri()}/framework/${this.config.framework}/bootstrap.ts`], true )
// add app/404 modules as shared entry entryMods.set(Array.from(this.#modules.keys()).filter(url => ['/app', '/404'].includes(trimModuleExt(url))), true)
// add page module entries this.#pageRouting.lookup(routes => { routes.forEach(({ module: { url } }) => entryMods.set([url], false)) })
// add dynamic imported module as entry this.#modules.forEach(mod => { mod.deps.forEach(({ url, isDynamic }) => { if (isDynamic) { entryMods.set([url], false) } return url }) })
for (const mods of entryMods.keys()) { const deps = new Set<string>() mods.forEach(url => { this.lookupDeps(url, dep => { if (!dep.isDynamic) { deps.add(dep.url) } }) }) deps.forEach(url => { if (refCounter.has(url)) { sharedEntryMods.add(url) } else { refCounter.add(url) } }) }
log.info('- bundle') await this.#bundler.bundle(concatAllEntries()) }
/** render all pages in routing. */ private async ssg() { const { ssr } = this.config const outputDir = this.outputDir
if (ssr === false) { const html = await this.#renderer.renderSPAIndexPage() await ensureTextFile(path.join(outputDir, 'index.html'), html) await ensureTextFile(path.join(outputDir, '404.html'), html) return }
log.info(colors.bold('- Pages (SSG)'))
// render pages const paths = new Set(this.#pageRouting.paths) if (typeof ssr === 'object' && ssr.staticPaths) { ssr.staticPaths.forEach(path => paths.add(path)) } await Promise.all(Array.from(paths).map(async pathname => { if (this.isSSRable(pathname)) { const [router, nestedModules] = this.#pageRouting.createRouter({ pathname }) if (router.pagePath !== '') { let [html, data] = await this.#renderer.renderPage(router, nestedModules) this.#injects.get('ssr')?.forEach(transform => { html = transform(pathname, html) }) await ensureTextFile(path.join(outputDir, pathname, 'index.html'), html) if (data) { const dataFile = path.join( outputDir, '_aleph/data', (pathname === '/' ? 'index' : pathname) + '.json' ) await ensureTextFile(dataFile, JSON.stringify(data)) } log.info(' ○', pathname, colors.dim('• ' + util.formatBytes(html.length))) } else { log.error('Page not found:', pathname) } } }))
// render 404 page { const [router] = this.#pageRouting.createRouter({ pathname: '/404' }) let html = await this.#renderer.render404Page(router) this.#injects.get('ssr')?.forEach(transform => { html = transform('/404', html) }) await ensureTextFile(path.join(outputDir, '404.html'), html) } }
private async useRenderCache( namespace: string, key: string, render: () => Promise<[string, any]> ): Promise<[string, any]> { let cache = this.#renderCache.get(namespace) if (cache === undefined) { cache = new Map() this.#renderCache.set(namespace, cache) } const cached = cache.get(key) if (cached !== undefined) { return cached } const ret = await render() if (namespace !== '-') { this.#injects.get('ssr')?.forEach(transform => { ret[0] = transform(key, ret[0]) }) } cache.set(key, ret) return ret }
/** check a page whether is able to SSR. */ private isSSRable(pathname: string): boolean { const { ssr } = this.config if (util.isPlainObject(ssr)) { if (ssr.include) { for (let r of ssr.include) { if (!r.test(pathname)) { return false } } } if (ssr.exclude) { for (let r of ssr.exclude) { if (r.test(pathname)) { return false } } } return true } return ssr }
private replaceDepHash(jsContent: string, dep: DependencyDescriptor) { const s = `.js#${dep.url}@` return jsContent.split(s).map((p, i) => { if (i > 0 && p.charAt(6) === '"') { return dep.hash.slice(0, 6) + p.slice(6) } return p }).join(s) }
/** lookup deps recurively. */ private lookupDeps( url: string, callback: (dep: DependencyDescriptor) => false | void, __tracing: Set<string> = new Set() ) { const mod = this.getModule(url) if (mod === null) { return } if (__tracing.has(url)) { return } __tracing.add(url) for (const dep of mod.deps) { if (callback(dep) === false) { return false } } for (const { url } of mod.deps) { if ((this.lookupDeps(url, callback, __tracing)) === false) { return } } }}