diff --git a/cli/dev.ts b/cli/dev.ts index 90ff5308f..1bf5f4a8c 100644 --- a/cli/dev.ts +++ b/cli/dev.ts @@ -1,5 +1,5 @@ import { Application, serve } from '../server/mod.ts' -import { parsePortNumber } from '../server/util.ts' +import { getOptionValue, parsePortNumber } from '../server/util.ts' export const helpMessage = ` Usage: @@ -16,7 +16,7 @@ Options: ` export default async function (workingDir: string, options: Record) { - const port = parsePortNumber(String(options.p || options.port || '8080')) const app = new Application(workingDir, 'development', Boolean(options.r || options.reload)) - serve('localhost', port, app) + const port = parsePortNumber(getOptionValue(options, ['p', 'port'], '8080')) + await serve({ app, port, hostname: 'localhost' }) } diff --git a/cli/start.ts b/cli/start.ts index 1d55e8f31..64d07755f 100644 --- a/cli/start.ts +++ b/cli/start.ts @@ -1,5 +1,7 @@ +import type { ServeOptions } from '../server/mod.ts' import { Application, serve } from '../server/mod.ts' -import { parsePortNumber } from '../server/util.ts' +import { getOptionValue, parsePortNumber } from '../server/util.ts' +import log from '../shared/log.ts' export const helpMessage = ` Usage: @@ -9,16 +11,29 @@ Usage: if the is empty, the current directory will be used. Options: - -hn, --hostname The address at which the server is to be started -p, --port A port number to start the aleph.js app, default is 8080 + --hostname The address at which the server is to be started + --cert The server certificate file + --key The server public key file -L, --log-level Set log level [possible values: debug, info] -r, --reload Reload source code cache -h, --help Prints help message ` export default async function (workingDir: string, options: Record) { - const host = String(options.hn || options.hostname || 'localhost') - const port = parsePortNumber(String(options.p || options.port || '8080')) const app = new Application(workingDir, 'production', Boolean(options.r || options.reload)) - serve(host, port, app) + const port = parsePortNumber(getOptionValue(options, ['p', 'port'], '8080')) + const hostname = getOptionValue(options, ['hostname'], 'localhost') + const certFile = getOptionValue(options, ['cert']) + const keyFile = getOptionValue(options, ['key']) + const opts: ServeOptions = { app, port, hostname } + if (certFile && keyFile) { + opts.certFile = certFile + opts.keyFile = keyFile + } else if (certFile) { + log.fatal('missing `--key` option') + } else if (keyFile) { + log.fatal('missing `--cert` option') + } + await serve(opts) } diff --git a/deps.ts b/deps.ts index 121667496..76cd6c212 100644 --- a/deps.ts +++ b/deps.ts @@ -7,7 +7,7 @@ export { ensureDir } from 'https://deno.land/std@0.85.0/fs/ensure_dir.ts' export { walk } from 'https://deno.land/std@0.85.0/fs/walk.ts' export { Sha1 } from 'https://deno.land/std@0.85.0/hash/sha1.ts' export { Sha256 } from 'https://deno.land/std@0.85.0/hash/sha256.ts' -export { listenAndServe, serve } from 'https://deno.land/std@0.85.0/http/server.ts' +export { listenAndServe, serve, serveTLS } from 'https://deno.land/std@0.85.0/http/server.ts' export * as bufio from 'https://deno.land/std@0.85.0/io/bufio.ts' export * as path from 'https://deno.land/std@0.85.0/path/mod.ts' export * as ws from 'https://deno.land/std@0.85.0/ws/mod.ts' @@ -15,7 +15,7 @@ export * as ws from 'https://deno.land/std@0.85.0/ws/mod.ts' export * as brotli from 'https://deno.land/x/brotli@v0.1.4/mod.ts' export { gzipDecode, gzipEncode } from 'https://deno.land/x/wasm_gzip@v1.0.0/mod.ts' // esm.sh -export { default as CleanCSS } from 'https://esm.sh/clean-css@4.2.3?no-check' +export { default as CleanCSS } from 'https://esm.sh/clean-css@5.0.1?no-check' export { default as postcss } from 'https://esm.sh/postcss@8.2.4' export type { AcceptedPlugin } from 'https://esm.sh/postcss@8.2.4' export { minify } from 'https://esm.sh/terser@5.5.1' diff --git a/framework/core/routing.ts b/framework/core/routing.ts index 8668f8ba3..591cacc4e 100644 --- a/framework/core/routing.ts +++ b/framework/core/routing.ts @@ -17,22 +17,33 @@ export type RouteModule = { readonly asyncDeps?: DependencyDescriptor[] } +export type RoutingOptions = { + routes?: Route[] + rewrites?: Record + baseUrl?: string + defaultLocale?: string + locales?: string[] +} + export class Routing { - private _routes: Route[] private _baseUrl: string private _defaultLocale: string private _locales: string[] + private _routes: Route[] + private _rewrites: Record - constructor( - routes: Route[] = [], - baseUrl: string = '/', - defaultLocale: string = 'en', - locales: string[] = [] - ) { - this._routes = routes + constructor({ + baseUrl = '/', + defaultLocale = 'en', + locales = [], + routes = [], + rewrites = {}, + }: RoutingOptions) { this._baseUrl = baseUrl this._defaultLocale = defaultLocale this._locales = locales + this._routes = routes + this._rewrites = rewrites } get baseUrl() { @@ -107,10 +118,10 @@ export class Routing { createRouter(location?: { pathname: string, search?: string }): [RouterURL, RouteModule[]] { const loc = location || (window as any).location || { pathname: '/' } - const query = new URLSearchParams(loc.search) + const url = rewriteURL(loc.pathname + (loc.search || ''), this._baseUrl, this._rewrites) let locale = this._defaultLocale - let pathname = util.cleanPath(util.trimPrefix(loc.pathname, this._baseUrl)) + let pathname = decodeURI(url.pathname) let pagePath = '' let params: Record = {} let chain: RouteModule[] = [] @@ -139,7 +150,7 @@ export class Routing { } }, true) - return [{ locale, pathname, pagePath, params, query }, chain] + return [{ locale, pathname, pagePath, params, query: url.searchParams }, chain] } lookup(callback: (path: Route[]) => Boolean | void) { @@ -149,20 +160,20 @@ export class Routing { private _lookup( callback: (path: Route[]) => Boolean | void, skipNestIndex = false, - __tracing: Route[] = [], - __routes = this._routes + _tracing: Route[] = [], + _routes = this._routes ) { - for (const route of __routes) { - if (skipNestIndex && __tracing.length > 0 && route.path === '/') { + for (const route of _routes) { + if (skipNestIndex && _tracing.length > 0 && route.path === '/') { continue } - if (callback([...__tracing, route]) === false) { + if (callback([..._tracing, route]) === false) { return false } } - for (const route of __routes) { + for (const route of _routes) { if (route.path !== '/' && route.children?.length) { - if (this._lookup(callback, skipNestIndex, [...__tracing, route], route.children) === false) { + if (this._lookup(callback, skipNestIndex, [..._tracing, route], route.children) === false) { return false } } @@ -201,6 +212,33 @@ function matchPath(routePath: string, locPath: string): [Record, return [params, true] } +/** `rewriteURL` returns a rewrited URL */ +export function rewriteURL(reqUrl: string, baseUrl: string, rewrites: Record): URL { + const url = new URL('http://localhost' + reqUrl) + if (baseUrl !== '/') { + url.pathname = util.trimPrefix(decodeURI(url.pathname), baseUrl) + } + for (const path in rewrites) { + const to = rewrites[path] + const [params, ok] = matchPath(path, decodeURI(url.pathname)) + if (ok) { + const { searchParams } = url + url.href = 'http://localhost' + util.cleanPath(to.replace(/:(.+)(\/|&|$)/g, (s, k, e) => { + if (k in params) { + return params[k] + e + } + return s + })) + for (const [key, value] of url.searchParams.entries()) { + searchParams.append(key, value) + } + url.search = searchParams.toString() + break + } + } + return url +} + export async function redirect(url: string, replace?: boolean) { const { location, history } = window as any @@ -208,7 +246,7 @@ export async function redirect(url: string, replace?: boolean) { return } - if (isHttpUrl(url)) { + if (util.isLikelyHttpURL(url) || url.startsWith('file://') || url.startsWith('mailto:')) { location.href = url return } @@ -222,15 +260,6 @@ export async function redirect(url: string, replace?: boolean) { events.emit('popstate', { type: 'popstate', resetScroll: true }) } -export function isHttpUrl(url: string) { - try { - const { protocol } = new URL(url) - return protocol === 'https:' || protocol === 'http:' - } catch (error) { - return false - } -} - export function isModuleURL(url: string) { for (const ext of moduleExts) { if (url.endsWith('.' + ext)) { diff --git a/framework/core/routing_test.ts b/framework/core/routing_test.ts index 814165916..bc45a5d44 100644 --- a/framework/core/routing_test.ts +++ b/framework/core/routing_test.ts @@ -2,8 +2,15 @@ import { assertEquals } from 'https://deno.land/std@0.85.0/testing/asserts.ts' import { Routing } from './routing.ts' Deno.test(`routing`, () => { - const routing = new Routing([], '/', 'en', ['en', 'zh-CN']) + const routing = new Routing({ + locales: ['en', 'zh-CN'], + rewrites: { + '/Hello World': '/hello-world', + '/你好世界': '/zh-CN/hello-world', + } + }) routing.update({ url: '/pages/index.tsx', hash: '' }) + routing.update({ url: '/pages/hello-world.tsx', hash: '' }) routing.update({ url: '/pages/blog/index.tsx', hash: '' }) routing.update({ url: '/pages/blog/[slug].tsx', hash: '' }) routing.update({ url: '/pages/user/index.tsx', hash: '' }) @@ -18,6 +25,7 @@ Deno.test(`routing`, () => { assertEquals(routing.paths, [ '/', + '/hello-world', '/blog', '/user', '/docs', @@ -44,6 +52,22 @@ Deno.test(`routing`, () => { assertEquals(chain, [{ url: '/pages/index.tsx', hash: 'hsidfshy3yhfya49848' }]) } + { + const [router, chain] = routing.createRouter({ pathname: '/Hello World' }) + assertEquals(router.locale, 'en') + assertEquals(router.pathname, '/hello-world') + assertEquals(router.pagePath, '/hello-world') + assertEquals(chain, [{ url: '/pages/hello-world.tsx', hash: '' }]) + } + + { + const [router, chain] = routing.createRouter({ pathname: '/你好世界' }) + assertEquals(router.locale, 'zh-CN') + assertEquals(router.pathname, '/hello-world') + assertEquals(router.pagePath, '/hello-world') + assertEquals(chain, [{ url: '/pages/hello-world.tsx', hash: '' }]) + } + { const [router, chain] = routing.createRouter({ pathname: '/blog' }) assertEquals(router.locale, 'en') diff --git a/framework/react/anchor.ts b/framework/react/anchor.ts index 4887857b3..60a63e931 100644 --- a/framework/react/anchor.ts +++ b/framework/react/anchor.ts @@ -2,7 +2,7 @@ import type { AnchorHTMLAttributes, CSSProperties, MouseEvent, PropsWithChildren import { createElement, useCallback, useEffect, useMemo } from 'https://esm.sh/react' import util from '../../shared/util.ts' import events from '../core/events.ts' -import { isHttpUrl, redirect } from '../core/routing.ts' +import { redirect } from '../core/routing.ts' import { useRouter } from './hooks.ts' const prefetchedPages = new Set() @@ -39,7 +39,7 @@ export default function Anchor(props: AnchorProps) { if (!util.isNEString(propHref)) { return '' } - if (isHttpUrl(propHref)) { + if (util.isLikelyHttpURL(propHref)) { return propHref } let [pathname, search] = util.splitBy(propHref, '?') @@ -72,7 +72,7 @@ export default function Anchor(props: AnchorProps) { return undefined }, [href, propAriaCurrent]) const prefetch = useCallback(() => { - if (href && !isHttpUrl(href) && href !== currentHref && !prefetchedPages.has(href)) { + if (href && !util.isLikelyHttpURL(href) && href !== currentHref && !prefetchedPages.has(href)) { events.emit('fetch-page-module', { href }) prefetchedPages.add(href) } diff --git a/framework/react/bootstrap.ts b/framework/react/bootstrap.ts index bac93fa1e..8577b76fe 100644 --- a/framework/react/bootstrap.ts +++ b/framework/react/bootstrap.ts @@ -1,24 +1,20 @@ import type { ComponentType } from 'https://esm.sh/react' import { createElement } from 'https://esm.sh/react' import { hydrate, render } from 'https://esm.sh/react-dom' -import util from "../../shared/util.ts" -import { Route, RouteModule, Routing } from '../core/routing.ts' +import util from '../../shared/util.ts' +import { RouteModule, Routing, RoutingOptions } from '../core/routing.ts' import type { PageRoute } from './pageprops.ts' import { createPageProps } from './pageprops.ts' import Router from './router.ts' import { importModule, loadPageDataFromTag } from './util.ts' -type Options = { - baseUrl: string - defaultLocale: string - locales: string[] - routes: Route[] +type BootstrapOptions = Required & { sharedModules: RouteModule[], renderMode: 'ssr' | 'spa' } -export default async function bootstrap(options: Options) { - const { baseUrl, defaultLocale, locales, routes, sharedModules, renderMode } = options +export default async function bootstrap(options: BootstrapOptions) { + const { baseUrl, defaultLocale, locales, routes, rewrites, sharedModules, renderMode } = options const { document } = window as any const customComponents: Record = {} await Promise.all(sharedModules.map(async mod => { @@ -32,7 +28,7 @@ export default async function bootstrap(options: Options) { break } })) - const routing = new Routing(routes, baseUrl, defaultLocale, locales) + const routing = new Routing({ routes, rewrites, baseUrl, defaultLocale, locales }) const [url, pageModuleChain] = routing.createRouter() const imports = await Promise.all(pageModuleChain.map(async mod => { const [{ default: Component }] = await Promise.all([ diff --git a/plugins/sass_test.ts b/plugins/sass_test.ts index dd84377d3..d4f3d1e06 100644 --- a/plugins/sass_test.ts +++ b/plugins/sass_test.ts @@ -1,4 +1,4 @@ -import { assertEquals } from 'https://deno.land/std@0.83.0/testing/asserts.ts' +import { assertEquals } from 'https://deno.land/std@0.85.0/testing/asserts.ts' import plugin from './sass.ts' Deno.test('scss loader plugin', async () => { diff --git a/plugins/wasm_test.ts b/plugins/wasm_test.ts index 6c7fe61a5..d4989becf 100644 --- a/plugins/wasm_test.ts +++ b/plugins/wasm_test.ts @@ -1,4 +1,4 @@ -import { assertEquals } from 'https://deno.land/std@0.83.0/testing/asserts.ts' +import { assertEquals } from 'https://deno.land/std@0.85.0/testing/asserts.ts' import plugin from './wasm.ts' Deno.test('wasm loader plugin', async () => { diff --git a/server/app.ts b/server/app.ts index 824b87938..a531fec27 100644 --- a/server/app.ts +++ b/server/app.ts @@ -3,14 +3,15 @@ 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 { 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' +import { defaultConfig, loadConfig } from './config.ts' +import { AlephRuntimeCode, cleanupCompilation, computeHash, createHtml, formatBytesWithColor, getAlephPkgUrl, getRelativePath, reFullVersion, reHashJs, reHashResolve, respondErrorJSON } from './util.ts' /** * The Aleph Server Application class. @@ -24,8 +25,8 @@ export class Application { #denoCacheDir = '' #modules: Map = new Map() - #pageRouting: Routing = new Routing() - #apiRouting: Routing = new Routing() + #pageRouting: Routing = new Routing({}) + #apiRouting: Routing = new Routing({}) #fsWatchListeners: Array = [] #renderer: { render: CallableFunction } = { render: () => { } } #renderCache: Map> = new Map() @@ -38,24 +39,7 @@ export class Application { 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.config = { ...defaultConfig } this.importMap = { imports: {}, scopes: {} } this.ready = this.init(reload) } @@ -362,145 +346,6 @@ export class Application { ]) } - /** load config from `aleph.config.(ts|js|json)` */ - private async loadConfig() { - const config: Record = {} - - 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 = fixImportMap(importMap.imports) - const scopes: Record> = {} - 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() @@ -538,7 +383,12 @@ export class Application { await ensureDir(this.buildDir) } - await this.loadConfig() + const [config, importMap] = await loadConfig(this.workingDir) + Object.assign(this.config, config) + Object.assign(this.importMap, importMap) + + // create page routing + this.#pageRouting = new Routing(this.config) // change current work dir to appDoot Deno.chdir(this.workingDir) @@ -736,6 +586,7 @@ export class Application { defaultLocale, locales: [], routes: this.#pageRouting.routes, + rewrites: this.config.rewrites, sharedModules: Array.from(this.#modules.values()).filter(({ url }) => { switch (util.trimModuleExt(url)) { case '/404': diff --git a/server/config.ts b/server/config.ts new file mode 100644 index 000000000..c49433008 --- /dev/null +++ b/server/config.ts @@ -0,0 +1,204 @@ +import type { AcceptedPlugin } from '../deps.ts' +import { path } from '../deps.ts' +import { defaultReactVersion } from '../shared/constants.ts' +import { existsFileSync } from '../shared/fs.ts' +import log from '../shared/log.ts' +import util from '../shared/util.ts' +import type { Config, ImportMap } from '../types.ts' +import { VERSION } from '../version.ts' +import { fixImportMap, reLocaleID } from './util.ts' + +export const defaultConfig: Readonly> = { + framework: 'react', + reactVersion: defaultReactVersion, + buildTarget: 'es5', + baseUrl: '/', + srcDir: '/', + outputDir: '/dist', + defaultLocale: 'en', + locales: [], + rewrites: {}, + ssr: {}, + plugins: [], + postcss: { + plugins: [ + 'autoprefixer' + ] + }, + env: {}, +} + + +/** load config from `aleph.config.(ts|js|json)` */ +export async function loadConfig(workingDir: string): Promise<[Config, ImportMap]> { + let data: Config = {} + for (const name of Array.from(['ts', 'js', 'json']).map(ext => 'aleph.config.' + ext)) { + const p = path.join(workingDir, name) + if (existsFileSync(p)) { + log.info('Aleph server config loaded from', name) + if (name.endsWith('.json')) { + const v = JSON.parse(await Deno.readTextFile(p)) + if (util.isPlainObject(v)) { + data = v + } + } else { + let { default: v } = await import('file://' + p) + if (util.isFunction(v)) { + v = await v() + } + if (util.isPlainObject(v)) { + data = v + } + } + break + } + } + + const config: Config = {} + const { + framework, + reactVersion, + srcDir, + outputDir, + baseUrl, + buildTarget, + defaultLocale, + locales, + ssr, + rewrites, + plugins, + postcss, + env, + } = data + if (isFramework(framework)) { + config.framework = framework + } + if (util.isNEString(reactVersion)) { + config.reactVersion = reactVersion + } + if (util.isNEString(srcDir)) { + config.srcDir = util.cleanPath(srcDir) + } + if (util.isNEString(outputDir)) { + config.outputDir = util.cleanPath(outputDir) + } + if (util.isNEString(baseUrl)) { + config.baseUrl = util.cleanPath(encodeURI(baseUrl)) + } + if (isTarget(buildTarget)) { + config.buildTarget = buildTarget + } + if (util.isNEString(defaultLocale)) { + config.defaultLocale = defaultLocale + } + if (util.isArray(locales)) { + locales.filter(l => !reLocaleID.test(l)).forEach(l => log.warn(`invalid locale ID '${l}'`)) + config.locales = Array.from(new Set(locales.filter(l => reLocaleID.test(l)))) + } + if (typeof ssr === 'boolean') { + config.ssr = ssr + } else if (util.isPlainObject(ssr)) { + 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])) : [] + config.ssr = { include, exclude, staticPaths } + } + if (util.isPlainObject(rewrites)) { + config.rewrites = rewrites + } + if (util.isPlainObject(env)) { + config.env = env + } + if (util.isNEArray(plugins)) { + config.plugins = plugins + } + if (isPostcssConfig(postcss)) { + config.postcss = postcss + } else { + for (const name of Array.from(['ts', 'js', 'json']).map(ext => `postcss.config.${ext}`)) { + const p = path.join(workingDir, name) + if (existsFileSync(p)) { + if (name.endsWith('.json')) { + const postcss = JSON.parse(await Deno.readTextFile(p)) + if (isPostcssConfig(postcss)) { + config.postcss = postcss + } + } else { + let { default: postcss } = await import('file://' + p) + if (util.isFunction(postcss)) { + postcss = await postcss() + } + if (isPostcssConfig(postcss)) { + config.postcss = postcss + } + } + break + } + } + } + + // todo: load ssr.config.ts + + // load import maps + const importMap: ImportMap = { imports: {}, scopes: {} } + for (const filename of Array.from(['import_map', 'import-map', 'importmap']).map(name => `${name}.json`)) { + const importMapFile = path.join(workingDir, filename) + if (existsFileSync(importMapFile)) { + const importMap = JSON.parse(await Deno.readTextFile(importMapFile)) + const imports: Record = fixImportMap(importMap.imports) + const scopes: Record> = {} + if (util.isPlainObject(importMap.scopes)) { + Object.entries(importMap.scopes).forEach(([key, imports]) => { + scopes[key] = fixImportMap(imports) + }) + } + Object.assign(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@${config.reactVersion}`, + 'react-dom': `https://esm.sh/react-dom@${config.reactVersion}`, + } + Object.assign(importMap.imports, imports) + } + + return [config, importMap] +} + +function isFramework(v: any): v is 'react' { + switch (v) { + case 'react': + return true + default: + return false + } +} + +function isTarget(v: any): v is 'es5' | 'es2015' | 'es2016' | 'es2017' | 'es2018' | 'es2019' | 'es2020' { + switch (v) { + case 'es5': + case 'es2015': + case 'es2016': + case 'es2017': + case 'es2018': + case 'es2019': + case 'es2020': + return true + default: + return false + } +} + +function isPostcssConfig(v: any): v is { plugins: (string | AcceptedPlugin | [string | ((options: Record) => AcceptedPlugin), Record])[] } { + return util.isPlainObject(v) && util.isArray(v.plugins) +} diff --git a/server/server.ts b/server/server.ts index 958e77f5f..224ace3c5 100644 --- a/server/server.ts +++ b/server/server.ts @@ -1,5 +1,5 @@ -import { path, serve as stdServe, ws } from '../deps.ts' -import { RouteModule } from '../framework/core/routing.ts' +import { path, serve as stdServe, serveTLS, ws } from '../deps.ts' +import { rewriteURL, RouteModule } from '../framework/core/routing.ts' import { existsFileSync } from '../shared/fs.ts' import log from '../shared/log.ts' import util from '../shared/util.ts' @@ -26,8 +26,9 @@ export class Server { } const app = this.#app - const url = new URL('http://localhost' + r.url) - const pathname = util.cleanPath(decodeURI(url.pathname)) + const { baseUrl, rewrites } = app.config + const url = rewriteURL(r.url, baseUrl, rewrites) + const pathname = decodeURI(url.pathname) const req = new Request(r, pathname, {}, url.searchParams) try { @@ -48,12 +49,15 @@ export class Server { if (data.type === 'hotAccept' && util.isNEString(data.url)) { const mod = app.getModule(data.url) if (mod) { - watcher.on('modify-' + mod.url, (hash: string) => socket.send(JSON.stringify({ - type: 'update', - url: mod.url, - updateUrl: util.cleanPath(`${app.config.baseUrl}/_aleph/${util.trimModuleExt(mod.url)}.${util.shortHash(hash!)}.js`), - hash, - }))) + watcher.on('modify-' + mod.url, (hash: string) => { + const updateUrl = `/_aleph/${util.trimModuleExt(mod.url)}.${util.shortHash(hash!)}.js` + socket.send(JSON.stringify({ + type: 'update', + url: mod.url, + updateUrl: baseUrl !== '/' ? baseUrl + updateUrl : updateUrl, + hash, + })) + }) } } } catch (e) { } @@ -66,28 +70,6 @@ export class Server { return } - // serve public files - const filePath = path.join(app.workingDir, 'public', pathname) - if (existsFileSync(filePath)) { - const info = Deno.lstatSync(filePath) - const lastModified = info.mtime?.toUTCString() ?? new Date().toUTCString() - if (lastModified === r.headers.get('If-Modified-Since')) { - req.status(304).send('') - return - } - - const body = Deno.readFileSync(filePath) - req.setHeader('Last-Modified', lastModified) - req.send(body, getContentType(filePath)) - return - } - - // serve APIs - if (pathname.startsWith('/api/')) { - app.handleAPI(r, { pathname, search: url.search }) - return - } - // serve dist files if (pathname.startsWith('/_aleph/')) { if (pathname.startsWith('/_aleph/data/') && pathname.endsWith('.json')) { @@ -140,8 +122,30 @@ export class Server { return } + // serve public files + const filePath = path.join(app.workingDir, 'public', pathname) + if (existsFileSync(filePath)) { + const info = Deno.lstatSync(filePath) + const lastModified = info.mtime?.toUTCString() ?? new Date().toUTCString() + if (lastModified === r.headers.get('If-Modified-Since')) { + req.status(304).send('') + return + } + + const body = Deno.readFileSync(filePath) + req.setHeader('Last-Modified', lastModified) + req.send(body, getContentType(filePath)) + return + } + + // serve APIs + if (pathname.startsWith('/api/')) { + app.handleAPI(r, { pathname, search: url.searchParams.toString() }) + return + } + // ssr - const [status, html] = await app.getPageHtml({ pathname, search: url.search }) + const [status, html] = await app.getPageHtml({ pathname, search: url.searchParams.toString() }) req.status(status).send(html, 'text/html; charset=utf-8') } catch (err) { req.status(500).send(createHtml({ @@ -153,19 +157,39 @@ export class Server { } } +export type ServeOptions = { + /** The Application to serve. */ + app: Application + /** The port to listen on. */ + port: number + /** A literal IP address or host name that can be resolved to an IP address. + * If not specified, defaults to `0.0.0.0`. */ + hostname?: string + /** Server certificate file. */ + certFile?: string + /** Server public key file. */ + keyFile?: string +} + /** start a standard aleph server. */ -export async function serve(hostname: string, port: number, app: Application) { +export async function serve({ app, port, hostname, certFile, keyFile }: ServeOptions) { const server = new Server(app) await app.ready + while (true) { try { - const s = stdServe({ hostname, port }) - log.info(`Aleph server ready on http://${hostname}:${port}`) + let s: AsyncIterable + if (certFile && keyFile) { + s = serveTLS({ port, hostname, certFile, keyFile }) + } else { + s = stdServe({ port, hostname }) + } + log.info(`Aleph server ready on http://${hostname}:${port}${app.config.baseUrl}`) for await (const r of s) { server.handle(r) } } catch (err) { - if (err instanceof Deno.errors.AddrInUse) { + if (err instanceof Deno.errors.AddrInUse && app.isDev) { log.warn(`port ${port} already in use, try ${port + 1}...`) port++ } else { diff --git a/server/util.ts b/server/util.ts index 439c8697f..7afc1015d 100644 --- a/server/util.ts +++ b/server/util.ts @@ -112,13 +112,24 @@ export function fixImportMap(v: any) { /** parse port number */ export function parsePortNumber(v: string): number { const num = parseInt(v) - if (isNaN(num) || num <= 0 || num > 1 << 16 || !Number.isInteger(num)) { - log.error(`invalid port 'v'`) - Deno.exit(1) + if (isNaN(num) || !Number.isInteger(num) || num <= 0 || num >= 1 << 16) { + log.fatal(`invalid port '${v}'`) } return num } +/** get option value */ +export function getOptionValue(options: Record, keys: string[], defaultValue?: string): string { + let value = defaultValue || '' + for (const key of keys) { + if (key in options && util.isNEString(options[key])) { + value = String(options[key]) + break + } + } + return value +} + /** * colorful the bytes string * - dim: 0 - 1MB diff --git a/types.ts b/types.ts index 3085c66fa..d5ac8b77f 100644 --- a/types.ts +++ b/types.ts @@ -56,22 +56,24 @@ export type SSROptions = { export type Config = { /** `framework` to run your application (default is 'react'). */ framework?: 'react' + /** `reactVersion` specifies the **react version** (default is '17.0.1'). */ + reactVersion?: string /** `buildTarget` specifies the build target in production mode (default is **es5** to be compatible with IE11). */ buildTarget?: 'es5' | 'es2015' | 'es2016' | 'es2017' | 'es2018' | 'es2019' | 'es2020' + /** `baseUrl` specifies the path prefix for the application (default is '/'). */ + baseUrl?: string /** `srcDir` to put your application source code (default is '/'). */ srcDir?: string /** `outputDir` specifies the output directory for `build` command (default is '**dist**'). */ outputDir?: string - /** `baseUrl` specifies the path prefix for the application (default is '/'). */ - baseUrl?: string - /** `reactVersion` specifies the **react version** (default is '17.0.1'). */ - reactVersion?: string /** `defaultLocale` specifies the default locale of the application (default is '**en**'). */ defaultLocale?: string /** A list of locales. */ locales?: string[] /** The options for **SSR**. */ ssr?: boolean | SSROptions + /* The server path rewrite rules. */ + rewrites?: Record /** A list of plugin. */ plugins?: Plugin[] /** A list of plugin of PostCSS. */