Skip to main content
Module

x/aleph/server/app.ts

The Full-stack Framework in Deno.
Very Popular
Go to Latest
File
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741
import { buildChecksum, initWasm, SWCOptions, TransformOptions, transpileSync } from '../compiler/mod.ts'import type { AcceptedPlugin, ECMA } from '../deps.ts'import { CleanCSS, colors, ensureDir, minify, path, postcss, Sha256, walk } from '../deps.ts'import { EventEmitter } from '../framework/core/events.ts'import { isModuleURL, RouteModule, Routing, toPagePath } from '../framework/core/routing.ts'import { defaultReactVersion, minDenoVersion, moduleExts } from '../shared/constants.ts'import { ensureTextFile, existsDirSync, existsFileSync, lazyRemove } from '../shared/fs.ts'import log from '../shared/log.ts'import util from '../shared/util.ts'import type { Config, DependencyDescriptor, ImportMap, Module, RenderResult, RouterURL, ServerRequest } from '../types.ts'import { VERSION } from '../version.ts'import { Request } from './api.ts'import { AlephRuntimeCode, cleanupCompilation, computeHash, createHtml, fixImportMap, formatBytesWithColor, getAlephPkgUrl, getRelativePath, reFullVersion, reHashJs, reHashResolve, reLocaleID, respondErrorJSON } from './util.ts'
/** * The Aleph Server Application class. */export class Application { readonly workingDir: string readonly mode: 'development' | 'production' readonly config: Readonly<Required<Config>> readonly importMap: ImportMap readonly ready: Promise<void>
#denoCacheDir = '' #modules: Map<string, Module> = new Map() #pageRouting: Routing = new Routing() #apiRouting: Routing = new Routing() #fsWatchListeners: Array<EventEmitter> = [] #renderer: { render: CallableFunction } = { render: () => { } } #renderCache: Map<string, Map<string, RenderResult>> = new Map() #postcssPlugins: Record<string, AcceptedPlugin> = {} #cleanCSS = new CleanCSS({ compatibility: '*' /* Internet Explorer 10+ */ }) #compilerReady: Promise<void> | boolean = false #postcssReady: Promise<void[]> | boolean = false #reloading = false
constructor(workingDir = '.', mode: 'development' | 'production' = 'production', reload = false) { this.workingDir = path.resolve(workingDir) this.mode = mode this.config = { framework: 'react', srcDir: existsDirSync(path.join(this.workingDir, '/src/pages')) ? '/src' : '/', outputDir: '/dist', baseUrl: '/', defaultLocale: 'en', env: {}, locales: [], ssr: {}, buildTarget: 'es5', reactVersion: defaultReactVersion, plugins: [], postcss: { plugins: [ 'autoprefixer' ] } } this.importMap = { imports: {}, scopes: {} } this.ready = this.init(reload) }
get isDev() { return this.mode === 'development' }
get srcDir() { return path.join(this.workingDir, this.config.srcDir) }
get outputDir() { return path.join(this.workingDir, this.config.outputDir) }
get buildDir() { return path.join(this.workingDir, '.aleph', this.mode) }
isHMRable(url: string) { if (!this.isDev) { return false } if (url.endsWith('.css') || url.endsWith('.pcss')) { return true } for (const ext of ['js', 'ts', 'mjs', 'jsx', 'tsx']) { if (url.endsWith('.' + ext)) { return url.startsWith('/pages/') || url.startsWith('/components/') || util.trimModuleExt(url) === '/app' || util.trimModuleExt(url) === '/404' } } for (const plugin of this.config.plugins) { if (plugin.type === 'loader' && plugin.test.test(url)) { return plugin.acceptHMR } } return false }
/** create a new module by given url. */ private newModule(url: string): Module { const mod = { url, loader: '', sourceHash: '', hash: '', deps: [], jsFile: '', bundlingFile: '', error: null, } const isRemote = util.isLikelyHttpURL(url) const { pathname } = new URL(util.isLikelyHttpURL(url) ? url : 'file://' + url) const ext = path.extname(pathname).slice(1).toLowerCase() if (/[a-z]/.test(ext)) { if (ext === 'css' || ext === 'pcss') { mod.loader = 'css' } else if (moduleExts.includes(ext)) { if (ext === 'mjs') { mod.loader = 'js' } else { mod.loader = ext } } else { for (const plugin of this.config.plugins) { if (plugin.type === 'loader' && plugin.test.test(pathname)) { mod.loader = ext break } } } } else if (isRemote) { mod.loader = 'js' } this.#modules.set(url, mod) return mod }
/** returns the module by given url. */ getModule(url: string): Module | null { if (this.#modules.has(url)) { return this.#modules.get(url)! } return null }
/** add a new page module by given path and source code. */ async addPageModule(pathname: string, code: string): Promise<void> { const url = path.join('/pages/', util.cleanPath(pathname) + '.tsx') const mod = await this.compile(url, { sourceCode: code }) this.#pageRouting.update(this.getRouteModule(mod)) }
/** add a new page module by given path and source code. */ async removePageModule(pathname: string): Promise<void> { const url = path.join('/pages/', util.cleanPath(pathname) + '.tsx') if (this.#modules.has(url)) { await cleanupCompilation(this.#modules.get(url)!.jsFile) this.#modules.delete(url) this.#pageRouting.removeRoute(url) } }
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) } }
async handleAPI(req: ServerRequest, loc: { pathname: string, search?: string }) { const [url, chain] = this.#apiRouting.createRouter({ ...loc, pathname: decodeURI(loc.pathname) }) if (url.pagePath !== '') { const { url: moduleUrl } = chain[chain.length - 1] try { const { default: handle } = await import('file://' + this.#modules.get(moduleUrl)!.jsFile) if (util.isFunction(handle)) { await handle(new Request(req, url.pathname, url.params, url.query)) } else { respondErrorJSON(req, 500, 'bad api handler') } } catch (err) { respondErrorJSON(req, 500, err.message) log.error('invoke API:', err) } } else { respondErrorJSON(req, 404, 'not found') } }
async getSSRData(loc: { pathname: string, search?: string }): Promise<[number, any]> { if (!this.isSSRable(loc.pathname)) { return [404, null] }
const { status, data } = await this.renderPage(loc) return [status, data] }
async getPageHtml(loc: { pathname: string, search?: string }): Promise<[number, string, Record<string, string> | null]> { if (!this.isSSRable(loc.pathname)) { const [url] = this.#pageRouting.createRouter(loc) return [url.pagePath === '' ? 404 : 200, await this.getSPAIndexHtml(), null] }
const { url, status, head, scripts, body, data } = await this.renderPage(loc) const html = createHtml({ lang: url.locale, head: head, scripts: [ data ? { type: 'application/json', innerText: JSON.stringify(data, undefined, this.isDev ? 4 : 0), id: 'ssr-data' } : '', ...this.getHTMLScripts(), ...scripts ], body, minify: !this.isDev }) return [status, html, data] }
async getSPAIndexHtml() { const { defaultLocale } = this.config const customLoading = await this.renderLoadingPage() const html = createHtml({ lang: defaultLocale, scripts: [ ...this.getHTMLScripts() ], head: customLoading?.head || [], body: `<div id="__aleph">${customLoading?.body || ''}</div>`, minify: !this.isDev }) return html }
/** 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 project ready await this.ready
// clean old build if (existsDirSync(outputDir)) { for await (const entry of Deno.readDir(outputDir)) { await Deno.remove(path.join(outputDir, entry.name), { recursive: entry.isDirectory }) } }
// ensure output dir await ensureDir(distDir)
// optimizing await this.optimize()
// ssg await this.ssg()
// copy bundle dist await this.copyDist()
// 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: [/(^|\/)\.DS_Store$/] })) { 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`) }
/** inject HMR code */ injectHMRCode({ url, loader }: Module, content: string): string { const { __ALEPH_DEV_PORT: devPort } = globalThis as any const alephModuleLocalUrlPreifx = devPort ? `http_localhost_${devPort}` : `deno.land/x/aleph@v${VERSION}` const localUrl = this.fixImportUrl(url) const hmrImportPath = getRelativePath( path.dirname(localUrl), `/-/${alephModuleLocalUrlPreifx}/framework/core/hmr.js` ) const lines = [ `import { createHotContext } from ${JSON.stringify(hmrImportPath)};`, `import.meta.hot = createHotContext(${JSON.stringify(url)});` ] const reactRefresh = this.config.framework === 'react' && isModuleURL(url) if (reactRefresh) { const refreshImportPath = getRelativePath( path.dirname(localUrl), `/-/${alephModuleLocalUrlPreifx}/framework/react/refresh.js` ) lines.push(`import { RefreshRuntime, performReactRefresh } from ${JSON.stringify(refreshImportPath)};`) lines.push('') lines.push( `const prevRefreshReg = window.$RefreshReg$;`, `const prevRefreshSig = window.$RefreshSig$;`, `Object.assign(window, {`, ` $RefreshReg$: (type, id) => RefreshRuntime.register(type, ${JSON.stringify(url)} + " " + id),`, ` $RefreshSig$: RefreshRuntime.createSignatureFunctionForTransform`, `});`, ) } lines.push('') lines.push(content) lines.push('') if (reactRefresh) { lines.push( 'window.$RefreshReg$ = prevRefreshReg;', 'window.$RefreshSig$ = prevRefreshSig;', 'import.meta.hot.accept(performReactRefresh);' ) } else { if (loader === 'css') { lines.push('__applyCSS();') } lines.push('import.meta.hot.accept();') } return lines.join('\n') }
private getHTMLScripts() { const { baseUrl } = this.config
if (this.isDev) { const mainJS = this.getMainJS() return [ { src: util.cleanPath(`${baseUrl}/_aleph/main.${util.shortHash(computeHash(mainJS))}.js`), type: 'module' }, { src: util.cleanPath(`${baseUrl}/_aleph/-/deno.land/x/aleph/nomodule.js`), nomodule: true }, ] }
const mainJS = this.getMainJS(true) return ['polyfill', 'deps', 'shared'].map(name => { const mod = this.#modules.get(`/${name}.js`)! return { src: util.cleanPath(`${baseUrl}/_aleph/${name}.bundle.${util.shortHash(mod.hash)}.js`) } }).concat([ { src: util.cleanPath(`${baseUrl}/_aleph/main.bundle.${util.shortHash(computeHash(mainJS))}.js`) }, ]) }
/** load config from `aleph.config.(ts|js|json)` */ private async loadConfig() { const config: Record<string, any> = {}
for (const name of Array.from(['ts', 'js', 'json']).map(ext => 'aleph.config.' + ext)) { const p = path.join(this.workingDir, name) if (existsFileSync(p)) { log.info('Aleph server config loaded from', name) if (name.endsWith('.json')) { const conf = JSON.parse(await Deno.readTextFile(p)) if (util.isPlainObject(conf)) { Object.assign(config, conf) } } else { let { default: conf } = await import('file://' + p) if (util.isFunction(conf)) { conf = await conf() } if (util.isPlainObject(conf)) { Object.assign(config, conf) } } break } }
const { srcDir, ouputDir, baseUrl, buildTarget, sourceMap, defaultLocale, locales, ssr, env, plugins, postcss, } = config if (util.isNEString(srcDir)) { Object.assign(this.config, { srcDir: util.cleanPath(srcDir) }) } if (util.isNEString(ouputDir)) { Object.assign(this.config, { ouputDir: util.cleanPath(ouputDir) }) } if (util.isNEString(baseUrl)) { Object.assign(this.config, { baseUrl: util.cleanPath(encodeURI(baseUrl)) }) } if (/^es(20\d{2}|5)$/i.test(buildTarget)) { Object.assign(this.config, { buildTarget: buildTarget.toLowerCase() }) } if (typeof sourceMap === 'boolean') { Object.assign(this.config, { sourceMap }) } if (util.isNEString(defaultLocale)) { Object.assign(this.config, { defaultLocale }) } if (util.isArray(locales)) { Object.assign(this.config, { locales: Array.from(new Set(locales.filter(l => reLocaleID.test(l)))) }) locales.filter(l => !reLocaleID.test(l)).forEach(l => log.warn(`invalid locale ID '${l}'`)) } if (typeof ssr === 'boolean') { Object.assign(this.config, { ssr }) } else if (util.isPlainObject(ssr)) { const fallback = util.isNEString(ssr.fallback) ? util.ensureExt(ssr.fallback, '.html') : '404.html' const include = util.isArray(ssr.include) ? ssr.include.map(v => util.isNEString(v) ? new RegExp(v) : v).filter(v => v instanceof RegExp) : [] const exclude = util.isArray(ssr.exclude) ? ssr.exclude.map(v => util.isNEString(v) ? new RegExp(v) : v).filter(v => v instanceof RegExp) : [] const staticPaths = util.isArray(ssr.staticPaths) ? ssr.staticPaths.map(v => util.cleanPath(v.split('?')[0])) : [] Object.assign(this.config, { ssr: { fallback, include, exclude, staticPaths } }) } if (util.isPlainObject(env)) { Object.assign(this.config, { env }) } if (util.isNEArray(plugins)) { Object.assign(this.config, { plugins }) } if (util.isPlainObject(postcss) && util.isArray(postcss.plugins)) { Object.assign(this.config, { postcss }) } else { for (const name of Array.from(['ts', 'js', 'json']).map(ext => `postcss.config.${ext}`)) { const p = path.join(this.workingDir, name) if (existsFileSync(p)) { if (name.endsWith('.json')) { const postcss = JSON.parse(await Deno.readTextFile(p)) if (util.isPlainObject(postcss) && util.isArray(postcss.plugins)) { Object.assign(this.config, { postcss }) } } else { let { default: postcss } = await import('file://' + p) if (util.isFunction(postcss)) { postcss = await postcss() } if (util.isPlainObject(postcss) && util.isArray(postcss.plugins)) { Object.assign(this.config, { postcss }) } } break } } }
// todo: load ssr.config.ts
// load import maps for (const filename of Array.from(['import_map', 'import-map', 'importmap']).map(name => `${name}.json`)) { const importMapFile = path.join(this.workingDir, filename) if (existsFileSync(importMapFile)) { const importMap = JSON.parse(await Deno.readTextFile(importMapFile)) const imports: Record<string, string> = fixImportMap(importMap.imports) const scopes: Record<string, Record<string, string>> = {} if (util.isPlainObject(importMap.scopes)) { Object.entries(importMap.scopes).forEach(([key, imports]) => { scopes[key] = fixImportMap(imports) }) } Object.assign(this.importMap, { imports, scopes }) break } }
// update import map for alephjs dev env const { __ALEPH_DEV_PORT: devPort } = globalThis as any if (devPort) { const alias = `http://localhost:${devPort}/` const imports = { 'https://deno.land/x/aleph/': alias, [`https://deno.land/x/aleph@v${VERSION}/`]: alias, 'aleph': `${alias}mod.ts`, 'aleph/': alias, 'react': `https://esm.sh/react@${this.config.reactVersion}`, 'react-dom': `https://esm.sh/react-dom@${this.config.reactVersion}`, } Object.assign(this.importMap.imports, imports) }
// create page routing this.#pageRouting = new Routing([], this.config.baseUrl, this.config.defaultLocale, this.config.locales) }
/** initialize project */ private async init(reload: boolean) { const t = performance.now() const alephPkgUrl = getAlephPkgUrl() const { env, framework, plugins, ssr } = this.config const walkOptions = { includeDirs: false, exts: moduleExts, skip: [/^\./, /\.d\.ts$/i, /\.(test|spec|e2e)\.m?(j|t)sx?$/i] } const apiDir = path.join(this.srcDir, 'api') const pagesDir = path.join(this.srcDir, 'pages')
if (!(existsDirSync(pagesDir))) { log.fatal(`'pages' directory not found.`) }
if (Deno.version.deno < minDenoVersion) { log.fatal(`need Deno ${minDenoVersion}+, but got ${Deno.version.deno}`) }
const p = Deno.run({ cmd: [Deno.execPath(), 'info', '--unstable', '--json'], stdout: 'piped', stderr: 'null' }) const output = (new TextDecoder).decode(await p.output()) this.#denoCacheDir = JSON.parse(output).denoDir p.close() if (!existsDirSync(this.#denoCacheDir)) { log.fatal('invalid deno cache dir') }
if (reload) { this.#reloading = true if (existsDirSync(this.buildDir)) { await Deno.remove(this.buildDir, { recursive: true }) } await ensureDir(this.buildDir) }
await this.loadConfig()
// change current work dir to appDoot Deno.chdir(this.workingDir)
// inject env variables Object.entries(env).forEach(([key, value]) => Deno.env.set(key, value)) Deno.env.set('ALEPH_VERSION', VERSION) Deno.env.set('BUILD_MODE', this.mode)
// add react refresh helpers for ssr if (framework == 'react' && this.isDev) { Object.assign(globalThis, { $RefreshReg$: () => { }, $RefreshSig$: () => (type: any) => type, }) }
if (!this.isDev) { log.info('Building...') }
// check custom components for await (const { path: p, } of walk(this.srcDir, { ...walkOptions, maxDepth: 1 })) { const name = path.basename(p) switch (util.trimModuleExt(name)) { case 'app': case '404': case 'loading': await this.compile('/' + name) break } }
// create api routing if (existsDirSync(apiDir)) { for await (const { path: p } of walk(apiDir, walkOptions)) { const mod = await this.compile(util.cleanPath('/api/' + util.trimPrefix(p, apiDir))) this.#apiRouting.update(this.getRouteModule(mod)) } }
// create page routing for await (const { path: p } of walk(pagesDir, { ...walkOptions })) { const mod = await this.compile(util.cleanPath('/pages/' + util.trimPrefix(p, pagesDir))) this.#pageRouting.update(this.getRouteModule(mod)) }
// pre-compile framework modules await this.compile(`${alephPkgUrl}/framework/${framework}/bootstrap.ts`) if (this.isDev) { const mods = ['hmr.ts', 'nomodule.ts'] for (const mod of mods) { await this.compile(`${alephPkgUrl}/framework/core/${mod}`) } if (framework === 'react') { await this.compile(`${alephPkgUrl}/framework/react/refresh.ts`) } }
// compile and import framework renderer when ssr is enable if (ssr) { const rendererUrl = `${alephPkgUrl}/framework/${framework}/renderer.ts` await this.compile(rendererUrl) const { render } = await import('file://' + this.#modules.get(rendererUrl)!.jsFile) this.#renderer = { render } }
// apply server plugins for (const plugin of plugins) { if (plugin.type === 'server') { await plugin.onInit(this) } }
// reload end if (reload) { this.#reloading = false }
log.debug(`init project in ${Math.round(performance.now() - t)}ms`)
if (!this.isDev) { await this.bundle() } else { 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)) const validated = () => { // ignore `.aleph` and output directories if (url.startsWith('/.aleph/') || url.startsWith(this.config.outputDir)) { return false }
// is module if (isModuleURL(url)) { if (url.startsWith('/pages/') || url.startsWith('/api/')) { return true } switch (util.trimModuleExt(url)) { case '/404': case '/app': return true } }
// is dep for (const { deps } of this.#modules.values()) { if (deps.findIndex(dep => dep.url === url) > -1) { return true } }
// is loaded by plugin return this.config.plugins.findIndex(p => p.type === 'loader' && p.test.test(url)) > -1 }
if (validated()) { 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, hash }: Module) => { if (util.trimModuleExt(url) === '/app') { this.#renderCache.clear() } else if (url.startsWith('/pages/')) { this.#renderCache.delete(toPagePath(url)) this.#pageRouting.update(this.getRouteModule({ url, hash })) } else if (url.startsWith('/api/')) { this.#apiRouting.update(this.getRouteModule({ url, hash })) } } if (hmrable) { if (type === 'add') { this.#fsWatchListeners.forEach(e => e.emit('add', this.getRouteModule(mod))) } else { this.#fsWatchListeners.forEach(e => e.emit('modify-' + mod.url, mod.hash)) } } update(mod) this.checkCompilationSideEffect(url, (mod) => { update(mod) if (!hmrable && this.isHMRable(mod.url)) { this.#fsWatchListeners.forEach(w => w.emit('modify-' + mod.url, mod.hash)) } }) }).catch(err => { log.error(`compile(${url}):`, err.message) }) } else if (this.#modules.has(url)) { if (util.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) } } } }
/** returns the route module by given module. */ private getRouteModule({ url, hash }: Pick<Module, 'url' | 'hash'>): RouteModule { const deps = this.lookupDeps(url).filter(({ isData, isStyle, isDynamic }) => !!(isData || (isStyle && isDynamic))) return { url, hash, asyncDeps: deps.length > 0 ? deps : undefined } }
/** get main js. */ getMainJS(bundleMode = false): string { const alephPkgUrl = getAlephPkgUrl() const { baseUrl, defaultLocale, framework } = this.config const config: Record<string, any> = { baseUrl, defaultLocale, locales: [], routes: this.#pageRouting.routes, sharedModules: Array.from(this.#modules.values()).filter(({ url }) => { switch (util.trimModuleExt(url)) { case '/404': case '/app': return true default: return false } }).map(mod => this.getRouteModule(mod)), renderMode: this.config.ssr ? 'ssr' : 'spa' }
if (bundleMode) { return `var bootstrap=__ALEPH.pack["${alephPkgUrl}/framework/${framework}/bootstrap.ts"].default;bootstrap(${JSON.stringify(config)})` }
const prefix = alephPkgUrl.replace('https://', '').replace('http://localhost:', 'http_localhost_') return [ (this.config.framework === 'react' && this.isDev) && `import "./-/${prefix}/framework/react/refresh.js"`, `import bootstrap from "./-/${prefix}/framework/${framework}/bootstrap.js"`, `bootstrap(${JSON.stringify(config, undefined, this.isDev ? 4 : undefined)})` ].filter(Boolean).join('\n') }
/** fix import url */ private fixImportUrl(importUrl: string): string { const isRemote = util.isLikelyHttpURL(importUrl) if (isRemote) { const url = new URL(importUrl) let pathname = url.pathname let ok = [...moduleExts, 'css', 'pcss'].includes(path.extname(pathname).slice(1)) if (ok) { for (const plugin of this.config.plugins) { if (plugin.type === 'loader' && plugin.test.test(pathname)) { ok = true break } } } if (!ok) { pathname += '.js' } let search = Array.from(url.searchParams.entries()).map(([key, value]) => value ? `${key}=${value}` : key) if (search.length > 0) { pathname += '_' + search.join(',') } return [ '/-/', (url.protocol === 'http:' ? 'http_' : ''), url.hostname, (url.port ? '_' + url.port : ''), pathname ].join('') } return importUrl }
/** preprocess css with postcss plugins */ private async preprocessCSS(sourceCode: string) { let t: number | null = null if (this.#postcssReady === false) { t = performance.now() this.#postcssReady = Promise.all(this.config.postcss.plugins.map(async p => { let name: string | null = null if (util.isNEString(p)) { name = p } else if (Array.isArray(p) && util.isNEString(p[0])) { name = p[0] } if (name) { const { default: Plugin } = await import(`https://esm.sh/${name}?external=postcss@8.1.4&no-check`) this.#postcssPlugins[name] = Plugin } })) } if (this.#postcssReady instanceof Promise) { await this.#postcssReady this.#postcssReady = true } if (t !== null) { log.debug(`${this.config.postcss.plugins.length} postcss plugins loaded in ${Math.round(performance.now() - t)}ms`) } const pcss = (await postcss(this.config.postcss.plugins.map(p => { if (typeof p === 'string') { return this.#postcssPlugins[p] } else if (Array.isArray(p)) { const [plugin, options] = p if (util.isNEString(plugin)) { const _plugin = this.#postcssPlugins[plugin] if (util.isFunction(_plugin)) { let fn = _plugin as Function return fn(options) } else { return plugin } } else { plugin(options) } } else { return p } })).process(sourceCode).async()).content if (!this.isDev) { return this.#cleanCSS.minify(pcss).styles } else { return pcss } }
/** transpile code without types checking. */ private async transpile(sourceCode: string, options: TransformOptions) { let t: number | null = null if (this.#compilerReady === false) { t = performance.now() this.#compilerReady = initWasm(this.#denoCacheDir) } if (this.#compilerReady instanceof Promise) { await this.#compilerReady this.#compilerReady = true } if (t !== null) { log.debug(`init compiler wasm in ${Math.round(performance.now() - t)}ms`) }
return transpileSync(sourceCode, options) }
/** download and compile a moudle by given url, then cache on the disk. */ private async compile( url: string, options?: { sourceCode?: string, forceCompile?: boolean, bundleMode?: boolean, bundledModules?: string[] } ): Promise<Module> { const alephPkgUrl = getAlephPkgUrl() const isRemote = util.isLikelyHttpURL(url) const localUrl = this.fixImportUrl(url) const name = util.trimModuleExt(path.basename(localUrl)) const saveDir = path.join(this.buildDir, path.dirname(localUrl)) const metaFile = path.join(saveDir, `${name}.meta.json`) const { sourceCode, forceCompile, bundleMode, bundledModules } = options ?? {}
let mod: Module if (this.#modules.has(url)) { mod = this.#modules.get(url)! if (!forceCompile && !sourceCode && !(bundleMode && mod.bundlingFile === '')) { return mod } } else { mod = this.newModule(url) try { if (existsFileSync(metaFile)) { const { url, sourceHash, hash, deps } = JSON.parse(await Deno.readTextFile(metaFile)) if (url === url && util.isNEString(sourceHash) && util.isNEString(hash) && util.isArray(deps)) { mod.sourceHash = sourceHash mod.hash = hash mod.deps = deps } else { log.warn(`invalid metadata ('${name}.meta.json')`) Deno.remove(metaFile) } } } catch (e) { } }
let sourceContent = new Uint8Array() let changed = false let fsync = false let jsContent = '' let jsSourceMap: string | null = null
if (sourceCode) { sourceContent = (new TextEncoder).encode(sourceCode) const sourceHash = computeHash(sourceContent) if (mod.sourceHash === '' || mod.sourceHash !== sourceHash) { mod.sourceHash = sourceHash changed = true } } else if (isRemote) { const isLocalhost = /^https?:\/\/localhost(:\d+)?\//.test(url) if (['js', 'ts', 'jsx', 'tsx'].includes(mod.loader) && !isLocalhost) { try { sourceContent = await this.fetchDependency(url) const sourceHash = computeHash(sourceContent) if (mod.sourceHash === '' || mod.sourceHash !== sourceHash) { mod.sourceHash = sourceHash changed = true } } catch (err) { log.error(`dependency '${url}' not found`) mod.error = err return mod } } else { // todo: cache non-localhost file to local drive try { if (!isLocalhost) { log.info('Download', url) } const buffer = await fetch(url).then(resp => resp.arrayBuffer()) sourceContent = await Deno.readAll(new Deno.Buffer(buffer)) const sourceHash = computeHash(sourceContent) if (mod.sourceHash === '' || mod.sourceHash !== sourceHash) { mod.sourceHash = sourceHash changed = true } } catch (err) { throw new Error(`Download ${url}: ${err.message}`) } } } else { const filepath = path.join(this.srcDir, url) try { sourceContent = await Deno.readFile(filepath) const sourceHash = computeHash(sourceContent) if (mod.sourceHash === '' || mod.sourceHash !== sourceHash) { mod.sourceHash = sourceHash changed = true } } catch (err) { if (err instanceof Deno.errors.NotFound) { log.error(`module '${url}' not found`) mod.error = err return mod } throw err } }
// check previous compilation output if the source content doesn't changed. if (!changed) { if (bundleMode) { let bundlingFile = path.join(saveDir, `${name}.bundling.${util.shortHash(mod.sourceHash)}.js`) if (existsFileSync(bundlingFile)) { mod.bundlingFile = bundlingFile } else { changed = true } } else if (isRemote || mod.hash !== '') { let jsFile = path.join(saveDir, name + (isRemote ? '' : `.${util.shortHash(mod.hash)}`) + '.js') if (existsFileSync(jsFile)) { mod.jsFile = jsFile } else { changed = true } } }
// compile source code if (changed) { let sourceCode = (new TextDecoder).decode(sourceContent) let loader = mod.loader
for (const plugin of this.config.plugins) { if (plugin.type === 'loader' && plugin.test.test(url)) { const { code, format = 'js' } = await plugin.transform(sourceContent, url) sourceCode = code loader = format mod.loader = format break } }
if (loader === 'css') { const css = await this.preprocessCSS(sourceCode) sourceCode = [ `import { applyCSS } from "${alephPkgUrl}/framework/core/style.ts";`, `export default function __applyCSS() {`, ` applyCSS(${JSON.stringify(url)}, ${JSON.stringify(css)});`, `}`, bundleMode && `__ALEPH.pack[${JSON.stringify(url)}] = { default: __applyCSS };` ].filter(Boolean).join('\n') loader = 'js' // todo: css source map }
if (loader !== 'js' && loader !== 'ts' && loader !== 'jsx' && loader !== 'tsx') { log.error(`Unknown loader '${path.extname(url).slice(1)}'`) mod.error = new Error('unknown loader') return mod }
try { const t = performance.now() const swcOptions: SWCOptions = { target: 'es2020', sourceType: loader, sourceMap: this.isDev, } const { code, map, deps, inlineStyles } = await this.transpile(sourceCode, { url, bundleMode, bundledModules, swcOptions, importMap: this.importMap, reactVersion: this.config.reactVersion, isDev: this.isDev, })
fsync = true jsContent = code if (map) { jsSourceMap = map }
// resolve inline-style await Promise.all(Object.entries(inlineStyles).map(async ([key, style]) => { let type = style.type let tpl = style.quasis.reduce((css, quais, i, a) => { css += quais if (i < a.length - 1) { css += `%%aleph-inline-style-expr-${i}%%` } return css }, '') .replace(/\:\s*%%aleph-inline-style-expr-(\d+)%%/g, (_, id) => `: var(--aleph-inline-style-expr-${id})`) .replace(/%%aleph-inline-style-expr-(\d+)%%/g, (_, id) => `/*%%aleph-inline-style-expr-${id}%%*/`) if (type !== 'css') { for (const plugin of this.config.plugins) { if (plugin.type === 'loader' && plugin.test.test(`${key}.${type}`)) { const { code, format } = await plugin.transform((new TextEncoder).encode(tpl), url) if (format === 'css') { tpl = code type = 'css' break } } } } if (type === 'css') { tpl = await this.preprocessCSS(tpl) tpl = tpl.replace( /\: var\(--aleph-inline-style-expr-(\d+)\)/g, (_, id) => ': ${' + style.exprs[parseInt(id)] + '}' ).replace( /\/\*%%aleph-inline-style-expr-(\d+)%%\*\//g, (_, id) => '${' + style.exprs[parseInt(id)] + '}' ) jsContent = jsContent.replace(`"%%${key}-placeholder%%"`, '`' + tpl + '`') } }))
mod.deps = deps.map(({ specifier, isDynamic }) => { const dep: DependencyDescriptor = { url: specifier, hash: '' } if (isDynamic) { dep.isDynamic = true } if (dep.url.startsWith('#useDeno-')) { dep.isData = true dep.hash = util.trimPrefix(dep.url, '#useDeno-') if (!this.config.ssr) { log.warn(`use 'useDeno' hook in SPA mode`) } } else if (dep.url.startsWith('#inline-style-')) { dep.isStyle = true dep.hash = util.trimPrefix(dep.url, '#inline-style-') } return dep })
log.debug(`compile '${url}' in ${Math.round(performance.now() - t)}ms ${bundleMode ? '(bundle mode)' : ''}`) } catch (e) { mod.error = new Error('transpile error') return mod } }
// compile deps const deps = mod.deps.filter(({ url }) => { return !url.startsWith('#') && (!bundleMode || (!util.isLikelyHttpURL(url) && !bundledModules?.includes(url))) }) for (const dep of deps) { const depMod = await this.compile(dep.url, { bundleMode, bundledModules }) if (depMod.loader === 'css' && !dep.isStyle) { dep.isStyle = true } if (dep.hash === '' || dep.hash !== depMod.hash || bundleMode) { dep.hash = depMod.hash if (!util.isLikelyHttpURL(dep.url)) { const relativePathname = getRelativePath( path.dirname(url), util.trimModuleExt(dep.url) ) if (!changed && jsContent === '') { if (!bundleMode) { jsContent = await Deno.readTextFile(mod.jsFile) } else { jsContent = await Deno.readTextFile(mod.bundlingFile) } } const newContent = jsContent.replace(reHashResolve, (s, key, spaces, ql, importPath, qr) => { const importPathname = importPath.replace(reHashJs, '') if (importPathname === relativePathname || importPathname === relativePathname + '.bundling') { if (!bundleMode) { return `${key}${spaces}${ql}${relativePathname}.${util.shortHash(dep.hash)}.js${qr}` } else { return `${key}${spaces}${ql}${relativePathname}.bundling.${util.shortHash(depMod.sourceHash)}.js${qr}` } } return s }) if (newContent !== jsContent) { jsContent = newContent if (!fsync) { fsync = true } } } } }
if (fsync) { if (!bundleMode) { mod.hash = computeHash(jsContent + buildChecksum) mod.jsFile = path.join(saveDir, name + (isRemote ? '' : `.${util.shortHash(mod.hash)}`) + '.js') await cleanupCompilation(mod.jsFile) await Promise.all([ ensureTextFile(mod.jsFile, jsContent + (jsSourceMap ? '//# sourceMappingURL=' + path.basename(mod.jsFile) + '.map' : '')), jsSourceMap ? ensureTextFile(mod.jsFile + '.map', jsSourceMap) : Promise.resolve(), ensureTextFile(metaFile, JSON.stringify({ url, sourceHash: mod.sourceHash, hash: mod.hash, deps: mod.deps, }, undefined, 4)), ]) } else { mod.bundlingFile = path.join(saveDir, `${name}.bundling.${util.shortHash(mod.sourceHash)}.js`) await cleanupCompilation(mod.bundlingFile) await Promise.all([ await ensureTextFile(mod.bundlingFile, jsContent), await ensureTextFile(metaFile, JSON.stringify({ url, sourceHash: mod.sourceHash, hash: mod.hash || mod.sourceHash, deps: mod.deps, }, undefined, 4)) ]) } }
return mod }
/** check compilation side-effect caused by dependency graph. */ private async checkCompilationSideEffect(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) { if (dep.hash !== "" && dep.hash !== hash) { log.debug('compilation side-effect:', mod.url, colors.dim('<-'), url) dep.hash = hash const relativePath = getRelativePath( path.dirname(mod.url), util.trimModuleExt(dep.url) ) let jsContent = await Deno.readTextFile(mod.jsFile) jsContent = jsContent.replace(reHashResolve, (s, key, spaces, ql, importPath, qr) => { if (importPath.replace(reHashJs, '') === relativePath) { return `${key}${spaces}${ql}${relativePath}.${util.shortHash(dep.hash)}.js${qr}` } return s }) let sourceMap: string | null = null if (existsFileSync(mod.jsFile + '.map')) { sourceMap = Deno.readTextFileSync(mod.jsFile + '.map') } mod.hash = computeHash(jsContent + buildChecksum) mod.jsFile = `${mod.jsFile.replace(reHashJs, '')}.${util.shortHash(mod.hash)}.js` jsContent = jsContent.split('\n').map(line => { if (line.startsWith('//# sourceMappingURL=')) { return `//# sourceMappingURL=${path.basename(mod.jsFile).replace(reHashJs, '')}.${util.shortHash(mod.hash)}.js.map` } return line }).join('\n') await cleanupCompilation(mod.jsFile) await Promise.all([ ensureTextFile(mod.jsFile.replace(reHashJs, '') + '.meta.json', JSON.stringify({ url: mod.url, sourceHash: mod.sourceHash, hash: mod.hash, deps: mod.deps, }, undefined, 4)), ensureTextFile(mod.jsFile, jsContent), sourceMap ? ensureTextFile(mod.jsFile + '.map', sourceMap) : Promise.resolve(), ]) callback && callback(mod) this.checkCompilationSideEffect(mod.url, callback) } break } } } }
/** fetch dependency content, use deno builtin cache system */ private async fetchDependency(url: string): Promise<Uint8Array> { 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 cacheDir = path.join(this.#denoCacheDir, 'deps', util.trimSuffix(protocol, ':'), hostname + (port ? '_PORT' + port : '')) const cacheFilename = path.join(cacheDir, (new Sha256()).update(pathname + search).hex())
if (!reload && existsFileSync(cacheFilename)) { return await Deno.readFile(cacheFilename) }
try { log.info('Download', url) const p = Deno.run({ cmd: [Deno.execPath(), 'cache', reload ? '--reload' : '', u.toString()], stdout: 'null', stderr: 'null' }) await p.status() p.close() if (existsFileSync(cacheFilename)) { return await Deno.readFile(cacheFilename) } } catch (e) { log.warn(e) }
// download dep when deno cache failed log.info('Force download from', url) const buffer = await fetch(u.toString()).then(resp => resp.arrayBuffer()) return await Deno.readAll(new Deno.Buffer(buffer)) }
/** bundle modules for production. */ private async bundle() { const alephPkgUrl = getAlephPkgUrl() const refCounter = new Map<string, number>() const lookup = (url: string) => { if (this.#modules.has(url)) { const { deps } = this.#modules.get(url)! new Set(deps.map(({ url }) => url)).forEach(url => { if (refCounter.has(url)) { refCounter.set(url, refCounter.get(url)! + 1) } else { refCounter.set(url, 1) } }) } } const mods = Array.from(this.#modules.values()) const appModule = mods.find(({ url }) => util.trimModuleExt(url) == '/app') const e404Module = mods.find(({ url }) => util.trimModuleExt(url) == '/404') const pageModules: Module[] = []
// add framework bootstrap refCounter.set(`${alephPkgUrl}/framework/${this.config.framework}/bootstrap.ts`, 1) if (appModule) { await this.compile(appModule.url, { bundleMode: true }) lookup(appModule.url) } if (e404Module) { await this.compile(e404Module.url, { bundleMode: true }) lookup(e404Module.url) } this.#pageRouting.lookup(routes => routes.forEach(({ module: { url } }) => { const mod = this.getModule(url) if (mod) { lookup(url) mod.deps.forEach(dep => { if (dep.isStyle) { lookup(dep.url) } }) pageModules.push(mod) } }))
log.debug(refCounter)
const remoteDeps: string[] = [] const localSharedDeps: string[] = [] Array.from(refCounter.entries()).forEach(([url, count]) => { if (util.isLikelyHttpURL(url)) { remoteDeps.push(url) } else if (!url.startsWith('#') && !url.startsWith('/pages/') && count > 1) { localSharedDeps.push(url) } }) if (appModule) { localSharedDeps.push(appModule.url) } if (e404Module) { localSharedDeps.push(e404Module.url) }
log.info('- Bundling') await this.createPolyfillBundle() await this.createChunkBundle('deps', remoteDeps) if (localSharedDeps.length > 0) { await this.createChunkBundle('shared', localSharedDeps) }
// create and copy polyfill // bundle and copy page moudles await Promise.all(pageModules.map(async mod => this.createPageBundle(mod, localSharedDeps))) }
/** create polyfill bundle. */ private async createPolyfillBundle() { const alephPkgUrl = getAlephPkgUrl() const { buildTarget } = this.config const hash = computeHash(AlephRuntimeCode + buildTarget + buildChecksum + Deno.version.deno) const polyfillFile = path.join(this.buildDir, `polyfill.bundle.${util.shortHash(hash)}.js`) const polyfillMod = this.newModule('/polyfill.js') polyfillMod.hash = polyfillMod.sourceHash = hash if (!existsFileSync(polyfillFile)) { const rawPolyfillFile = `${alephPkgUrl}/compiler/polyfills/${buildTarget}/polyfill.js` await this.runDenoBundle(rawPolyfillFile, polyfillFile, AlephRuntimeCode, true) } log.info(` {} polyfill (${buildTarget.toUpperCase()}) ${colors.dim('• ' + util.formatBytes(Deno.statSync(polyfillFile).size))}`) }
/** create chunk bundle. */ private async createChunkBundle(name: string, deps: string[]) { const bundlingCode = deps.map((url, i) => { const mod = this.#modules.get(url) if (mod) { const importUrl = util.isLikelyHttpURL(mod.url) ? mod.jsFile : mod.bundlingFile return importUrl ? [ `import * as ${name}_mod_${i} from ${JSON.stringify('file://' + importUrl)}`, `__ALEPH.pack[${JSON.stringify(url)}] = ${name}_mod_${i}` ] : [] } }).flat().join('\n') const hash = computeHash(bundlingCode + buildChecksum + Deno.version.deno) const bundleEntryFile = path.join(this.buildDir, `${name}.bundle.entry.js`) const bundleFile = path.join(this.buildDir, `${name}.bundle.${util.shortHash(hash)}.js`) const mod = this.newModule(`/${name}.js`) mod.hash = mod.sourceHash = hash if (!existsFileSync(bundleFile)) { await Deno.writeTextFile(bundleEntryFile, bundlingCode) await this.runDenoBundle(bundleEntryFile, bundleFile) lazyRemove(bundleEntryFile) } log.info(` {} ${name} ${colors.dim('• ' + util.formatBytes(Deno.statSync(bundleFile).size))}`) }
/** create page bundle. */ private async createPageBundle(mod: Module, bundledModules: string[]) { const { bundlingFile, hash } = await this.compile(mod.url, { bundleMode: true, bundledModules }) const pathname = util.trimSuffix(bundlingFile.replace(reHashJs, ''), '.bundling') const bundleEntryFile = pathname + `.bundle.entry.js` const bundleFile = pathname + `.bundle.${util.shortHash(hash)}.js`
if (!existsFileSync(bundleFile)) { const bundleEntryCode = [ `import * as mod from ${JSON.stringify('file://' + bundlingFile)}`, `__ALEPH.pack[${JSON.stringify(mod.url)}] = mod` ].join('\n') await Deno.writeTextFile(bundleEntryFile, bundleEntryCode) await this.runDenoBundle(bundleEntryFile, bundleFile) lazyRemove(bundleEntryFile) } }
/** run deno bundle and compess the output with terser. */ private async runDenoBundle(bundleEntryFile: string, bundleFile: string, header = '', reload = false) { const p = Deno.run({ cmd: [Deno.execPath(), 'bundle', '--no-check', reload ? '--reload' : '', bundleEntryFile, bundleFile].filter(Boolean), stdout: 'null', stderr: 'piped' }) const data = await p.stderrOutput() p.close() if (!existsFileSync(bundleFile)) { const msg = (new TextDecoder).decode(data).replaceAll('file://', '').replaceAll(this.buildDir, '/aleph.js') await Deno.stderr.write((new TextEncoder).encode(msg)) Deno.exit(1) }
// transpile bundle code to `buildTarget` let { code } = await this.transpile(await Deno.readTextFile(bundleFile), { url: '/bundle.js', swcOptions: { target: this.config.buildTarget }, })
// workaround for https://github.com/denoland/deno/issues/9212 if (Deno.version.deno === '1.7.0' && bundleEntryFile.endsWith('deps.bundle.entry.js')) { code = code.replace(' _ = l.baseState, ', ' var _ = l.baseState, ') }
// IIFEify code = [ '(() => {', header, code, '})()' ].join('\n')
// minify code const ret = await minify(code, { compress: true, mangle: true, ecma: parseInt(util.trimPrefix(this.config.buildTarget, 'es')) as ECMA, sourceMap: false }) if (ret.code) { code = ret.code }
await cleanupCompilation(bundleFile) await Deno.writeTextFile(bundleFile, code) }
private async copyDist() { const pageModules: Module[] = [] this.#pageRouting.lookup(routes => routes.forEach(({ module: { url } }) => { const mod = this.getModule(url) if (mod) { pageModules.push(mod) } })) await Promise.all([ (async () => { const mainJS = this.getMainJS(true) const filename = `main.bundle.${util.shortHash(computeHash(mainJS))}.js` const saveAs = path.join(this.outputDir, '_aleph', filename) await Deno.writeTextFile(saveAs, mainJS) })(), ...['deps', 'shared', 'polyfill'].map(async name => { const mod = this.#modules.get(`/${name}.js`) if (mod) { const { hash } = mod const bundleFile = path.join(this.buildDir, `${name}.bundle.${util.shortHash(hash)}.js`) const saveAs = path.join(this.outputDir, '_aleph', `${name}.bundle.${util.shortHash(hash)}.js`) await Deno.copyFile(bundleFile, saveAs) } }), ...pageModules.map(async mod => { const { bundlingFile, hash } = mod const pathname = util.trimSuffix(bundlingFile.replace(reHashJs, ''), '.bundling') const bundleFile = pathname + `.bundle.${util.shortHash(hash)}.js` const saveAs = path.join(this.outputDir, `/_aleph/`, util.trimPrefix(pathname, this.buildDir) + `.bundle.${util.shortHash(hash)}.js`) await ensureDir(path.dirname(saveAs)) await Deno.copyFile(bundleFile, saveAs) }) ]) }
/** optimize for production. */ private async optimize() { // todo: optimize }
/** render all pages in routing. */ private async ssg() { const { ssr } = this.config const outputDir = this.outputDir
if (!ssr) { const html = await this.getSPAIndexHtml() await ensureTextFile(path.join(outputDir, 'index.html'), html) await ensureTextFile(path.join(outputDir, '404.html'), html) return }
log.info(colors.bold('- Pages (SSG)')) 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 [status, html, data] = await this.getPageHtml({ pathname }) if (status == 200) { const htmlFile = path.join(outputDir, pathname, 'index.html') await ensureTextFile(htmlFile, 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 if (status == 404) { log.info(' ○', colors.dim(pathname), colors.red('Page not found')) } else if (status == 500) { log.info(' ○', colors.dim(pathname), colors.red('Error 500')) } } }))
// write 404 page const { url, head, scripts, body, data } = await this.render404Page() const e404PageHtml = createHtml({ lang: url.locale, head: head, scripts: [ data ? { type: 'application/json', innerText: JSON.stringify(data, undefined, this.isDev ? 4 : 0), id: 'ssr-data' } : '', ...this.getHTMLScripts(), ...scripts ], body, minify: !this.isDev }) await ensureTextFile(path.join(outputDir, '404.html'), e404PageHtml) if (data) { const dataFile = path.join(outputDir, '_aleph/data/_404.json') await ensureTextFile(dataFile, JSON.stringify(data)) } }
/** render page base the given location. */ private async renderPage(loc: { pathname: string, search?: string }) { const start = performance.now() const [url, pageModuleChain] = this.#pageRouting.createRouter(loc) const key = [url.pathname, url.query.toString()].filter(Boolean).join('?') if (url.pagePath !== '') { if (this.#renderCache.has(url.pagePath)) { const cache = this.#renderCache.get(url.pagePath)! if (cache.has(key)) { return cache.get(key)! } } else { this.#renderCache.set(url.pagePath, new Map()) } } const ret: RenderResult = { url, status: url.pagePath === '' ? 404 : 200, head: [], scripts: [], body: '<div id="__aleph"></div>', data: null, } if (ret.status === 404) { if (this.isDev) { log.warn(`${colors.bold('404')} '${url.pathname}' not found`) } return await this.render404Page(url) } try { const appModule = Array.from(this.#modules.values()).find(({ url }) => util.trimModuleExt(url) == '/app') const { default: App } = appModule ? await import('file://' + appModule.jsFile) : {} as any const imports = pageModuleChain.map(async ({ url }) => { const mod = this.#modules.get(url)! const { default: Component } = await import('file://' + mod.jsFile) return { url, Component } }) const { head, body, data, scripts } = await this.#renderer.render( url, App, undefined, await Promise.all(imports), [ appModule ? this.lookupDeps(appModule.url).filter(dep => !!dep.isStyle) : [], ...pageModuleChain.map(({ url }) => this.lookupDeps(url).filter(dep => !!dep.isStyle)).flat() ].flat() ) ret.head = head ret.scripts = await Promise.all(scripts.map(async (script: Record<string, any>) => { if (script.innerText && !this.isDev) { return { ...script, innerText: (await minify(script.innerText)).code } } return script })) ret.body = `<div id="__aleph">${body}</div>` ret.data = data this.#renderCache.get(url.pagePath)!.set(key, ret) if (this.isDev) { log.info(`render '${url.pathname}' in ${Math.round(performance.now() - start)}ms`) } } catch (err) { ret.status = 500 ret.head = ['<title>Error 500 - Aleph.js</title>'] ret.body = `<div id="__aleph"><pre>${colors.stripColor(err.stack)}</pre></div>` log.error(err) } return ret }
/** check a page whether is ssrable. */ 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 }
/** render custom 404 page. */ private async render404Page(url: RouterURL = { locale: this.config.defaultLocale, pagePath: '', pathname: '/', params: {}, query: new URLSearchParams() }) { const ret: RenderResult = { url, status: 404, head: [], scripts: [], body: '<div id="__aleph"></div>', data: null } try { const e404Module = Array.from(this.#modules.keys()) .filter(url => util.trimModuleExt(url) == '/404') .map(url => this.#modules.get(url))[0] const { default: E404 } = e404Module ? await import('file://' + e404Module.jsFile) : {} as any const { head, body, data, scripts } = await this.#renderer.render( url, undefined, E404, [], e404Module ? this.lookupDeps(e404Module.url).filter(dep => !!dep.isStyle) : [] ) ret.head = head ret.scripts = await Promise.all(scripts.map(async (script: Record<string, any>) => { if (script.innerText && !this.isDev) { return { ...script, innerText: (await minify(script.innerText)).code } } return script })) ret.body = `<div id="__aleph">${body}</div>` ret.data = data } catch (err) { ret.status = 500 ret.head = ['<title>Error 500 - Aleph.js</title>'] ret.body = `<div id="__aleph"><pre>${colors.stripColor(err.stack)}</pre></div>` log.error(err) } return ret }
/** render custom loading page for SPA mode. */ private async renderLoadingPage() { const loadingModule = Array.from(this.#modules.values()).find(({ url }) => util.trimModuleExt(url) === '/loading') if (loadingModule) { const { default: Loading } = await import('file://' + loadingModule.jsFile) const router = { locale: this.config.defaultLocale, pagePath: '', pathname: '/', params: {}, query: new URLSearchParams() } const { head, body } = await this.#renderer.render( router, undefined, undefined, [{ url: loadingModule.url, Component: Loading }], this.lookupDeps(loadingModule.url).filter(dep => !!dep.isStyle) ) return { head, body: `<div id="__aleph">${body}</div>` } as Pick<RenderResult, 'head' | 'body'> } return null }
/** lookup deps recurively. */ private lookupDeps(url: string, __deps: DependencyDescriptor[] = [], __tracing: Set<string> = new Set()) { const mod = this.getModule(url) if (!mod) { return __deps } if (__tracing.has(url)) { return __deps } __tracing.add(url) __deps.push(...mod.deps.filter(({ url }) => __deps.findIndex(i => i.url === url) === -1)) mod.deps.forEach(({ url }) => { if (isModuleURL(url) && !util.isLikelyHttpURL(url)) { this.lookupDeps(url, __deps, __tracing) } }) return __deps }}