16 Commits
0.3.0 ... 0.4.0

Author SHA1 Message Date
39d97d6e14 version 0.4.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
continuous-integration/drone Build is passing
2021-07-07 20:15:36 -05:00
f496046461 File-based response support & static server
All checks were successful
continuous-integration/drone/push Build is passing
- Clean up UniversalPath implementation
    - Use Readable/Writable types correctly for stream methods
    - Add .list() methods for getting child files

- Make Response body specify explicit types and support
  writing Readable streams to the body

- Create a static file server that supports directory listing
2021-07-07 20:13:23 -05:00
b3b5b169e8 Add mechanism for NPM package auto-discovery
All checks were successful
continuous-integration/drone/push Build is passing
2021-07-02 21:45:15 -05:00
5d960e6186 chore: make Rehydratable use Awaitable; add docblock 2021-07-02 21:44:34 -05:00
cf6d14abca - Start support for auto-generated routes using UniversalPath
All checks were successful
continuous-integration/drone/push Build is passing
- Start support for custom view engine props & functions
- Start login template and namespace
2021-06-29 01:44:07 -05:00
faa8a31102 Route - prevent pre/post middleware from being applied twice
All checks were successful
continuous-integration/drone/push Build is passing
2021-06-29 00:34:05 -05:00
7506d6567d Support registering namespaced view directories; add lib() universal path
All checks were successful
continuous-integration/drone/push Build is passing
2021-06-24 00:14:04 -05:00
a69c81ed35 chore(version): 0.3.1
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-06-17 19:35:50 -05:00
36b451c32b Expose auth repos in context; create routes commands
All checks were successful
continuous-integration/drone/push Build is passing
2021-06-17 19:35:31 -05:00
9796a7277e Begin abstracting global container into injector 2021-06-17 19:34:32 -05:00
f00233d49a Add middleware and logic for bootstrapping the session auth
All checks were successful
continuous-integration/drone/push Build is passing
2021-06-05 13:24:12 -05:00
91abcdf8ef Start auth framework
All checks were successful
continuous-integration/drone/push Build is passing
2021-06-05 12:02:36 -05:00
c264d45927 Add query executed event; forward model events to global event bus
All checks were successful
continuous-integration/drone/push Build is passing
2021-06-05 08:36:35 -05:00
61731c4ebd Add basic concepts for event bus, and implement in request and model
All checks were successful
continuous-integration/drone/push Build is passing
2021-06-04 01:03:31 -05:00
dab3d006c8 Containers - add ability to purge/release factories; override factories in scoped 2021-06-04 01:03:10 -05:00
cd9bec7c5e Remove old doc build trigger from CI
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2021-06-02 22:45:49 -05:00
82 changed files with 3162 additions and 178 deletions

View File

@@ -217,18 +217,3 @@ steps:
when:
status: failure
event: pull_request
- name: trigger documentation build
image: plugins/downstream
settings:
server: https://ci.garrettmills.dev
token:
from_secret: drone_token
fork: false
last_successful: true
deploy: production
repositories:
- Extollo/docs@master
when:
status: success
event: tag

View File

@@ -1,6 +1,6 @@
{
"name": "@extollo/lib",
"version": "0.3.0",
"version": "0.4.0",
"description": "The framework library that lifts up your code.",
"main": "lib/index.js",
"types": "lib/index.d.ts",
@@ -8,19 +8,26 @@
"lib": "lib"
},
"dependencies": {
"@atao60/fse-cli": "^0.1.6",
"@types/bcrypt": "^5.0.0",
"@types/busboy": "^0.2.3",
"@types/cli-table": "^0.3.0",
"@types/mime-types": "^2.1.0",
"@types/mkdirp": "^1.0.1",
"@types/negotiator": "^0.6.1",
"@types/node": "^14.14.37",
"@types/node": "^14.17.4",
"@types/pg": "^8.6.0",
"@types/pluralize": "^0.0.29",
"@types/pug": "^2.0.4",
"@types/rimraf": "^3.0.0",
"@types/ssh2": "^0.5.46",
"@types/uuid": "^8.3.0",
"bcrypt": "^5.0.1",
"busboy": "^0.3.1",
"cli-table": "^0.3.6",
"colors": "^1.4.0",
"dotenv": "^8.2.0",
"mime-types": "^2.1.31",
"mkdirp": "^1.0.4",
"negotiator": "^0.6.2",
"pg": "^8.6.0",
@@ -38,8 +45,9 @@
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"prebuild": "pnpm run lint",
"prebuild": "pnpm run lint && rimraf lib",
"build": "tsc",
"postbuild": "fse copy --all --dereference --preserveTimestamps --keepExisting=false --quiet --errorOnExist=false src/resources lib/resources",
"app": "tsc && node lib/index.js",
"prepare": "pnpm run build",
"docs:build": "typedoc --options typedoc.json",
@@ -61,5 +69,11 @@
"@typescript-eslint/eslint-plugin": "^4.26.0",
"@typescript-eslint/parser": "^4.26.0",
"eslint": "^7.27.0"
},
"extollo": {
"discover": true,
"units": {
"discover": false
}
}
}

661
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
import {Inject, Injectable, Instantiable, StaticClass} from '../di'
import {Unit} from '../lifecycle/Unit'
import {Logging} from '../service/Logging'
import {CanonicalResolver} from '../service/Canonical'
import {Middleware} from '../http/routing/Middleware'
import {SessionAuthMiddleware} from './middleware/SessionAuthMiddleware'
import {AuthRequiredMiddleware} from './middleware/AuthRequiredMiddleware'
import {GuestRequiredMiddleware} from './middleware/GuestRequiredMiddleware'
import {Middlewares} from '../service/Middlewares'
/**
* Unit class that bootstraps the authentication framework.
*/
@Injectable()
export class Authentication extends Unit {
@Inject()
protected readonly logging!: Logging
@Inject()
protected readonly middleware!: Middlewares
async up(): Promise<void> {
this.container()
this.middleware.registerNamespace('@auth', this.getMiddlewareResolver())
}
/**
* Create the canonical namespace resolver for auth middleware.
* @protected
*/
protected getMiddlewareResolver(): CanonicalResolver<StaticClass<Middleware, Instantiable<Middleware>>> {
return (key: string) => {
return ({
web: SessionAuthMiddleware,
required: AuthRequiredMiddleware,
guest: GuestRequiredMiddleware,
})[key]
}
}
}

View File

@@ -0,0 +1,11 @@
import {HTTPError} from '../http/HTTPError'
import {HTTPStatus} from '../util'
/**
* Error thrown when a user attempts an action that they are not authorized to perform.
*/
export class NotAuthorizedError extends HTTPError {
constructor(message = 'Not Authorized') {
super(HTTPStatus.FORBIDDEN, message)
}
}

143
src/auth/SecurityContext.ts Normal file
View File

@@ -0,0 +1,143 @@
import {Inject, Injectable} from '../di'
import {EventBus} from '../event/EventBus'
import {Awaitable, Maybe} from '../util'
import {Authenticatable, AuthenticatableRepository} from './types'
import {UserAuthenticatedEvent} from './event/UserAuthenticatedEvent'
import {UserFlushedEvent} from './event/UserFlushedEvent'
import {UserAuthenticationResumedEvent} from './event/UserAuthenticationResumedEvent'
/**
* Base-class for a context that authenticates users and manages security.
*/
@Injectable()
export abstract class SecurityContext {
@Inject()
protected readonly bus!: EventBus
/** The currently authenticated user, if one exists. */
private authenticatedUser?: Authenticatable
constructor(
/** The repository from which to draw users. */
public readonly repository: AuthenticatableRepository,
/** The name of this context. */
public readonly name: string,
) { }
/**
* Called when the context is created. Can be used by child-classes to do setup work.
*/
initialize(): Awaitable<void> {} // eslint-disable-line @typescript-eslint/no-empty-function
/**
* Authenticate the given user, without persisting the authentication.
* That is, when the lifecycle ends, the user will be unauthenticated implicitly.
* @param user
*/
async authenticateOnce(user: Authenticatable): Promise<void> {
this.authenticatedUser = user
await this.bus.dispatch(new UserAuthenticatedEvent(user, this))
}
/**
* Authenticate the given user and persist the authentication.
* @param user
*/
async authenticate(user: Authenticatable): Promise<void> {
this.authenticatedUser = user
await this.persist()
await this.bus.dispatch(new UserAuthenticatedEvent(user, this))
}
/**
* Attempt to authenticate a user based on their credentials.
* If the credentials are valid, the user will be authenticated, but the authentication
* will not be persisted. That is, when the lifecycle ends, the user will be
* unauthenticated implicitly.
* @param credentials
*/
async attemptOnce(credentials: Record<string, string>): Promise<Maybe<Authenticatable>> {
const user = await this.repository.getByCredentials(credentials)
if ( user ) {
await this.authenticateOnce(user)
return user
}
}
/**
* Attempt to authenticate a user based on their credentials.
* If the credentials are valid, the user will be authenticated and the
* authentication will be persisted.
* @param credentials
*/
async attempt(credentials: Record<string, string>): Promise<Maybe<Authenticatable>> {
const user = await this.repository.getByCredentials(credentials)
if ( user ) {
await this.authenticate(user)
return user
}
}
/**
* Unauthenticate the current user, if one exists, but do not persist the change.
*/
async flushOnce(): Promise<void> {
const user = this.authenticatedUser
if ( user ) {
this.authenticatedUser = undefined
await this.bus.dispatch(new UserFlushedEvent(user, this))
}
}
/**
* Unauthenticate the current user, if one exists, and persist the change.
*/
async flush(): Promise<void> {
const user = this.authenticatedUser
if ( user ) {
this.authenticatedUser = undefined
await this.persist()
await this.bus.dispatch(new UserFlushedEvent(user, this))
}
}
/**
* Assuming a user is still authenticated in the context,
* try to look up and fill in the user.
*/
async resume(): Promise<void> {
const credentials = await this.getCredentials()
const user = await this.repository.getByCredentials(credentials)
if ( user ) {
this.authenticatedUser = user
await this.bus.dispatch(new UserAuthenticationResumedEvent(user, this))
}
}
/**
* Write the current state of the security context to whatever storage
* medium the context's host provides.
*/
abstract persist(): Awaitable<void>
/**
* Get the credentials for the current user from whatever storage medium
* the context's host provides.
*/
abstract getCredentials(): Awaitable<Record<string, string>>
/**
* Get the currently authenticated user, if one exists.
*/
getUser(): Maybe<Authenticatable> {
return this.authenticatedUser
}
/**
* Returns true if there is a currently authenticated user.
*/
hasUser(): boolean {
return Boolean(this.authenticatedUser)
}
}

25
src/auth/config.ts Normal file
View File

@@ -0,0 +1,25 @@
import {Instantiable} from '../di'
import {ORMUserRepository} from './orm/ORMUserRepository'
/**
* Inferface for type-checking the AuthenticatableRepositories values.
*/
export interface AuthenticatableRepositoryMapping {
orm: Instantiable<ORMUserRepository>,
}
/**
* String mapping of AuthenticatableRepository implementations.
*/
export const AuthenticatableRepositories: AuthenticatableRepositoryMapping = {
orm: ORMUserRepository,
}
/**
* Interface for making the auth config type-safe.
*/
export interface AuthConfig {
repositories: {
session: keyof AuthenticatableRepositoryMapping,
}
}

View File

@@ -0,0 +1,31 @@
import {SecurityContext} from '../SecurityContext'
import {Inject, Injectable} from '../../di'
import {Session} from '../../http/session/Session'
import {Awaitable} from '../../util'
import {AuthenticatableRepository} from '../types'
/**
* Security context implementation that uses the session as storage.
*/
@Injectable()
export class SessionSecurityContext extends SecurityContext {
@Inject()
protected readonly session!: Session
constructor(
/** The repository from which to draw users. */
public readonly repository: AuthenticatableRepository,
) {
super(repository, 'session')
}
getCredentials(): Awaitable<Record<string, string>> {
return {
securityIdentifier: this.session.get('extollo.auth.securityIdentifier'),
}
}
persist(): Awaitable<void> {
this.session.set('extollo.auth.securityIdentifier', this.getUser()?.getIdentifier())
}
}

View File

@@ -0,0 +1,27 @@
import {Event} from '../../event/Event'
import {SecurityContext} from '../SecurityContext'
import {Awaitable, JSONState} from '../../util'
import {Authenticatable} from '../types'
/**
* Event fired when a user is authenticated.
*/
export class UserAuthenticatedEvent extends Event {
constructor(
public readonly user: Authenticatable,
public readonly context: SecurityContext,
) {
super()
}
async dehydrate(): Promise<JSONState> {
return {
user: await this.user.dehydrate(),
contextName: this.context.name,
}
}
rehydrate(state: JSONState): Awaitable<void> { // eslint-disable-line @typescript-eslint/no-unused-vars
// TODO fill this in
}
}

View File

@@ -0,0 +1,27 @@
import {Event} from '../../event/Event'
import {SecurityContext} from '../SecurityContext'
import {Awaitable, JSONState} from '../../util'
import {Authenticatable} from '../types'
/**
* Event fired when a security context for a given user is resumed.
*/
export class UserAuthenticationResumedEvent extends Event {
constructor(
public readonly user: Authenticatable,
public readonly context: SecurityContext,
) {
super()
}
async dehydrate(): Promise<JSONState> {
return {
user: await this.user.dehydrate(),
contextName: this.context.name,
}
}
rehydrate(state: JSONState): Awaitable<void> { // eslint-disable-line @typescript-eslint/no-unused-vars
// TODO fill this in
}
}

View File

@@ -0,0 +1,27 @@
import {Event} from '../../event/Event'
import {SecurityContext} from '../SecurityContext'
import {Awaitable, JSONState} from '../../util'
import {Authenticatable} from '../types'
/**
* Event fired when a user is unauthenticated.
*/
export class UserFlushedEvent extends Event {
constructor(
public readonly user: Authenticatable,
public readonly context: SecurityContext,
) {
super()
}
async dehydrate(): Promise<JSONState> {
return {
user: await this.user.dehydrate(),
contextName: this.context.name,
}
}
rehydrate(state: JSONState): Awaitable<void> { // eslint-disable-line @typescript-eslint/no-unused-vars
// TODO fill this in
}
}

21
src/auth/index.ts Normal file
View File

@@ -0,0 +1,21 @@
export * from './types'
export * from './NotAuthorizedError'
export * from './SecurityContext'
export * from './event/UserAuthenticatedEvent'
export * from './event/UserFlushedEvent'
export * from './event/UserAuthenticationResumedEvent'
export * from './contexts/SessionSecurityContext'
export * from './orm/ORMUser'
export * from './orm/ORMUserRepository'
export * from './middleware/AuthRequiredMiddleware'
export * from './middleware/GuestRequiredMiddleware'
export * from './middleware/SessionAuthMiddleware'
export * from './Authentication'
export * from './config'

View File

@@ -0,0 +1,19 @@
import {Middleware} from '../../http/routing/Middleware'
import {Inject, Injectable} from '../../di'
import {SecurityContext} from '../SecurityContext'
import {ResponseObject} from '../../http/routing/Route'
import {error} from '../../http/response/ErrorResponseFactory'
import {NotAuthorizedError} from '../NotAuthorizedError'
import {HTTPStatus} from '../../util'
@Injectable()
export class AuthRequiredMiddleware extends Middleware {
@Inject()
protected readonly security!: SecurityContext
async apply(): Promise<ResponseObject> {
if ( !this.security.hasUser() ) {
return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN)
}
}
}

View File

@@ -0,0 +1,19 @@
import {Middleware} from '../../http/routing/Middleware'
import {Inject, Injectable} from '../../di'
import {SecurityContext} from '../SecurityContext'
import {ResponseObject} from '../../http/routing/Route'
import {error} from '../../http/response/ErrorResponseFactory'
import {NotAuthorizedError} from '../NotAuthorizedError'
import {HTTPStatus} from '../../util'
@Injectable()
export class GuestRequiredMiddleware extends Middleware {
@Inject()
protected readonly security!: SecurityContext
async apply(): Promise<ResponseObject> {
if ( this.security.hasUser() ) {
return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN)
}
}
}

View File

@@ -0,0 +1,40 @@
import {Middleware} from '../../http/routing/Middleware'
import {Inject, Injectable} from '../../di'
import {ResponseObject} from '../../http/routing/Route'
import {Config} from '../../service/Config'
import {AuthenticatableRepository} from '../types'
import {SessionSecurityContext} from '../contexts/SessionSecurityContext'
import {SecurityContext} from '../SecurityContext'
import {ORMUserRepository} from '../orm/ORMUserRepository'
import {AuthConfig, AuthenticatableRepositories} from '../config'
import {Logging} from '../../service/Logging'
/**
* Injects a SessionSecurityContext into the request and attempts to
* resume the user's authentication.
*/
@Injectable()
export class SessionAuthMiddleware extends Middleware {
@Inject()
protected readonly config!: Config
@Inject()
protected readonly logging!: Logging
async apply(): Promise<ResponseObject> {
this.logging.debug('Applying session auth middleware...')
const context = <SessionSecurityContext> this.make(SessionSecurityContext, this.getRepository())
this.request.registerSingletonInstance(SecurityContext, context)
await context.resume()
}
/**
* Build the correct AuthenticatableRepository based on the auth config.
* @protected
*/
protected getRepository(): AuthenticatableRepository {
const config: AuthConfig | undefined = this.config.get('auth')
const repo: typeof AuthenticatableRepository = AuthenticatableRepositories[config?.repositories?.session ?? 'orm']
return this.make<AuthenticatableRepository>(repo ?? ORMUserRepository)
}
}

64
src/auth/orm/ORMUser.ts Normal file
View File

@@ -0,0 +1,64 @@
import {Field, FieldType, Model} from '../../orm'
import {Authenticatable, AuthenticatableIdentifier} from '../types'
import {Injectable} from '../../di'
import * as bcrypt from 'bcrypt'
import {Awaitable, JSONState} from '../../util'
/**
* A basic ORM-driven user class.
*/
@Injectable()
export class ORMUser extends Model<ORMUser> implements Authenticatable {
protected static table = 'users'
protected static key = 'user_id'
/** The primary key of the user in the table. */
@Field(FieldType.serial, 'user_id')
public userId!: number
/** The unique string-identifier of the user. */
@Field(FieldType.varchar)
public username!: string
/** The user's first name. */
@Field(FieldType.varchar, 'first_name')
public firstName!: string
/** The user's last name. */
@Field(FieldType.varchar, 'last_name')
public lastName!: string
/** The hashed and salted password of the user. */
@Field(FieldType.varchar, 'password_hash')
public passwordHash!: string
/** Human-readable display name of the user. */
getDisplayIdentifier(): string {
return `${this.firstName} ${this.lastName}`
}
/** Unique identifier of the user. */
getIdentifier(): AuthenticatableIdentifier {
return this.username
}
/** Check if the provided password is valid for the user. */
verifyPassword(password: string): Awaitable<boolean> {
return bcrypt.compare(password, this.passwordHash)
}
/** Change the user's password, hashing it. */
async setPassword(password: string): Promise<void> {
this.passwordHash = await bcrypt.hash(password, 10)
}
async dehydrate(): Promise<JSONState> {
return this.toQueryRow()
}
async rehydrate(state: JSONState): Promise<void> {
await this.assumeFromSource(state)
}
}

View File

@@ -0,0 +1,41 @@
import {Authenticatable, AuthenticatableIdentifier, AuthenticatableRepository} from '../types'
import {Awaitable, Maybe} from '../../util'
import {ORMUser} from './ORMUser'
import {Injectable} from '../../di'
/**
* A user repository implementation that looks up users stored in the database.
*/
@Injectable()
export class ORMUserRepository extends AuthenticatableRepository {
/** Look up the user by their username. */
getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>> {
return ORMUser.query<ORMUser>()
.where('username', '=', id)
.first()
}
/**
* Try to look up a user by the credentials provided.
* If a securityIdentifier is specified, look up the user by username.
* If username/password are specified, look up the user and verify the password.
* @param credentials
*/
async getByCredentials(credentials: Record<string, string>): Promise<Maybe<Authenticatable>> {
if ( credentials.securityIdentifier ) {
return ORMUser.query<ORMUser>()
.where('username', '=', credentials.securityIdentifier)
.first()
}
if ( credentials.username && credentials.password ) {
const user = await ORMUser.query<ORMUser>()
.where('username', '=', credentials.username)
.first()
if ( user && await user.verifyPassword(credentials.password) ) {
return user
}
}
}
}

36
src/auth/types.ts Normal file
View File

@@ -0,0 +1,36 @@
import {Awaitable, JSONState, Maybe, Rehydratable} from '../util'
/** Value that can be used to uniquely identify a user. */
export type AuthenticatableIdentifier = string | number
/**
* Base class for entities that can be authenticated.
*/
export abstract class Authenticatable implements Rehydratable {
/** Get the unique identifier of the user. */
abstract getIdentifier(): AuthenticatableIdentifier
/** Get the human-readable identifier of the user. */
abstract getDisplayIdentifier(): string
abstract dehydrate(): Promise<JSONState>
abstract rehydrate(state: JSONState): Awaitable<void>
}
/**
* Base class for a repository that stores and recalls users.
*/
export abstract class AuthenticatableRepository {
/** Look up the user by their unique identifier. */
abstract getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>>
/**
* Attempt to look up and verify a user by their credentials.
* Returns the user if the credentials are valid.
* @param credentials
*/
abstract getByCredentials(credentials: Record<string, string>): Awaitable<Maybe<Authenticatable>>
}

View File

@@ -0,0 +1,71 @@
import {Directive, OptionDefinition} from '../Directive'
import {Inject, Injectable} from '../../di'
import {Routing} from '../../service/Routing'
import Table = require('cli-table')
import {RouteHandler} from '../../http/routing/Route'
@Injectable()
export class RouteDirective extends Directive {
@Inject()
protected readonly routing!: Routing
getDescription(): string {
return 'Get information about a specific route'
}
getKeywords(): string | string[] {
return ['route']
}
getOptions(): OptionDefinition[] {
return [
'{route} | the path of the route',
'--method -m {value} | the HTTP method of the route',
]
}
async handle(): Promise<void> {
const method: string | undefined = this.option('method')
?.toLowerCase()
?.trim()
const route: string = this.option('route')
.toLowerCase()
.trim()
this.routing.getCompiled()
.filter(match => match.getRoute().trim() === route && (!method || match.getMethod() === method))
.tap(matches => {
if ( !matches.length ) {
this.error('No matching routes found. (Use `./ex routes` to list)')
process.exitCode = 1
}
})
.each(match => {
const pre = match.getMiddlewares()
.where('stage', '=', 'pre')
.map<[string, string]>(ware => [ware.stage, this.handlerToString(ware.handler)])
const post = match.getMiddlewares()
.where('stage', '=', 'post')
.map<[string, string]>(ware => [ware.stage, this.handlerToString(ware.handler)])
const maxLen = match.getMiddlewares().max(ware => this.handlerToString(ware.handler).length)
const table = new Table({
head: ['Stage', 'Handler'],
colWidths: [10, Math.max(maxLen, match.getDisplayableHandler().length) + 2],
})
table.push(...pre.toArray())
table.push(['handler', match.getDisplayableHandler()])
table.push(...post.toArray())
this.info(`\nRoute: ${match}\n\n${table}`)
})
}
protected handlerToString(handler: RouteHandler): string {
return typeof handler === 'string' ? handler : '(anonymous function)'
}
}

View File

@@ -0,0 +1,33 @@
import {Directive} from '../Directive'
import {Inject, Injectable} from '../../di'
import {Routing} from '../../service/Routing'
import Table = require('cli-table')
@Injectable()
export class RoutesDirective extends Directive {
@Inject()
protected readonly routing!: Routing
getDescription(): string {
return 'List routes registered in the application'
}
getKeywords(): string | string[] {
return ['routes']
}
async handle(): Promise<void> {
const maxRouteLength = this.routing.getCompiled().max(route => String(route).length)
const maxHandlerLength = this.routing.getCompiled().max(route => route.getDisplayableHandler().length)
const rows = this.routing.getCompiled().map<[string, string]>(route => [String(route), route.getDisplayableHandler()])
const table = new Table({
head: ['Route', 'Handler'],
colWidths: [maxRouteLength + 2, maxHandlerLength + 2],
})
table.push(...rows.toArray())
this.info('\n' + table)
}
}

View File

@@ -34,6 +34,7 @@ export class ShellDirective extends Directive {
async handle(): Promise<void> {
const state: any = {
app: this.app(),
lib: await import('../../index'),
make: (target: DependencyKey, ...parameters: any[]) => this.make(target, ...parameters),
}

View File

@@ -7,6 +7,8 @@ import {Directive} from '../Directive'
import {ShellDirective} from '../directive/ShellDirective'
import {TemplateDirective} from '../directive/TemplateDirective'
import {RunDirective} from '../directive/RunDirective'
import {RoutesDirective} from '../directive/RoutesDirective'
import {RouteDirective} from '../directive/RouteDirective'
/**
* Unit that takes the place of the final unit in the application that handles
@@ -42,6 +44,8 @@ export class CommandLineApplication extends Unit {
this.cli.registerDirective(ShellDirective)
this.cli.registerDirective(TemplateDirective)
this.cli.registerDirective(RunDirective)
this.cli.registerDirective(RoutesDirective)
this.cli.registerDirective(RouteDirective)
const argv = process.argv.slice(2)
const match = this.cli.getDirectives()

View File

@@ -1,4 +1,4 @@
import {DependencyKey, InstanceRef, Instantiable, isInstantiable} from './types'
import {DependencyKey, InstanceRef, Instantiable, isInstantiable, StaticClass} from './types'
import {AbstractFactory} from './factory/AbstractFactory'
import {collect, Collection, globalRegistry, logIfDebugging} from '../util'
import {Factory} from './factory/Factory'
@@ -7,6 +7,7 @@ import {ClosureFactory} from './factory/ClosureFactory'
import NamedFactory from './factory/NamedFactory'
import SingletonFactory from './factory/SingletonFactory'
import {InvalidDependencyKeyError} from './error/InvalidDependencyKeyError'
import {ContainerBlueprint} from './ContainerBlueprint'
export type MaybeFactory<T> = AbstractFactory<T> | undefined
export type MaybeDependency = any | undefined
@@ -23,6 +24,11 @@ export class Container {
const existing = <Container | undefined> globalRegistry.getGlobal('extollo/injector')
if ( !existing ) {
const container = new Container()
ContainerBlueprint.getContainerBlueprint()
.resolve()
.map(factory => container.registerFactory(factory))
globalRegistry.setGlobal('extollo/injector', container)
return container
}
@@ -47,6 +53,25 @@ export class Container {
this.registerSingleton('injector', this)
}
/**
* Purge all factories and instances of the given key from this container.
* @param key
*/
purge(key: DependencyKey): this {
this.factories = this.factories.filter(x => !x.match(key))
this.release(key)
return this
}
/**
* Remove all stored instances of the given key from this container.
* @param key
*/
release(key: DependencyKey): this {
this.instances = this.instances.filter(x => x.key !== key)
return this
}
/**
* Register a basic instantiable class as a standard Factory with this container.
* @param {Instantiable} dependency
@@ -113,7 +138,7 @@ export class Container {
* @param staticClass
* @param instance
*/
registerSingletonInstance<T>(staticClass: Instantiable<T>, instance: T): this {
registerSingletonInstance<T>(staticClass: StaticClass<T, any> | Instantiable<T>, instance: T): this {
if ( this.resolve(staticClass) ) {
throw new DuplicateFactoryKeyError(staticClass)
}

View File

@@ -0,0 +1,53 @@
import {DependencyKey, Instantiable} from './types'
import NamedFactory from './factory/NamedFactory'
import {AbstractFactory} from './factory/AbstractFactory'
import {Factory} from './factory/Factory'
import {ClosureFactory} from './factory/ClosureFactory'
export class ContainerBlueprint {
private static instance?: ContainerBlueprint
public static getContainerBlueprint(): ContainerBlueprint {
if ( !this.instance ) {
this.instance = new ContainerBlueprint()
}
return this.instance
}
protected factories: (() => AbstractFactory<any>)[] = []
/**
* Register a basic instantiable class as a standard Factory with this container,
* identified by a string name rather than static class.
* @param {string} name - unique name to identify the factory in the container
* @param {Instantiable} dependency
*/
registerNamed(name: string, dependency: Instantiable<any>): this {
this.factories.push(() => new NamedFactory(name, dependency))
return this
}
/**
* Register a basic instantiable class as a standard Factory with this container.
* @param {Instantiable} dependency
*/
register(dependency: Instantiable<any>): this {
this.factories.push(() => new Factory(dependency))
return this
}
/**
* Register a producer function as a ClosureFactory with this container.
* @param key
* @param producer
*/
registerProducer(key: DependencyKey, producer: () => any): this {
this.factories.push(() => new ClosureFactory(key, producer))
return this
}
resolve(): AbstractFactory<any>[] {
return this.factories.map(x => x())
}
}

View File

@@ -1,5 +1,6 @@
import {Container, MaybeDependency, MaybeFactory} from './Container'
import {DependencyKey} from './types'
import {DependencyKey, Instantiable, StaticClass} from './types'
import {AbstractFactory} from './factory/AbstractFactory'
/**
* A container that uses some parent container as a base, but
@@ -30,6 +31,8 @@ export class ScopedContainer extends Container {
return new ScopedContainer(container)
}
private resolveParentScope = true
constructor(
private parentContainer: Container,
) {
@@ -38,11 +41,11 @@ export class ScopedContainer extends Container {
}
hasInstance(key: DependencyKey): boolean {
return super.hasInstance(key) || this.parentContainer.hasInstance(key)
return super.hasInstance(key) || (this.resolveParentScope && this.parentContainer.hasInstance(key))
}
hasKey(key: DependencyKey): boolean {
return super.hasKey(key) || this.parentContainer.hasKey(key)
return super.hasKey(key) || (this.resolveParentScope && this.parentContainer.hasKey(key))
}
getExistingInstance(key: DependencyKey): MaybeDependency {
@@ -51,8 +54,10 @@ export class ScopedContainer extends Container {
return inst
}
if ( this.resolveParentScope ) {
return this.parentContainer.getExistingInstance(key)
}
}
resolve(key: DependencyKey): MaybeFactory<any> {
const factory = super.resolve(key)
@@ -60,6 +65,77 @@ export class ScopedContainer extends Container {
return factory
}
return this.parentContainer?.resolve(key)
if ( this.resolveParentScope ) {
return this.parentContainer.resolve(key)
}
}
/**
* Register a basic instantiable class as a standard Factory with this container.
* @param {Instantiable} dependency
*/
register(dependency: Instantiable<any>): this {
return this.withoutParentScopes(() => super.register(dependency))
}
/**
* Register the given function as a factory within the container.
* @param {string} name - unique name to identify the factory in the container
* @param {function} producer - factory to produce a value
*/
registerProducer(name: DependencyKey, producer: () => any): this {
return this.withoutParentScopes(() => super.registerProducer(name, producer))
}
/**
* Register a basic instantiable class as a standard Factory with this container,
* identified by a string name rather than static class.
* @param {string} name - unique name to identify the factory in the container
* @param {Instantiable} dependency
*/
registerNamed(name: string, dependency: Instantiable<any>): this {
return this.withoutParentScopes(() => super.registerNamed(name, dependency))
}
/**
* Register a value as a singleton in the container. It will not be instantiated, but
* can be injected by its unique name.
* @param {string} key - unique name to identify the singleton in the container
* @param value
*/
registerSingleton<T>(key: DependencyKey, value: T): this {
return this.withoutParentScopes(() => super.registerSingleton(key, value))
}
/**
* Register a static class to the container along with its already-instantiated
* instance that will be used to resolve the class.
* @param staticClass
* @param instance
*/
registerSingletonInstance<T>(staticClass: StaticClass<T, any> | Instantiable<T>, instance: T): this {
return this.withoutParentScopes(() => super.registerSingletonInstance(staticClass, instance))
}
/**
* Register a given factory with the container.
* @param {AbstractFactory} factory
*/
registerFactory(factory: AbstractFactory<unknown>): this {
return this.withoutParentScopes(() => super.registerFactory(factory))
}
/**
* Execute a closure on this container, disabling parent-resolution.
* Effectively, the closure will have access to this container as if
* it were NOT a scoped container, and only contained its factories.
* @param closure
*/
withoutParentScopes<T>(closure: () => T): T {
const oldResolveParentScope = this.resolveParentScope
this.resolveParentScope = false
const value: T = closure()
this.resolveParentScope = oldResolveParentScope
return value
}
}

View File

@@ -10,7 +10,7 @@ import {
DEPENDENCY_KEYS_SERVICE_TYPE_KEY,
PropertyDependency,
} from '../types'
import {Container} from '../Container'
import {ContainerBlueprint} from '../ContainerBlueprint'
/**
* Get a collection of dependency requirements for the given target object.
@@ -145,9 +145,9 @@ export const Singleton = (name?: string): ClassDecorator => {
Injectable()(target)
if ( name ) {
Container.getContainer().registerNamed(name, target)
ContainerBlueprint.getContainerBlueprint().registerNamed(name, target)
} else {
Container.getContainer().register(target)
ContainerBlueprint.getContainerBlueprint().register(target)
}
}
}

View File

@@ -7,6 +7,7 @@ export * from './factory/Factory'
export * from './factory/NamedFactory'
export * from './factory/SingletonFactory'
export * from './ContainerBlueprint'
export * from './Container'
export * from './ScopedContainer'
export * from './types'

13
src/event/Event.ts Normal file
View File

@@ -0,0 +1,13 @@
import {Dispatchable} from './types'
import {Awaitable, JSONState} from '../util'
/**
* Abstract class representing an event that may be fired.
*/
export abstract class Event implements Dispatchable {
abstract dehydrate(): Awaitable<JSONState>
abstract rehydrate(state: JSONState): Awaitable<void>
}

53
src/event/EventBus.ts Normal file
View File

@@ -0,0 +1,53 @@
import {Singleton, StaticClass} from '../di'
import {Bus, Dispatchable, EventSubscriber, EventSubscriberEntry, EventSubscription} from './types'
import {Awaitable, Collection, uuid4} from '../util'
/**
* A non-queued bus implementation that executes subscribers immediately in the main thread.
*/
@Singleton()
export class EventBus implements Bus {
/**
* Collection of subscribers, by their events.
* @protected
*/
protected subscribers: Collection<EventSubscriberEntry<any>> = new Collection<EventSubscriberEntry<any>>()
subscribe<T extends Dispatchable>(event: StaticClass<T, T>, subscriber: EventSubscriber<T>): Awaitable<EventSubscription> {
const entry: EventSubscriberEntry<T> = {
id: uuid4(),
event,
subscriber,
}
this.subscribers.push(entry)
return this.buildSubscription(entry.id)
}
unsubscribe<T extends Dispatchable>(subscriber: EventSubscriber<T>): Awaitable<void> {
this.subscribers = this.subscribers.where('subscriber', '!=', subscriber)
}
async dispatch(event: Dispatchable): Promise<void> {
const eventClass: StaticClass<typeof event, typeof event> = event.constructor as StaticClass<Dispatchable, Dispatchable>
await this.subscribers.where('event', '=', eventClass)
.promiseMap(entry => entry.subscriber(event))
}
/**
* Build an EventSubscription object for the subscriber of the given ID.
* @param id
* @protected
*/
protected buildSubscription(id: string): EventSubscription {
let subscribed = true
return {
unsubscribe: (): Awaitable<void> => {
if ( subscribed ) {
this.subscribers = this.subscribers.where('id', '!=', id)
subscribed = false
}
},
}
}
}

View File

@@ -0,0 +1,28 @@
import {EventBus} from './EventBus'
import {Collection} from '../util'
import {Bus, Dispatchable} from './types'
/**
* A non-queued bus implementation that executes subscribers immediately in the main thread.
* This bus also supports "propagating" events along to any other connected buses.
* Such behavior is useful, e.g., if we want to have a semi-isolated request-
* level bus whose events still reach the global EventBus instance.
*/
export class PropagatingEventBus extends EventBus {
protected recipients: Collection<Bus> = new Collection<Bus>()
async dispatch(event: Dispatchable): Promise<void> {
await super.dispatch(event)
await this.recipients.promiseMap(bus => bus.dispatch(event))
}
/**
* Register the given bus to receive events fired on this bus.
* @param recipient
*/
connect(recipient: Bus): void {
if ( !this.recipients.includes(recipient) ) {
this.recipients.push(recipient)
}
}
}

47
src/event/types.ts Normal file
View File

@@ -0,0 +1,47 @@
import {Awaitable, Rehydratable} from '../util'
import {StaticClass} from '../di'
/**
* A closure that should be executed with the given event is fired.
*/
export type EventSubscriber<T extends Dispatchable> = (event: T) => Awaitable<void>
/**
* An object used to track event subscriptions internally.
*/
export interface EventSubscriberEntry<T extends Dispatchable> {
/** Globally unique ID of this subscription. */
id: string
/** The event class subscribed to. */
event: StaticClass<T, T>
/** The closure to execute when the event is fired. */
subscriber: EventSubscriber<T>
}
/**
* An object returned upon subscription, used to unsubscribe.
*/
export interface EventSubscription {
/**
* Unsubscribe the associated listener from the event bus.
*/
unsubscribe(): Awaitable<void>
}
/**
* An instance of something that can be fired on an event bus.
*/
export interface Dispatchable extends Rehydratable {
shouldQueue?: boolean
}
/**
* An event-driven bus that manages subscribers and dispatched items.
*/
export interface Bus {
subscribe<T extends Dispatchable>(eventClass: StaticClass<T, T>, subscriber: EventSubscriber<T>): Awaitable<EventSubscription>
unsubscribe<T extends Dispatchable>(subscriber: EventSubscriber<T>): Awaitable<void>
dispatch(event: Dispatchable): Awaitable<void>
}

View File

@@ -10,8 +10,9 @@ export class HTTPError extends ErrorWithContext {
constructor(
public readonly status: HTTPStatus = 500,
public readonly message: string = '',
context?: {[key: string]: any},
) {
super('HTTP ERROR')
super('HTTP ERROR', context)
this.message = message || HTTPMessage[status]
}
}

View File

@@ -4,6 +4,8 @@ import {Request} from '../../lifecycle/Request'
import {plaintext} from '../../response/StringResponseFactory'
import {ResponseFactory} from '../../response/ResponseFactory'
import {json} from '../../response/JSONResponseFactory'
import {UniversalPath} from '../../../util'
import {file} from '../../response/FileResponseFactory'
/**
* Base class for HTTP kernel modules that apply some response from a route handler to the request.
@@ -22,6 +24,8 @@ export abstract class AbstractResolvedRouteHandlerHTTPModule extends HTTPKernelM
if ( object instanceof ResponseFactory ) {
await object.write(request)
} else if ( object instanceof UniversalPath ) {
await file(object).write(request)
} else if ( typeof object !== 'undefined' ) {
await json(object).write(request)
} else {

View File

@@ -0,0 +1,28 @@
import {HTTPKernelModule} from '../HTTPKernelModule'
import {Inject, Injectable} from '../../../di'
import {HTTPKernel} from '../HTTPKernel'
import {Request} from '../../lifecycle/Request'
import {EventBus} from '../../../event/EventBus'
import {PropagatingEventBus} from '../../../event/PropagatingEventBus'
/**
* HTTP kernel module that creates a request-specific event bus
* and injects it into the request container.
*/
@Injectable()
export class InjectRequestEventBusHTTPModule extends HTTPKernelModule {
@Inject()
protected bus!: EventBus
public static register(kernel: HTTPKernel): void {
kernel.register(this).first()
}
public async apply(request: Request): Promise<Request> {
const bus = <PropagatingEventBus> this.make(PropagatingEventBus)
bus.connect(this.bus)
request.purge(EventBus).registerProducer(EventBus, () => bus)
return request
}
}

View File

@@ -28,7 +28,7 @@ export class MountActivatedRouteHTTPModule extends HTTPKernelModule {
const route = this.routing.match(request.method, request.path)
if ( route ) {
this.logging.verbose(`Mounting activated route: ${request.path} -> ${route}`)
const activated = new ActivatedRoute(route, request.path)
const activated = <ActivatedRoute> request.make(ActivatedRoute, route, request.path)
request.registerSingletonInstance<ActivatedRoute>(ActivatedRoute, activated)
} else {
this.logging.debug(`No matching route found for: ${request.method} -> ${request.path}`)

View File

@@ -2,6 +2,7 @@ import {Request} from './Request'
import {ErrorWithContext, HTTPStatus, BehaviorSubject} from '../../util'
import {ServerResponse} from 'http'
import {HTTPCookieJar} from '../kernel/HTTPCookieJar'
import {Readable} from 'stream'
/**
* Error thrown when the server tries to re-send headers after they have been sent once.
@@ -47,7 +48,7 @@ export class Response {
private isBlockingWriteback = false
/** The body contents that should be written to the response. */
public body = ''
public body: string | Buffer | Uint8Array | Readable = ''
/**
* Behavior subject fired right before the response content is written.
@@ -192,11 +193,21 @@ export class Response {
* Write the headers and specified data to the client.
* @param data
*/
public async write(data: unknown): Promise<void> {
public async write(data: string | Buffer | Uint8Array | Readable): Promise<void> {
return new Promise<void>((res, rej) => {
if ( !this.sentHeaders ) {
this.sendHeaders()
}
if ( data instanceof Readable ) {
data.pipe(this.serverResponse)
.on('finish', () => {
res()
})
.on('error', error => {
rej(error)
})
} else {
this.serverResponse.write(data, error => {
if ( error ) {
rej(error)
@@ -204,6 +215,7 @@ export class Response {
res()
}
})
}
})
}
@@ -212,9 +224,14 @@ export class Response {
*/
public async send(): Promise<void> {
await this.sending$.next(this)
if ( !(this.body instanceof Readable) ) {
this.setHeader('Content-Length', String(this.body?.length ?? 0))
}
await this.write(this.body ?? '')
this.end()
await this.sent$.next(this)
}

View File

@@ -0,0 +1,36 @@
import {ResponseFactory} from './ResponseFactory'
import {Request} from '../lifecycle/Request'
import {ErrorWithContext, UniversalPath} from '../../util'
/**
* Helper function that creates a FileResponseFactory for the given path.
* @param path
*/
export function file(path: UniversalPath): FileResponseFactory {
return new FileResponseFactory(path)
}
/**
* HTTP response factory that sends a file referenced by a given UniversalPath.
*/
export class FileResponseFactory extends ResponseFactory {
constructor(
/** The file to be sent. */
public readonly path: UniversalPath,
) {
super()
}
public async write(request: Request): Promise<Request> {
if ( !(await this.path.isFile()) ) {
throw new ErrorWithContext(`Cannot write non-file resource as response: ${this.path}`, {
path: this.path,
})
}
request.response.setHeader('Content-Type', this.path.contentType || 'application/octet-stream')
request.response.setHeader('Content-Length', String(await this.path.sizeInBytes()))
request.response.body = await this.path.readStream()
return request
}
}

View File

@@ -1,9 +1,11 @@
import {ErrorWithContext} from '../../util'
import {ResolvedRouteHandler, Route} from './Route'
import {Injectable} from '../../di'
/**
* Class representing a resolved route that a request is mounted to.
*/
@Injectable()
export class ActivatedRoute {
/**
* The parsed params from the route definition.

View File

@@ -87,11 +87,15 @@ export class Route extends AppClass {
for ( const group of stack ) {
route.prepend(group.prefix)
group.getGroupMiddlewareDefinitions()
.each(def => route.prependMiddleware(def))
.where('stage', '=', 'pre')
.each(def => {
route.prependMiddleware(def)
})
}
for ( const group of this.compiledGroupStack ) {
group.getGroupMiddlewareDefinitions()
.where('stage', '=', 'post')
.each(def => route.appendMiddleware(def))
}
@@ -224,6 +228,34 @@ export class Route extends AppClass {
super()
}
/**
* Get the string-form of the route.
*/
public getRoute(): string {
return this.route
}
/**
* Get the string-form method of the route.
*/
public getMethod(): HTTPMethod | HTTPMethod[] {
return this.method
}
/**
* Get collection of applied middlewares.
*/
public getMiddlewares(): Collection<{ stage: 'pre' | 'post', handler: RouteHandler }> {
return this.middlewares.clone()
}
/**
* Get the string-form of the route handler.
*/
public getDisplayableHandler(): string {
return typeof this.handler === 'string' ? this.handler : '(anonymous function)'
}
/**
* Returns true if this route matches the given HTTP verb and request path.
* @param method

169
src/http/servers/static.ts Normal file
View File

@@ -0,0 +1,169 @@
import {Request} from '../lifecycle/Request'
import {ActivatedRoute} from '../routing/ActivatedRoute'
import {Config} from '../../service/Config'
import {Collection, HTTPStatus, UniversalPath, universalPath} from '../../util'
import {Application} from '../../lifecycle/Application'
import {HTTPError} from '../HTTPError'
import {view, ViewResponseFactory} from '../response/ViewResponseFactory'
import {redirect} from '../response/TemporaryRedirectResponseFactory'
import {file} from '../response/FileResponseFactory'
import {RouteHandler} from '../routing/Route'
/**
* Defines the behavior of the static server.
*/
export interface StaticServerOptions {
/** If true, browsing to a directory route will show the directory listing page. */
directoryListing?: boolean
/** The path to the directory whose files should be served. */
basePath?: string | string[] | UniversalPath
/** If specified, only files with these extensions will be served. */
allowedExtensions?: string[]
/** If specified, files with these extensions will not be served. */
excludedExtensions?: string[]
}
/**
* HTTPError class thrown by the static server.
*/
export class StaticServerHTTPError extends HTTPError {
}
/**
* Build the response factory that shows the directory listing.
* @param dirname
* @param dirPath
*/
async function getDirectoryListingResponse(dirname: string, dirPath: UniversalPath): Promise<ViewResponseFactory> {
return view('@extollo:static:dirlist', {
dirname,
contents: (await (await dirPath.list())
.promiseMap(async path => {
const isDirectory = await path.isDirectory()
return {
isDirectory,
name: path.toBase,
size: isDirectory ? '-' : await path.sizeForHumans(),
}
}))
.sortBy(row => {
return `${row.isDirectory ? 0 : 1}${row.name}`
})
.all(),
})
}
/**
* Returns true if the given file path has an extension that is allowed by
* the static server options.
* @param filePath
* @param options
*/
function isValidFileExtension(filePath: UniversalPath, options: StaticServerOptions): boolean {
return (
(
!options.allowedExtensions
|| options.allowedExtensions.includes(filePath.ext)
)
&& (
!options.excludedExtensions
|| !options.excludedExtensions.includes(filePath.ext)
)
)
}
/**
* Resolve the configured base path into a universal path.
* Defaults to `{app path}/resources/static` if none provided.
* @param appPath
* @param basePath
*/
function getBasePath(appPath: UniversalPath, basePath?: string | string[] | UniversalPath): UniversalPath {
if ( basePath instanceof UniversalPath ) {
return basePath
}
if ( !basePath ) {
return appPath.concat('resources', 'static')
}
if ( Array.isArray(basePath) ) {
return appPath.concat(...basePath)
}
if ( basePath.startsWith('/') ) {
return universalPath(basePath)
}
return appPath.concat(basePath)
}
/**
* Get a route handler that serves a directory as static files.
* @param options
*/
export function staticServer(options: StaticServerOptions = {}): RouteHandler {
return async (request: Request) => {
const config = <Config> request.make(Config)
const route = <ActivatedRoute> request.make(ActivatedRoute)
const app = <Application> request.make(Application)
const staticConfig = config.get('server.builtIns.static', {})
const mergedOptions = {
...staticConfig,
...options,
}
// Resolve the path to the resource on the filesystem
const basePath = getBasePath(app.appPath(), mergedOptions.basePath)
const filePath = basePath.concat(...Collection.normalize<string>(route.params[0]))
// If the resolved path is outside of the base path, fail out
if ( !filePath.isChildOf(basePath) && !filePath.is(basePath) ) {
throw new StaticServerHTTPError(HTTPStatus.NOT_FOUND, 'File not found', {
basePath: basePath.toString(),
filePath: filePath.toString(),
route: route.path,
reason: 'Resolved file is not a child of the base path.',
})
}
// If the resolved file is an invalid file extension, fail out
if ( !isValidFileExtension(filePath, mergedOptions) ) {
throw new StaticServerHTTPError(HTTPStatus.NOT_FOUND, 'File not found', {
basePath: basePath.toString(),
filePath: filePath.toString(),
route: route.path,
allowedExtensions: mergedOptions.allowedExtensions,
excludedExtensions: mergedOptions.excludedExtensions,
reason: 'Resolved file is not an allowed extension type',
})
}
// If the resolved file does not exist on the filesystem, fail out
if ( !(await filePath.exists()) ) {
throw new StaticServerHTTPError(HTTPStatus.NOT_FOUND, `File not found: ${route.path}`, {
basePath: basePath.toString(),
filePath: filePath.toString(),
route: route.path,
reason: 'Resolved file does not exist on the filesystem',
})
}
// If the resolved path is a directory, send the directory listing response
if ( await filePath.isDirectory() ) {
if ( !route.path.endsWith('/') ) {
return redirect(`${route.path}/`)
}
return getDirectoryListingResponse(route.path, filePath)
}
// Otherwise, just send the file as the response body
return file(filePath)
}
}

View File

@@ -1,6 +1,12 @@
export * from './util'
export * from './lib'
export * from './di'
export * from './event/types'
export * from './event/Event'
export * from './event/EventBus'
export * from './event/PropagatingEventBus'
export * from './service/Logging'
export * from './lifecycle/RunLevelErrorHandler'
@@ -37,6 +43,7 @@ export * from './http/response/ResponseFactory'
export * from './http/response/StringResponseFactory'
export * from './http/response/TemporaryRedirectResponseFactory'
export * from './http/response/ViewResponseFactory'
export * from './http/response/FileResponseFactory'
export * from './http/routing/ActivatedRoute'
export * from './http/routing/Route'
@@ -51,6 +58,8 @@ export * from './http/session/MemorySession'
export * from './http/Controller'
export * from './http/servers/static'
export * from './service/Canonical'
export * from './service/CanonicalInstantiable'
export * from './service/CanonicalRecursive'
@@ -65,6 +74,7 @@ export * from './service/Middlewares'
export * from './support/cache/MemoryCache'
export * from './support/cache/CacheFactory'
export * from './support/NodeModules'
export * from './views/ViewEngine'
export * from './views/ViewEngineFactory'
@@ -74,3 +84,4 @@ export * from './cli'
export * from './i18n'
export * from './forms'
export * from './orm'
export * from './auth'

8
src/lib.ts Normal file
View File

@@ -0,0 +1,8 @@
import {UniversalPath} from './util'
/**
* Get the path to the root of the @extollo/lib package.
*/
export function lib(): UniversalPath {
return new UniversalPath(__dirname)
}

View File

@@ -1,5 +1,5 @@
import {Application} from './Application'
import {Container, DependencyKey} from '../di'
import {Container, DependencyKey, Injectable} from '../di'
/**
* Base type for a class that supports binding methods by string.
@@ -25,12 +25,11 @@ export function isBindable(what: unknown): what is Bindable {
/**
* Base for classes that gives access to the global application and container.
*/
@Injectable()
export class AppClass {
/** The global application instance. */
private readonly appClassApplication!: Application;
constructor() {
this.appClassApplication = Application.getApplication()
private get appClassApplication(): Application {
return Application.getApplication()
}
/** Get the global Application. */

View File

@@ -1,4 +1,4 @@
import {Container} from '../di'
import {Container, ContainerBlueprint} from '../di'
import {
ErrorWithContext,
globalRegistry,
@@ -48,10 +48,21 @@ export function appPath(...parts: PathLike[]): UniversalPath {
* The main application container.
*/
export class Application extends Container {
public static readonly NODE_MODULES_INJECTION = 'extollo/npm'
public static get NODE_MODULES_PROVIDER(): string {
return process.env.EXTOLLO_NPM || 'pnpm'
}
public static getContainer(): Container {
const existing = <Container | undefined> globalRegistry.getGlobal('extollo/injector')
if ( !existing ) {
const container = new Application()
ContainerBlueprint.getContainerBlueprint()
.resolve()
.map(factory => container.registerFactory(factory))
globalRegistry.setGlobal('extollo/injector', container)
return container
}
@@ -74,6 +85,11 @@ export class Application extends Container {
return app
} else {
const app = new Application()
ContainerBlueprint.getContainerBlueprint()
.resolve()
.map(factory => app.registerFactory(factory))
globalRegistry.setGlobal('extollo/injector', app)
return app
}
@@ -195,6 +211,7 @@ export class Application extends Container {
this.setupLogging()
this.registerFactory(new CacheFactory()) // FIXME move this somewhere else?
this.registerSingleton(Application.NODE_MODULES_INJECTION, Application.NODE_MODULES_PROVIDER)
this.make<Logging>(Logging).debug(`Application root: ${this.baseDir}`)
}

View File

@@ -2,6 +2,9 @@ import {ErrorWithContext} from '../../util'
import {QueryResult} from '../types'
import {SQLDialect} from '../dialect/SQLDialect'
import {AppClass} from '../../lifecycle/AppClass'
import {Inject, Injectable} from '../../di'
import {EventBus} from '../../event/EventBus'
import {QueryExecutedEvent} from './event/QueryExecutedEvent'
/**
* Error thrown when a connection is used before it is ready.
@@ -18,7 +21,10 @@ export class ConnectionNotReadyError extends ErrorWithContext {
* Abstract base class for database connections.
* @abstract
*/
@Injectable()
export abstract class Connection extends AppClass {
@Inject()
protected bus!: EventBus
constructor(
/**
@@ -64,4 +70,14 @@ export abstract class Connection extends AppClass {
// public abstract tables(database_name: string): Promise<Collection<Table>>
// public abstract table(database_name: string, table_name: string): Promise<Table | undefined>
/**
* Fire a QueryExecutedEvent for the given query string.
* @param query
* @protected
*/
protected async queryExecuted(query: string): Promise<void> {
const event = new QueryExecutedEvent(this.name, this, query)
await this.bus.dispatch(event)
}
}

View File

@@ -54,6 +54,7 @@ export class PostgresConnection extends Connection {
try {
const result = await this.client.query(query)
await this.queryExecuted(query)
return {
rows: collect(result.rows),

View File

@@ -0,0 +1,67 @@
import {Event} from '../../../event/Event'
import {Inject, Injectable} from '../../../di'
import {InvalidJSONStateError, JSONState} from '../../../util'
import {Connection} from '../Connection'
import {DatabaseService} from '../../DatabaseService'
/**
* Event fired when a query is executed.
*/
@Injectable()
export class QueryExecutedEvent extends Event {
@Inject()
protected database!: DatabaseService
/**
* The name of the connection where the query was executed.
* @protected
*/
public connectionName!: string
/**
* The connection where the query was executed.
*/
public connection!: Connection
/**
* The query that was executed.
*/
public query!: string
constructor(
connectionName?: string,
connection?: Connection,
query?: string,
) {
super()
if ( connectionName ) {
this.connectionName = connectionName
}
if ( connection ) {
this.connection = connection
}
if ( query ) {
this.query = query
}
}
async dehydrate(): Promise<JSONState> {
return {
connectionName: this.connectionName,
query: this.query,
}
}
rehydrate(state: JSONState): void {
if ( !state.connectionName || !state.query ) {
throw new InvalidJSONStateError('Missing connectionName or query from QueryExecutedEvent state.')
}
this.query = String(state.query)
this.connectionName = String(state.connectionName)
this.connection = this.database.get(this.connectionName)
}
}

View File

@@ -15,6 +15,7 @@ export * from './model/Field'
export * from './model/ModelBuilder'
export * from './model/ModelBuilder'
export * from './model/ModelResultIterable'
export * from './model/events'
export * from './model/Model'
export * from './services/Database'

View File

@@ -1,20 +1,32 @@
import {ModelKey, QueryRow, QuerySource} from '../types'
import {Container, Inject} from '../../di'
import {Container, Inject, StaticClass} from '../../di'
import {DatabaseService} from '../DatabaseService'
import {ModelBuilder} from './ModelBuilder'
import {getFieldsMeta, ModelField} from './Field'
import {deepCopy, BehaviorSubject, Pipe, Collection} from '../../util'
import {deepCopy, Pipe, Collection, Awaitable, uuid4} from '../../util'
import {EscapeValueObject} from '../dialect/SQLDialect'
import {AppClass} from '../../lifecycle/AppClass'
import {Logging} from '../../service/Logging'
import {Connection} from '../connection/Connection'
import {Bus, Dispatchable, EventSubscriber, EventSubscriberEntry, EventSubscription} from '../../event/types'
import {ModelRetrievedEvent} from './events/ModelRetrievedEvent'
import {ModelSavingEvent} from './events/ModelSavingEvent'
import {ModelSavedEvent} from './events/ModelSavedEvent'
import {ModelUpdatingEvent} from './events/ModelUpdatingEvent'
import {ModelUpdatedEvent} from './events/ModelUpdatedEvent'
import {ModelCreatingEvent} from './events/ModelCreatingEvent'
import {ModelCreatedEvent} from './events/ModelCreatedEvent'
import {EventBus} from '../../event/EventBus'
/**
* Base for classes that are mapped to tables in a database.
*/
export abstract class Model<T extends Model<T>> extends AppClass {
export abstract class Model<T extends Model<T>> extends AppClass implements Bus {
@Inject()
protected readonly logging!: Logging;
protected readonly logging!: Logging
@Inject()
protected readonly bus!: EventBus
/**
* The name of the connection this model should run through.
@@ -78,49 +90,10 @@ export abstract class Model<T extends Model<T>> extends AppClass {
protected originalSourceRow?: QueryRow
/**
* Behavior subject that fires after the model is populated.
* Collection of event subscribers, by their events.
* @protected
*/
protected retrieved$ = new BehaviorSubject<Model<T>>()
/**
* Behavior subject that fires right before the model is saved.
*/
protected saving$ = new BehaviorSubject<Model<T>>()
/**
* Behavior subject that fires right after the model is saved.
*/
protected saved$ = new BehaviorSubject<Model<T>>()
/**
* Behavior subject that fires right before the model is updated.
*/
protected updating$ = new BehaviorSubject<Model<T>>()
/**
* Behavior subject that fires right after the model is updated.
*/
protected updated$ = new BehaviorSubject<Model<T>>()
/**
* Behavior subject that fires right before the model is inserted.
*/
protected creating$ = new BehaviorSubject<Model<T>>()
/**
* Behavior subject that fires right after the model is inserted.
*/
protected created$ = new BehaviorSubject<Model<T>>()
/**
* Behavior subject that fires right before the model is deleted.
*/
protected deleting$ = new BehaviorSubject<Model<T>>()
/**
* Behavior subject that fires right after the model is deleted.
*/
protected deleted$ = new BehaviorSubject<Model<T>>()
protected modelEventBusSubscribers: Collection<EventSubscriberEntry<any>> = new Collection<EventSubscriberEntry<any>>()
/**
* Get the table name for this model.
@@ -193,9 +166,16 @@ export abstract class Model<T extends Model<T>> extends AppClass {
values?: {[key: string]: any},
) {
super()
this.initialize()
this.boot(values)
}
/**
* Called when the model is instantiated. Use for any setup of events, &c.
* @protected
*/
protected initialize(): void {} // eslint-disable-line @typescript-eslint/no-empty-function
/**
* Initialize the model's properties from the given values and do any other initial setup.
*
@@ -228,7 +208,7 @@ export abstract class Model<T extends Model<T>> extends AppClass {
this.setFieldFromObject(field.modelKey, field.databaseKey, row)
})
await this.retrieved$.next(this)
await this.dispatch(new ModelRetrievedEvent<T>(this as any))
return this
}
@@ -592,11 +572,11 @@ export abstract class Model<T extends Model<T>> extends AppClass {
* @param withoutTimestamps
*/
public async save({ withoutTimestamps = false } = {}): Promise<Model<T>> {
await this.saving$.next(this)
await this.dispatch(new ModelSavingEvent<T>(this as any))
const ctor = this.constructor as typeof Model
if ( this.exists() && this.isDirty() ) {
await this.updating$.next(this)
await this.dispatch(new ModelUpdatingEvent<T>(this as any))
if ( !withoutTimestamps && ctor.timestamps && ctor.UPDATED_AT ) {
(this as any)[ctor.UPDATED_AT] = new Date()
@@ -617,9 +597,9 @@ export abstract class Model<T extends Model<T>> extends AppClass {
await this.assumeFromSource(data)
}
await this.updated$.next(this)
await this.dispatch(new ModelUpdatedEvent<T>(this as any))
} else if ( !this.exists() ) {
await this.creating$.next(this)
await this.dispatch(new ModelCreatingEvent<T>(this as any))
if ( !withoutTimestamps ) {
if ( ctor.timestamps && ctor.CREATED_AT ) {
@@ -647,10 +627,11 @@ export abstract class Model<T extends Model<T>> extends AppClass {
if ( data ) {
await this.assumeFromSource(result)
}
await this.created$.next(this)
await this.dispatch(new ModelCreatedEvent<T>(this as any))
}
await this.saved$.next(this)
await this.dispatch(new ModelSavedEvent<T>(this as any))
return this
}
@@ -822,4 +803,44 @@ export abstract class Model<T extends Model<T>> extends AppClass {
protected setFieldFromObject(thisFieldName: string | symbol, objectFieldName: string, object: QueryRow): void {
(this as any)[thisFieldName] = object[objectFieldName]
}
subscribe<EventT extends Dispatchable>(event: StaticClass<EventT, EventT>, subscriber: EventSubscriber<EventT>): Awaitable<EventSubscription> {
const entry: EventSubscriberEntry<EventT> = {
id: uuid4(),
event,
subscriber,
}
this.modelEventBusSubscribers.push(entry)
return this.buildSubscription(entry.id)
}
unsubscribe<EventT extends Dispatchable>(subscriber: EventSubscriber<EventT>): Awaitable<void> {
this.modelEventBusSubscribers = this.modelEventBusSubscribers.where('subscriber', '!=', subscriber)
}
async dispatch(event: Dispatchable): Promise<void> {
const eventClass: StaticClass<typeof event, typeof event> = event.constructor as StaticClass<Dispatchable, Dispatchable>
await this.modelEventBusSubscribers.where('event', '=', eventClass)
.promiseMap(entry => entry.subscriber(event))
await this.bus.dispatch(event)
}
/**
* Build an EventSubscription object for the subscriber of the given ID.
* @param id
* @protected
*/
protected buildSubscription(id: string): EventSubscription {
let subscribed = true
return {
unsubscribe: (): Awaitable<void> => {
if ( subscribed ) {
this.modelEventBusSubscribers = this.modelEventBusSubscribers.where('id', '!=', id)
subscribed = false
}
},
}
}
}

View File

@@ -0,0 +1,9 @@
import {Model} from '../Model'
import {ModelEvent} from './ModelEvent'
/**
* Event fired right after a model is inserted.
*/
export class ModelCreatedEvent<T extends Model<T>> extends ModelEvent<T> {
}

View File

@@ -0,0 +1,9 @@
import {Model} from '../Model'
import {ModelEvent} from './ModelEvent'
/**
* Event fired right before a model is inserted.
*/
export class ModelCreatingEvent<T extends Model<T>> extends ModelEvent<T> {
}

View File

@@ -0,0 +1,9 @@
import {Model} from '../Model'
import {ModelEvent} from './ModelEvent'
/**
* Event fired right after a model is deleted.
*/
export class ModelDeletedEvent<T extends Model<T>> extends ModelEvent<T> {
}

View File

@@ -0,0 +1,9 @@
import {Model} from '../Model'
import {ModelEvent} from './ModelEvent'
/**
* Event fired right before a model is deleted.
*/
export class ModelDeletingEvent<T extends Model<T>> extends ModelEvent<T> {
}

View File

@@ -0,0 +1,31 @@
import {Model} from '../Model'
import {Event} from '../../../event/Event'
import {JSONState} from '../../../util'
/**
* Base class for events that concern an instance of a model.
*/
export abstract class ModelEvent<T extends Model<T>> extends Event {
/**
* The instance of the model.
*/
public instance!: T
constructor(
instance?: T,
) {
super()
if ( instance ) {
this.instance = instance
}
}
// TODO implement serialization here
dehydrate(): Promise<JSONState> {
return Promise.resolve({})
}
rehydrate(/* state: JSONState */): void | Promise<void> {
return undefined
}
}

View File

@@ -0,0 +1,9 @@
import {Model} from '../Model'
import {ModelEvent} from './ModelEvent'
/**
* Event fired right after a model's data is loaded from the source.
*/
export class ModelRetrievedEvent<T extends Model<T>> extends ModelEvent<T> {
}

View File

@@ -0,0 +1,9 @@
import {Model} from '../Model'
import {ModelEvent} from './ModelEvent'
/**
* Event fired right after a model is persisted to the source.
*/
export class ModelSavedEvent<T extends Model<T>> extends ModelEvent<T> {
}

View File

@@ -0,0 +1,9 @@
import {Model} from '../Model'
import {ModelEvent} from './ModelEvent'
/**
* Event fired right before a model is persisted to the source.
*/
export class ModelSavingEvent<T extends Model<T>> extends ModelEvent<T> {
}

View File

@@ -0,0 +1,9 @@
import {Model} from '../Model'
import {ModelEvent} from './ModelEvent'
/**
* Event fired right after a model's data is updated.
*/
export class ModelUpdatedEvent<T extends Model<T>> extends ModelEvent<T> {
}

View File

@@ -0,0 +1,9 @@
import {Model} from '../Model'
import {ModelEvent} from './ModelEvent'
/**
* Event fired right before a model's data is updated.
*/
export class ModelUpdatingEvent<T extends Model<T>> extends ModelEvent<T> {
}

View File

@@ -0,0 +1,21 @@
import {ModelCreatedEvent} from './ModelCreatedEvent'
import {ModelUpdatingEvent} from './ModelUpdatingEvent'
import {ModelCreatingEvent} from './ModelCreatingEvent'
import {ModelSavedEvent} from './ModelSavedEvent'
import {ModelDeletedEvent} from './ModelDeletedEvent'
import {ModelDeletingEvent} from './ModelDeletingEvent'
import {ModelRetrievedEvent} from './ModelRetrievedEvent'
import {ModelUpdatedEvent} from './ModelUpdatedEvent'
import {ModelEvent} from './ModelEvent'
export const ModelEvents = {
ModelCreatedEvent,
ModelCreatingEvent,
ModelDeletedEvent,
ModelDeletingEvent,
ModelEvent,
ModelRetrievedEvent,
ModelSavedEvent,
ModelUpdatedEvent,
ModelUpdatingEvent,
}

14
src/orm/schema/Schema.ts Normal file
View File

@@ -0,0 +1,14 @@
import {Connection} from '../connection/Connection'
import {Awaitable} from '../../util'
export abstract class Schema {
constructor(
protected readonly connection: Connection,
) { }
public abstract hasTable(name: string): Awaitable<boolean>
public abstract hasColumn(table: string, name: string): Awaitable<boolean>
public abstract hasColumns(table: string, name: string[]): Awaitable<boolean>
}

View File

@@ -0,0 +1,109 @@
import {Pipe} from '../../util'
export abstract class SchemaBuilderBase {
protected shouldDrop: 'yes'|'no'|'exists' = 'no'
protected shouldRenameTo?: string
constructor(
protected readonly name: string,
) { }
public drop(): this {
this.shouldDrop = 'yes'
return this
}
public dropIfExists(): this {
this.shouldDrop = 'exists'
return this
}
public rename(to: string): this {
this.shouldRenameTo = to
return this
}
pipe(): Pipe<this> {
return Pipe.wrap<this>(this)
}
}
export class ColumnBuilder extends SchemaBuilderBase {
}
export class IndexBuilder extends SchemaBuilderBase {
protected fields: Set<string> = new Set<string>()
protected removedFields: Set<string> = new Set<string>()
protected shouldBeUnique = false
protected shouldBePrimary = false
protected field(name: string): this {
this.fields.add(name)
return this
}
protected removeField(name: string): this {
this.removedFields.add(name)
this.fields.delete(name)
return this
}
primary(): this {
this.shouldBePrimary = true
return this
}
unique(): this {
this.shouldBeUnique = true
return this
}
}
export class TableBuilder extends SchemaBuilderBase {
protected columns: {[key: string]: ColumnBuilder} = {}
protected indexes: {[key: string]: IndexBuilder} = {}
public dropColumn(name: string): this {
this.column(name).drop()
return this
}
public renameColumn(from: string, to: string): this {
this.column(from).rename(to)
return this
}
public dropIndex(name: string): this {
this.index(name).drop()
return this
}
public renameIndex(from: string, to: string): this {
this.index(from).rename(to)
return this
}
public column(name: string) {
if ( !this.columns[name] ) {
this.columns[name] = new ColumnBuilder(name)
}
return this.columns[name]
}
public index(name: string) {
if ( !this.indexes[name] ) {
this.indexes[name] = new IndexBuilder(name)
}
return this.indexes[name]
}
}

View File

@@ -0,0 +1,12 @@
extends ./theme
block content
h3.login-heading.mb-4
block heading
if errors
each error in errors
p.form-error-message #{error}
form(method='post' enctype='multipart/form-data')
block form

View File

@@ -0,0 +1,22 @@
extends ./form
block head
title Login | #{config('app.name', 'Extollo')}
block heading
| Login to Continue
block form
.form-label-group
input#inputUsername.form-control(type='text' name='username' value=(form_data ? form_data.username : '') required placeholder='Username' autofocus)
label(for='inputUsername') Username
.form-label-group
input#inputPassword.form-control(type='password' name='password' required placeholder='Password')
label(for='inputPassword') Password
button.btn.btn-lg.btn-primary.btn-block.btn-login.text-uppercase.font-weight-bold.mb-2.form-submit-button(type='submit') Login
.text-center
span.small Need an account?&nbsp;
a(href='./register') Register here.
// .text-center
span.small(style="color: #999999;") Provider: #{provider_name}

View File

@@ -0,0 +1,22 @@
html
head
meta(name='viewport' content='width=device-width initial-scale=1')
block head
block styles
link(rel='stylesheet' href='https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css')
link(rel='stylesheet' href=vendor('@extollo', 'auth/theme.css'))
body
.container-fluid
.row.no-gutter
.d-none.d-md-flex.col-md-6.col-lg-8.bg-image
.col-md-6.col-lg-4
.login.d-flex.align-items-center.py-5
.container
.row
.col-md-9.col-lg-8.mx-auto
block content
block scripts
script(src='https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css')

View File

@@ -0,0 +1,45 @@
doctype html
html
head
title Index of #{dirname}
style.
body {
font-family: Arial, sans-serif;
}
table {
border-collapse: collapse;
width: 100%;
}
td, th {
border: 1px solid #dddddd;
text-align: left;
padding: 8px;
}
tr:nth-child(even) {
background-color: #dddddd;
}
body
h1 Directory Listing
h2 #{dirname}
table
tr
th Name
th Type
th Size
tr
td 📂&nbsp;
a(href='..') ..
td Directory
td -
each entry in contents
tr
td #{entry.isDirectory ? '📂 ' : ''}
a(href='./' + entry.name) #{entry.name}
td #{entry.isDirectory ? 'Directory' : 'File'}
td #{entry.size}
if !config('server.poweredBy.hide', false)
hr
small retrieved at #{(new Date).toDateString()} #{(new Date).toTimeString()} | powered by <a href="https://extollo.garrettmills.dev/" target="_blank">Extollo</a>

View File

@@ -16,6 +16,7 @@ import {ExecuteResolvedRoutePreflightHTTPModule} from '../http/kernel/module/Exe
import {ExecuteResolvedRoutePostflightHTTPModule} from '../http/kernel/module/ExecuteResolvedRoutePostflightHTTPModule'
import {ParseIncomingBodyHTTPModule} from '../http/kernel/module/ParseIncomingBodyHTTPModule'
import {Config} from './Config'
import {InjectRequestEventBusHTTPModule} from '../http/kernel/module/InjectRequestEventBusHTTPModule'
/**
* Application unit that starts the HTTP/S server, creates Request and Response objects
@@ -48,6 +49,7 @@ export class HTTPServer extends Unit {
ExecuteResolvedRoutePreflightHTTPModule.register(this.kernel)
ExecuteResolvedRoutePostflightHTTPModule.register(this.kernel)
ParseIncomingBodyHTTPModule.register(this.kernel)
InjectRequestEventBusHTTPModule.register(this.kernel)
await new Promise<void>(res => {
this.server = createServer(this.handler)

View File

@@ -7,14 +7,14 @@ import {Middleware} from '../http/routing/Middleware'
* A canonical unit that loads the middleware classes from `app/http/middlewares`.
*/
@Singleton()
export class Middlewares extends CanonicalStatic<Instantiable<Middleware>, Middleware> {
export class Middlewares extends CanonicalStatic<Middleware, Instantiable<Middleware>> {
protected appPath = ['http', 'middlewares']
protected canonicalItem = 'middleware'
protected suffix = '.middleware.js'
public async initCanonicalItem(definition: CanonicalDefinition): Promise<StaticClass<Instantiable<Middleware>, Middleware>> {
public async initCanonicalItem(definition: CanonicalDefinition): Promise<StaticClass<Middleware, Instantiable<Middleware>>> {
const item = await super.initCanonicalItem(definition)
if ( !(item.prototype instanceof Middleware) ) {
throw new TypeError(`Invalid middleware definition: ${definition.originalName}. Controllers must extend from @extollo/lib.http.routing.Middleware.`)

View File

@@ -1,10 +1,13 @@
import {Singleton, Inject} from '../di'
import {UniversalPath, Collection} from '../util'
import {UniversalPath, Collection, Pipe, universalPath} from '../util'
import {Unit} from '../lifecycle/Unit'
import {Logging} from './Logging'
import {Route} from '../http/routing/Route'
import {HTTPMethod} from '../http/lifecycle/Request'
import {ViewEngineFactory} from '../views/ViewEngineFactory'
import {ViewEngine} from '../views/ViewEngine'
import {lib} from '../lib'
import {Config} from './Config'
/**
* Application unit that loads the various route files from `app/http/routes` and pre-compiles the route handlers.
@@ -14,10 +17,16 @@ export class Routing extends Unit {
@Inject()
protected readonly logging!: Logging
@Inject()
protected readonly config!: Config
protected compiledRoutes: Collection<Route> = new Collection<Route>()
public async up(): Promise<void> {
this.app().registerFactory(new ViewEngineFactory())
const engine = <ViewEngine> this.make(ViewEngine)
this.logging.verbose('Registering @extollo view engine namespace.')
engine.registerNamespace('extollo', lib().concat('resources', 'views'))
for await ( const entry of this.path.walk() ) {
if ( !entry.endsWith('.routes.js') ) {
@@ -56,4 +65,62 @@ export class Routing extends Unit {
public get path(): UniversalPath {
return this.app().appPath('http', 'routes')
}
/**
* Get the collection of compiled routes.
*/
public getCompiled(): Collection<Route> {
return this.compiledRoutes
}
/**
* Resolve a UniversalPath to a file served as an asset.
* @example
* ```ts
* this.getAssetPath('images', '123.jpg').toRemote // => http://localhost:8000/assets/images/123.jpg
* ```
* @param parts
*/
public getAssetPath(...parts: string[]): UniversalPath {
return this.getAssetBase().concat(...parts)
}
public getAssetBase(): UniversalPath {
return this.getAppUrl().concat(this.config.get('server.builtIns.assets.prefix', '/assets'))
}
public getVendorPath(namespace: string, ...parts: string[]): UniversalPath {
return this.getVendorBase().concat(encodeURIComponent(namespace), ...parts)
}
public getVendorBase(): UniversalPath {
return this.getAppUrl().concat(this.config.get('server.builtIns.vendor.prefix', '/vendor'))
}
public getAppUrl(): UniversalPath {
const rawHost = String(this.config.get('server.url', 'http://localhost')).toLowerCase()
const isSSL = rawHost.startsWith('https://')
const port = this.config.get('server.port', 8000)
return Pipe.wrap<string>(rawHost)
.unless(
host => host.startsWith('http://') || host.startsWith('https'),
host => `http://${host}`,
)
.when(
host => {
const hasPort = host.split(':').length > 2
const defaultRaw = !isSSL && port === 80
const defaultSSL = isSSL && port === 443
return !hasPort && !defaultRaw && !defaultSSL
},
host => {
const parts = host.split('/')
parts[2] += `:${port}`
return parts.join('/')
},
)
.tap<UniversalPath>(host => universalPath(host))
.get()
}
}

112
src/support/NodeModules.ts Normal file
View File

@@ -0,0 +1,112 @@
import * as childProcess from 'child_process'
import {UniversalPath} from '../util'
import {Inject, Injectable, InjectParam} from '../di'
import {Application} from '../lifecycle/Application'
import {Logging} from '../service/Logging'
import {NodeModule, ExtolloAwareNodeModule} from './types'
import {EventBus} from '../event/EventBus'
import {PackageDiscovered} from './PackageDiscovered'
/**
* A helper class for discovering and interacting with
* NPM-style modules.
*/
@Injectable()
export class NodeModules {
@Inject()
protected readonly logging!: Logging
@Inject()
protected readonly bus!: EventBus
constructor(
@InjectParam(Application.NODE_MODULES_INJECTION)
protected readonly manager: string,
) { }
/**
* Get the NodeModule entry for the base application.
*/
async app(): Promise<NodeModule> {
return new Promise<NodeModule>((res, rej) => {
childProcess.exec(`${this.manager} ls --json`, (error, stdout) => {
if ( error ) {
return rej(error)
}
res(JSON.parse(stdout)[0])
})
})
}
/**
* Get the path to the node_modules folder for the base application.
*/
async root(): Promise<UniversalPath> {
return new Promise<UniversalPath>((res, rej) => {
childProcess.exec(`${this.manager} root`, (error, stdout) => {
if ( error ) {
return rej(error)
}
res(new UniversalPath(stdout.trim()))
})
})
}
/**
* Iterate over packages, recursively, starting with the base application's
* package.json and fire PackageDiscovered events for any that have a valid
* Extollo discovery entry.
*/
async discover(): Promise<void> {
const root = await this.root()
const module = await this.app()
return this.discoverRoot(root, module)
}
/**
* Recursively discover child-packages from the node_modules root for the
* given module.
*
* Fires PackageDiscovered events for valid, discovery-enabled packages.
*
* @param root - the path to node_modules
* @param module - the module whose children we are discovering
* @protected
*/
protected async discoverRoot(root: UniversalPath, module: NodeModule): Promise<void> {
for ( const key in module.dependencies ) {
if ( !Object.prototype.hasOwnProperty.call(module.dependencies, key) ) {
continue
}
this.logging.verbose(`Auto-discovery considering package: ${key}`)
try {
const packageJson = root.concat(key, 'package.json')
this.logging.verbose(`Auto-discovery package path: ${packageJson}`)
if ( await packageJson.exists() ) {
const packageJsonString: string = await packageJson.read()
const packageJsonData: ExtolloAwareNodeModule = JSON.parse(packageJsonString)
if ( !packageJsonData?.extollo?.discover ) {
this.logging.debug(`Skipping non-discoverable package: ${key}`)
continue
}
this.logging.info(`Auto-discovering package: ${key}`)
await this.bus.dispatch(new PackageDiscovered(packageJsonData, packageJson.clone()))
const packageNodeModules = packageJson.concat('..', 'node_modules')
if ( await packageNodeModules.exists() && packageJsonData?.extollo?.recursiveDependencies?.discover ) {
this.logging.debug(`Recursing: ${packageNodeModules}`)
await this.discoverRoot(packageNodeModules, packageJsonData)
}
}
} catch (e: unknown) {
this.logging.error(`Encountered error while discovering package: ${key}`)
this.logging.error(e)
}
}
}
}

View File

@@ -0,0 +1,33 @@
import {Event} from '../event/Event'
import {Awaitable, JSONState, UniversalPath} from '../util'
import {ExtolloAwareNodeModule} from './types'
/**
* An event indicating that an NPM package has been discovered
* by the framework.
*
* Application services can listen for this event to register
* various discovery logic (e.g. automatically boot units
*/
export class PackageDiscovered extends Event {
constructor(
public packageConfig: ExtolloAwareNodeModule,
public packageJson: UniversalPath,
) {
super()
}
dehydrate(): Awaitable<JSONState> {
return {
packageConfig: this.packageConfig as JSONState,
packageJson: this.packageJson.toString(),
}
}
rehydrate(state: JSONState): Awaitable<void> {
if ( typeof state === 'object' ) {
this.packageConfig = (state.packageConfig as ExtolloAwareNodeModule)
this.packageJson = new UniversalPath(String(state.packageJson))
}
}
}

45
src/support/types.ts Normal file
View File

@@ -0,0 +1,45 @@
/**
* Partial package.json that may contain a partial Extollo discovery config.
*/
export interface ExtolloPackageDiscoveryConfig {
extollo?: {
discover?: boolean,
units?: {
discover?: boolean,
paths?: string[],
},
recursiveDependencies?: {
discover?: boolean,
},
},
}
/**
* Interface that defines a NodeModule dependency.
*/
export interface NodeDependencySpecEntry {
from: string,
version: string,
resolved?: string,
dependencies?: {[key: string]: NodeDependencySpecEntry},
devDependencies?: {[key: string]: NodeDependencySpecEntry},
unsavedDependencies?: {[key: string]: NodeDependencySpecEntry},
optionalDependencies?: {[key: string]: NodeDependencySpecEntry},
}
/**
* Defines information and dependencies of an NPM package.
*/
export interface NodeModule {
name?: string,
version?: string,
dependencies?: {[key: string]: NodeDependencySpecEntry},
devDependencies?: {[key: string]: NodeDependencySpecEntry},
unsavedDependencies?: {[key: string]: NodeDependencySpecEntry},
optionalDependencies?: {[key: string]: NodeDependencySpecEntry},
}
/**
* Type alias for a NodeModule that contains an ExtolloPackageDiscoveryConfig.
*/
export type ExtolloAwareNodeModule = NodeModule & ExtolloPackageDiscoveryConfig

View File

@@ -50,6 +50,20 @@ class Collection<T> {
return new Collection(items)
}
/**
* Create a new collection from an item or array of items.
* Filters out undefined items.
* @param itemOrItems
*/
public static normalize<T2>(itemOrItems: (CollectionItem<T2> | undefined)[] | CollectionItem<T2> | undefined): Collection<T2> {
if ( !Array.isArray(itemOrItems) ) {
itemOrItems = [itemOrItems]
}
const items = itemOrItems.filter(x => typeof x !== 'undefined') as CollectionItem<T2>[]
return new Collection<T2>(items)
}
/**
* Create a collection of "undefined" elements of a given size.
* @param size

View File

@@ -8,6 +8,11 @@ export type PipeOperator<T, T2> = (subject: T) => T2
*/
export type ReflexivePipeOperator<T> = (subject: T) => T
/**
* A condition or condition-resolving function for pipe methods.
*/
export type PipeCondition<T> = boolean | ((subject: T) => boolean)
/**
* A class for writing chained/conditional operations in a data-flow manner.
*
@@ -79,8 +84,8 @@ export class Pipe<T> {
* @param check
* @param op
*/
when(check: boolean, op: ReflexivePipeOperator<T>): Pipe<T> {
if ( check ) {
when(check: PipeCondition<T>, op: ReflexivePipeOperator<T>): Pipe<T> {
if ( (typeof check === 'function' && check(this.subject)) || check ) {
return Pipe.wrap(op(this.subject))
}
@@ -94,8 +99,12 @@ export class Pipe<T> {
* @param check
* @param op
*/
unless(check: boolean, op: ReflexivePipeOperator<T>): Pipe<T> {
return this.when(!check, op)
unless(check: PipeCondition<T>, op: ReflexivePipeOperator<T>): Pipe<T> {
if ( (typeof check === 'function' && check(this.subject)) || check ) {
return this
}
return Pipe.wrap(op(this.subject))
}
/**
@@ -103,7 +112,7 @@ export class Pipe<T> {
* @param check
* @param op
*/
whenNot(check: boolean, op: ReflexivePipeOperator<T>): Pipe<T> {
whenNot(check: PipeCondition<T>, op: ReflexivePipeOperator<T>): Pipe<T> {
return this.unless(check, op)
}

View File

@@ -2,6 +2,7 @@
* Type representing a JSON serializable object.
*/
import {ErrorWithContext} from '../error/ErrorWithContext'
import {Awaitable} from './types'
export type JSONState = { [key: string]: string | boolean | number | undefined | JSONState | Array<string | boolean | number | undefined | JSONState> }
@@ -30,14 +31,14 @@ export function isJSONState(what: unknown): what is JSONState {
export interface Rehydratable {
/**
* Dehydrate this class' state and get it.
* @return Promise<JSONState>
* @return JSONState|Promise<JSONState>
*/
dehydrate(): Promise<JSONState>
dehydrate(): Awaitable<JSONState>
/**
* Rehydrate a state into this class.
* @param {JSONState} state
* @return void|Promise<void>
*/
rehydrate(state: JSONState): void | Promise<void>
rehydrate(state: JSONState): Awaitable<void>
}

View File

@@ -1,9 +1,10 @@
import * as nodePath from 'path'
import * as fs from 'fs'
import * as mkdirp from 'mkdirp'
import { Filesystem } from './path/Filesystem'
import ReadableStream = NodeJS.ReadableStream;
import WritableStream = NodeJS.WritableStream;
import * as mime from 'mime-types'
import {FileNotFoundError, Filesystem} from './path/Filesystem'
import {Collection} from '../collection/Collection'
import {Readable, Writable} from 'stream'
/**
* An item that could represent a path.
@@ -22,6 +23,36 @@ export function universalPath(...parts: PathLike[]): UniversalPath {
return main.concat(...concats)
}
/**
* Format bytes as human-readable text.
*
* @param bytes Number of bytes.
* @param si True to use metric (SI) units, aka powers of 1000. False to use
* binary (IEC), aka powers of 1024.
* @param dp Number of decimal places to display.
* @see https://stackoverflow.com/a/14919494/4971138
*/
export function bytesToHumanFileSize(bytes: number, si = false, dp = 1): string {
const thresh = si ? 1000 : 1024
if (Math.abs(bytes) < thresh) {
return bytes + ' B'
}
const units = si
? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']
let u = -1
const r = 10 ** dp
do {
bytes /= thresh
++u
} while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1)
return bytes.toFixed(dp) + ' ' + units[u]
}
/**
* Walk recursively over entries in a directory.
*
@@ -155,6 +186,13 @@ export class UniversalPath {
return `${this.prefix}${this.resourceLocalPath}`
}
/**
* Get the basename of the path.
*/
get toBase(): string {
return nodePath.basename(this.resourceLocalPath)
}
/**
* Append and resolve the given paths to this resource and return a new UniversalPath.
*
@@ -224,6 +262,44 @@ export class UniversalPath {
return walk(this.resourceLocalPath)
}
/**
* Resolves true if this resource is a directory.
*/
async isDirectory(): Promise<boolean> {
if ( this.filesystem ) {
const stat = await this.filesystem.stat({
storePath: this.resourceLocalPath,
})
return stat.isDirectory
}
try {
return (await fs.promises.stat(this.resourceLocalPath)).isDirectory()
} catch (e) {
return false
}
}
/**
* Resolves true if this resource is a regular file.
*/
async isFile(): Promise<boolean> {
if ( this.filesystem ) {
const stat = await this.filesystem.stat({
storePath: this.resourceLocalPath,
})
return stat.isFile
}
try {
return (await fs.promises.stat(this.resourceLocalPath)).isFile()
} catch (e) {
return false
}
}
/**
* Returns true if the given resource exists at the path.
*/
@@ -244,6 +320,20 @@ export class UniversalPath {
}
}
/**
* List any immediate children of this resource.
*/
async list(): Promise<Collection<UniversalPath>> {
if ( this.filesystem ) {
const files = await this.filesystem.list(this.resourceLocalPath)
return files.map(x => this.concat(x))
}
const paths = await fs.promises.readdir(this.resourceLocalPath)
return Collection.collect<string>(paths)
.map(x => this.concat(x))
}
/**
* Recursively create this path as a directory. Equivalent to `mkdir -p` on Linux.
*/
@@ -290,7 +380,7 @@ export class UniversalPath {
/**
* Get a writable stream to this file's contents.
*/
async writeStream(): Promise<WritableStream> {
async writeStream(): Promise<Writable> {
if ( this.filesystem ) {
return this.filesystem.putStoreFileAsStream({
storePath: this.resourceLocalPath,
@@ -304,7 +394,7 @@ export class UniversalPath {
* Read the data from this resource's file as a string.
*/
async read(): Promise<string> {
let stream: ReadableStream
let stream: Readable
if ( this.filesystem ) {
stream = await this.filesystem.getStoreFileAsStream({
storePath: this.resourceLocalPath,
@@ -321,10 +411,37 @@ export class UniversalPath {
})
}
/**
* Get the size of this resource in bytes.
*/
async sizeInBytes(): Promise<number> {
if ( this.filesystem ) {
const stat = await this.filesystem.stat({
storePath: this.resourceLocalPath,
})
if ( stat.exists ) {
return stat.sizeInBytes
}
throw new FileNotFoundError(this.toString())
}
const stat = await fs.promises.stat(this.resourceLocalPath)
return stat.size
}
/**
* Get the size of this resource, formatted in a human-readable string.
*/
async sizeForHumans(): Promise<string> {
return bytesToHumanFileSize(await this.sizeInBytes())
}
/**
* Get a readable stream of this file's contents.
*/
async readStream(): Promise<ReadableStream> {
async readStream(): Promise<Readable> {
if ( this.filesystem ) {
return this.filesystem.getStoreFileAsStream({
storePath: this.resourceLocalPath,
@@ -334,17 +451,70 @@ export class UniversalPath {
}
}
/* get mime_type() {
return Mime.lookup(this.ext)
/**
* Returns true if this path exists in the subtree of the given path.
* @param otherPath
*/
isChildOf(otherPath: UniversalPath): boolean {
if ( (this.filesystem || otherPath.filesystem) && otherPath.filesystem !== this.filesystem ) {
return false
}
get content_type() {
return Mime.contentType(this.ext)
if ( this.prefix !== otherPath.prefix ) {
return false
}
get charset() {
if ( this.mime_type ) {
return Mime.charset(this.mime_type)
const relative = nodePath.relative(otherPath.toLocal, this.toLocal)
return Boolean(relative && !relative.startsWith('..') && !nodePath.isAbsolute(relative))
}
/**
* Returns true if the given path exists in the subtree of this path.
* @param otherPath
*/
isParentOf(otherPath: UniversalPath): boolean {
return otherPath.isChildOf(this)
}
/**
* Returns true if the given path refers to the same resource as this path.
* @param otherPath
*/
is(otherPath: UniversalPath): boolean {
if ( (this.filesystem || otherPath.filesystem) && otherPath.filesystem !== this.filesystem ) {
return false
}
if ( this.prefix !== otherPath.prefix ) {
return false
}
const relative = nodePath.relative(otherPath.toLocal, this.toLocal)
return relative === ''
}
/**
* Get the mime-type of this resource.
*/
get mimeType(): string | false {
return mime.lookup(this.resourceLocalPath)
}
/**
* Get the content-type header of this resource.
*/
get contentType(): string | false {
return mime.contentType(this.resourceLocalPath)
}
/**
* Get the charset of this resource.
*/
get charset(): string | false {
if ( this.mimeType ) {
return mime.charset(this.mimeType)
}
return false
}
}*/
}

View File

@@ -2,9 +2,10 @@ import {UniversalPath} from '../path'
import * as path from 'path'
import * as os from 'os'
import {uuid4} from '../data'
import ReadableStream = NodeJS.ReadableStream;
import WritableStream = NodeJS.WritableStream;
import {ErrorWithContext} from '../../error/ErrorWithContext'
import {Readable, Writable} from 'stream'
import {Awaitable} from '../types'
import {Collection} from '../../collection/Collection'
/**
* Error thrown when an operation is attempted on a non-existent file.
@@ -65,6 +66,16 @@ export interface Stat {
*/
tags: string[],
/**
* True if the resource exists as a directory.
*/
isDirectory: boolean,
/**
* True if the resource exists as a regular file.
*/
isFile: boolean,
accessed?: Date,
modified?: Date,
created?: Date,
@@ -77,12 +88,12 @@ export abstract class Filesystem {
/**
* Called when the Filesystem driver is initialized. Do any standup here.
*/
public open(): void | Promise<void> {} // eslint-disable-line @typescript-eslint/no-empty-function
public open(): Awaitable<void> {} // eslint-disable-line @typescript-eslint/no-empty-function
/**
* Called when the Filesystem driver is destroyed. Do any cleanup here.
*/
public close(): void | Promise<void> {} // eslint-disable-line @typescript-eslint/no-empty-function
public close(): Awaitable<void> {} // eslint-disable-line @typescript-eslint/no-empty-function
/**
* Get the URI prefix for this filesystem.
@@ -114,62 +125,67 @@ export abstract class Filesystem {
*
* @param args
*/
public abstract putLocalFile(args: {localPath: string, storePath: string, mimeType?: string, tags?: string[], tag?: string}): void | Promise<void>
public abstract putLocalFile(args: {localPath: string, storePath: string, mimeType?: string, tags?: string[], tag?: string}): Awaitable<void>
/**
* Download a file in the remote filesystem to the local filesystem and return it as a UniversalPath.
* @param args
*/
public abstract getStoreFileAsTemp(args: {storePath: string}): UniversalPath | Promise<UniversalPath>
public abstract getStoreFileAsTemp(args: {storePath: string}): Awaitable<UniversalPath>
/**
* Open a readable stream for a file in the remote filesystem.
* @param args
*/
public abstract getStoreFileAsStream(args: {storePath: string}): ReadableStream | Promise<ReadableStream>
public abstract getStoreFileAsStream(args: {storePath: string}): Awaitable<Readable>
/**
* Open a writable stream for a file in the remote filesystem.
* @param args
*/
public abstract putStoreFileAsStream(args: {storePath: string}): WritableStream | Promise<WritableStream>
public abstract putStoreFileAsStream(args: {storePath: string}): Awaitable<Writable>
/**
* Fetch some information about a file that may or may not be in the remote filesystem without fetching the entire file.
* @param args
*/
public abstract stat(args: {storePath: string}): Stat | Promise<Stat>
public abstract stat(args: {storePath: string}): Awaitable<Stat>
/**
* If the file does not exist in the remote filesystem, create it. If it does exist, update the modify timestamps.
* @param args
*/
public abstract touch(args: {storePath: string}): void | Promise<void>
public abstract touch(args: {storePath: string}): Awaitable<void>
/**
* Remove the given resource(s) from the remote filesystem.
* @param args
*/
public abstract remove(args: {storePath: string, recursive?: boolean }): void | Promise<void>
public abstract remove(args: {storePath: string, recursive?: boolean }): Awaitable<void>
/**
* Create the given path on the store as a directory, recursively.
* @param args
*/
public abstract mkdir(args: {storePath: string}): void | Promise<void>
public abstract mkdir(args: {storePath: string}): Awaitable<void>
/**
* Get the metadata object for the given file, if it exists.
* @param storePath
*/
public abstract getMetadata(storePath: string): FileMetadata | Promise<FileMetadata>
public abstract getMetadata(storePath: string): Awaitable<FileMetadata>
/**
* Set the metadata object for the given file, if the file exists.
* @param storePath
* @param meta
*/
public abstract setMetadata(storePath: string, meta: FileMetadata): void | Promise<void>
public abstract setMetadata(storePath: string, meta: FileMetadata): Awaitable<void>
/**
* List direct children of this resource.
*/
public abstract list(storePath: string): Awaitable<Collection<string>>
/**
* Normalize the input tags into a single array of strings. This is useful for implementing the fluent

View File

@@ -4,6 +4,7 @@ import * as path from 'path'
import {UniversalPath} from '../path'
import * as rimraf from 'rimraf'
import * as mkdirp from 'mkdirp'
import { Collection } from '../../collection/Collection'
export interface LocalFilesystemConfig {
baseDir: string
@@ -87,6 +88,8 @@ export class LocalFilesystem extends Filesystem {
accessed: stat.atime,
modified: stat.mtime,
created: stat.ctime,
isDirectory: stat.isDirectory(),
isFile: stat.isFile(),
}
} catch (e) {
if ( e?.code === 'ENOENT' ) {
@@ -95,6 +98,8 @@ export class LocalFilesystem extends Filesystem {
exists: false,
sizeInBytes: 0,
tags: [],
isFile: false,
isDirectory: false,
}
}
@@ -166,4 +171,13 @@ export class LocalFilesystem extends Filesystem {
protected metadataPath(storePath: string): string {
return path.resolve(this.baseConfig.baseDir, 'meta', storePath + '.json')
}
/**
* List all immediate children of the given path.
* @param storePath
*/
public async list(storePath: string): Promise<Collection<string>> {
const paths = await fs.promises.readdir(this.storePath(storePath))
return Collection.collect<string>(paths)
}
}

View File

@@ -2,7 +2,8 @@ import {FileMetadata, Filesystem, Stat} from './Filesystem'
import * as ssh2 from 'ssh2'
import * as path from 'path'
import * as fs from 'fs'
import ReadableStream = NodeJS.ReadableStream
import {Readable, Writable} from 'stream'
import {Collection} from '../../collection/Collection'
import {UniversalPath} from '../path'
/**
@@ -37,7 +38,7 @@ export class SSHFilesystem extends Filesystem {
})
}
async getStoreFileAsStream(args: { storePath: string }): Promise<ReadableStream> {
async getStoreFileAsStream(args: { storePath: string }): Promise<Readable> {
const sftp = await this.getSFTP()
return sftp.createReadStream(this.storePath(args.storePath))
}
@@ -62,7 +63,7 @@ export class SSHFilesystem extends Filesystem {
})
}
async putStoreFileAsStream(args: { storePath: string }): Promise<NodeJS.WritableStream> {
async putStoreFileAsStream(args: { storePath: string }): Promise<Writable> {
const sftp = await this.getSFTP()
return sftp.createWriteStream(this.storePath(args.storePath))
}
@@ -126,6 +127,8 @@ export class SSHFilesystem extends Filesystem {
accessed: stat.atime,
modified: stat.mtime,
created: stat.ctime,
isFile: stat.isFile(),
isDirectory: stat.isDirectory(),
}
} catch (e) {
return {
@@ -133,6 +136,8 @@ export class SSHFilesystem extends Filesystem {
exists: false,
sizeInBytes: 0,
tags: [],
isFile: false,
isDirectory: false,
}
}
}
@@ -243,7 +248,7 @@ export class SSHFilesystem extends Filesystem {
* @protected
*/
protected storePath(storePath: string): string {
return path.resolve(this.baseConfig.baseDir, 'data', storePath)
return path.join(this.baseConfig.baseDir, 'data', storePath)
}
/**
@@ -252,7 +257,7 @@ export class SSHFilesystem extends Filesystem {
* @protected
*/
protected metadataPath(storePath: string): string {
return path.resolve(this.baseConfig.baseDir, 'meta', storePath + '.json')
return path.join(this.baseConfig.baseDir, 'meta', storePath + '.json')
}
/**
@@ -268,4 +273,18 @@ export class SSHFilesystem extends Filesystem {
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
})
}
async list(storePath: string): Promise<Collection<string>> {
const sftp = await this.getSFTP()
return new Promise<Collection<string>>((res, rej) => {
sftp.readdir(this.storePath(storePath), (error, files) => {
if ( error ) {
rej(error)
} else {
res(Collection.collect(files).map(x => x.filename))
}
})
})
}
}

View File

@@ -1 +1,5 @@
/** Type alias for something that may or may not be wrapped in a promise. */
export type Awaitable<T> = T | Promise<T>
/** Type alias for something that may be undefined. */
export type Maybe<T> = T | undefined

View File

@@ -17,29 +17,36 @@ export class PugViewEngine extends ViewEngine {
public renderByName(templateName: string, locals: { [p: string]: any }): string | Promise<string> {
let compiled = this.compileCache[templateName]
if ( compiled ) {
return compiled(locals)
return compiled({
...this.getGlobals(),
...locals,
})
}
if ( !templateName.endsWith('.pug') ) {
templateName += '.pug'
}
const filePath = this.path.concat(...templateName.split(':'))
compiled = pug.compileFile(filePath.toLocal, this.getOptions())
const filePath = this.resolveName(templateName)
compiled = pug.compileFile(filePath.toLocal, this.getOptions(templateName))
this.compileCache[templateName] = compiled
return compiled(locals)
return compiled({
...this.getGlobals(),
...locals,
})
}
/**
* Get the object of options passed to Pug's compile methods.
* @protected
*/
protected getOptions(): pug.Options {
protected getOptions(templateName?: string): pug.Options {
return {
basedir: this.path.toLocal,
basedir: templateName ? this.resolveBasePath(templateName).toLocal : this.path.toLocal,
debug: this.debug,
compileDebug: this.debug,
globals: [],
}
}
getFileExtension(): string {
return '.pug'
}
}

View File

@@ -1,7 +1,8 @@
import {AppClass} from '../lifecycle/AppClass'
import {Config} from '../service/Config'
import {Container} from '../di'
import {UniversalPath} from '../util'
import {ErrorWithContext, UniversalPath} from '../util'
import {Routing} from '../service/Routing'
/**
* Abstract base class for rendering views via different view engines.
@@ -9,11 +10,16 @@ import {UniversalPath} from '../util'
export abstract class ViewEngine extends AppClass {
protected readonly config: Config
protected readonly routing: Routing
protected readonly debug: boolean
protected readonly namespaces: {[key: string]: UniversalPath} = {}
constructor() {
super()
this.config = Container.getContainer().make(Config)
this.routing = Container.getContainer().make(Routing)
this.debug = (this.config.get('server.mode', 'production') === 'development'
|| this.config.get('server.debug', false))
}
@@ -38,4 +44,86 @@ export abstract class ViewEngine extends AppClass {
* @param locals
*/
public abstract renderByName(templateName: string, locals: {[key: string]: any}): string | Promise<string>
/**
* Get the file extension of template files of this engine.
* @example `.pug`
*/
public abstract getFileExtension(): string
/**
* Get the global variables that should be passed to every view rendered.
* @protected
*/
protected getGlobals(): {[key: string]: any} {
return {
app: this.app(),
config: (key: string, fallback?: any) => this.config.get(key, fallback),
asset: (...parts: string[]) => this.routing.getAssetPath(...parts).toRemote,
vendor: (namespace: string, ...parts: string[]) => this.routing.getVendorPath(namespace, ...parts).toRemote,
}
}
/**
* Register a path as a root for rendering views prefixed with the given namespace.
* @param namespace
* @param basePath
*/
public registerNamespace(namespace: string, basePath: UniversalPath): this {
if ( namespace.startsWith('@') ) {
namespace = namespace.substr(1)
}
this.namespaces[namespace] = basePath
return this
}
/**
* Given the name of a template, get a UniversalPath pointing to its file.
* @param templateName
*/
public resolveName(templateName: string): UniversalPath {
let path = this.path
if ( templateName.startsWith('@') ) {
const [namespace, ...parts] = templateName.split(':')
path = this.namespaces[namespace.substr(1)]
if ( !path ) {
throw new ErrorWithContext('Invalid template namespace: ' + namespace, {
namespace,
templateName,
})
}
templateName = parts.join(':')
}
if ( !templateName.endsWith(this.getFileExtension()) ) {
templateName += this.getFileExtension()
}
return path.concat(...templateName.split(':'))
}
/**
* Given the name of a template, get a UniversalPath to the root of the tree where
* that template resides.
* @param templateName
*/
public resolveBasePath(templateName: string): UniversalPath {
let path = this.path
if ( templateName.startsWith('@') ) {
const [namespace] = templateName.split(':')
path = this.namespaces[namespace.substr(1)]
if ( !path ) {
throw new ErrorWithContext('Invalid template namespace: ' + namespace, {
namespace,
templateName,
})
}
}
return path
}
}