Skip to main content
Module

x/aleph/server/app.ts

The Full-stack Framework in Deno.
Go to Latest
File
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249
import { createHash } from 'https://deno.land/std@0.90.0/hash/mod.ts'import { bold, dim } from 'https://deno.land/std@0.90.0/fmt/colors.ts'import * as path from 'https://deno.land/std@0.90.0/path/mod.ts'import { walk } from 'https://deno.land/std@0.90.0/fs/walk.ts'import { ensureDir } from 'https://deno.land/std@0.90.0/fs/ensure_dir.ts'import { buildChecksum, ImportMap, parseExportNames, SourceType, transform, TransformOptions } from '../compiler/mod.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(bold('- Public Assets')) } log.info(' ∆', rp.split('\\').join('/'), 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, 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(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, 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 } } }}