7 Commits
0.3.1 ... 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
33 changed files with 1385 additions and 91 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@extollo/lib",
"version": "0.3.1",
"version": "0.4.0",
"description": "The framework library that lifts up your code.",
"main": "lib/index.js",
"types": "lib/index.d.ts",
@@ -8,12 +8,14 @@
"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",
@@ -25,6 +27,7 @@
"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",
@@ -42,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",
@@ -65,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
}
}
}

354
pnpm-lock.yaml generated
View File

@@ -1,10 +1,12 @@
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
@@ -16,6 +18,7 @@ dependencies:
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
@@ -36,6 +39,26 @@ devDependencies:
eslint: 7.27.0
lockfileVersion: 5.2
packages:
/@atao60/fse-cli/0.1.6:
dependencies:
'@babel/runtime': 7.14.6
arg: 5.0.0
chalk: 4.1.1
core-js: 3.15.2
fs-extra: 10.0.0
graceful-fs: 4.2.6
inquirer: 8.1.1
regenerator-runtime: 0.13.7
source-map-support: 0.5.19
terminal-link: 3.0.0
tslib: 2.3.0
dev: false
engines:
node: ^12.20.0 || ^14.13.1 || >=16.0.0
npm: '>=6.14.11'
hasBin: true
resolution:
integrity: sha512-BtUemvHc16zevepkJGjM2vuRVkyU3zIJEHpw/gvvxJRRSWppblw9uqXeR7y72kJ88VcTOnzz/1wNGWHsBHVpKQ==
/@babel/code-frame/7.12.11:
dependencies:
'@babel/highlight': 7.14.0
@@ -65,6 +88,14 @@ packages:
hasBin: true
resolution:
integrity: sha512-OhsyMrqygfk5v8HmWwOzlYjJrtLaFhF34MrfG/Z73DgYCI6ojNUTUp2TYbtnjo8PegeJp12eamsNettCQjKjVw==
/@babel/runtime/7.14.6:
dependencies:
regenerator-runtime: 0.13.7
dev: false
engines:
node: '>=6.9.0'
resolution:
integrity: sha512-/PCB2uJ7oM44tz8YhC4Z/6PeOKXp4K588f+5M3clr1M4zbqztlo0XEfJ2LEzj/FgwfgGcIdl8n7YYjTCI0BYwg==
/@babel/types/7.13.14:
dependencies:
'@babel/helper-validator-identifier': 7.12.11
@@ -155,6 +186,10 @@ packages:
dev: true
resolution:
integrity: sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA==
/@types/mime-types/2.1.0:
dev: false
resolution:
integrity: sha1-nKUs2jY/aZxpRmwqbM2q2RPqenM=
/@types/minimatch/3.0.4:
dev: false
resolution:
@@ -181,6 +216,10 @@ packages:
dev: false
resolution:
integrity: sha512-sld7b/xmFum66AAKuz/rp/CUO8+98fMpyQ3SBfzzBNGMd/1iHBTAg9oyAvcYlAj46bpc74r91jSw2iFdnx29nw==
/@types/node/14.17.4:
dev: false
resolution:
integrity: sha512-8kQ3+wKGRNN0ghtEn7EGps/B8CzuBz1nXZEIGGLP2GnwbqYn4dbTs7k+VKLTq1HvZLRCIDtN3Snx1Ege8B7L5A==
/@types/pg/8.6.0:
dependencies:
'@types/node': 14.17.1
@@ -376,6 +415,22 @@ packages:
node: '>=6'
resolution:
integrity: sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==
/ansi-escapes/4.3.2:
dependencies:
type-fest: 0.21.3
dev: false
engines:
node: '>=8'
resolution:
integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==
/ansi-escapes/5.0.0:
dependencies:
type-fest: 1.2.1
dev: false
engines:
node: '>=12'
resolution:
integrity: sha512-5GFMVX8HqE/TB+FuBJGuO5XG0WrsA6ptUqoODaT/n9mmUaZFkqnBueB4leqGBCmrUHnCnC4PCZTCd0E7QQ83bA==
/ansi-regex/2.1.1:
dev: false
engines:
@@ -383,7 +438,6 @@ packages:
resolution:
integrity: sha1-w7M6te42DYbg5ijwRorn7yfWVN8=
/ansi-regex/5.0.0:
dev: true
engines:
node: '>=8'
resolution:
@@ -399,7 +453,6 @@ packages:
/ansi-styles/4.3.0:
dependencies:
color-convert: 2.0.1
dev: true
engines:
node: '>=8'
resolution:
@@ -419,6 +472,10 @@ packages:
dev: false
resolution:
integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==
/arg/5.0.0:
dev: false
resolution:
integrity: sha512-4P8Zm2H+BRS+c/xX1LrHw0qKpEhdlZjLCgWy+d78T9vqa2Z2SiD2wMrYuWIAFy5IZUD7nnNXroRttz+0RzlrzQ==
/argparse/1.0.10:
dependencies:
sprintf-js: 1.0.3
@@ -468,6 +525,10 @@ packages:
/balanced-match/1.0.2:
resolution:
integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
/base64-js/1.5.1:
dev: false
resolution:
integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
/bcrypt-pbkdf/1.0.2:
dependencies:
tweetnacl: 0.14.5
@@ -484,6 +545,14 @@ packages:
requiresBuild: true
resolution:
integrity: sha512-9BTgmrhZM2t1bNuDtrtIMVSmmxZBrJ71n8Wg+YgdjHuIWYF7SjjmCPZFB+/5i/o/PIeRpwVJR3P+NrpIItUjqw==
/bl/4.1.0:
dependencies:
buffer: 5.7.1
inherits: 2.0.4
readable-stream: 3.6.0
dev: false
resolution:
integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==
/brace-expansion/1.1.11:
dependencies:
balanced-match: 1.0.2
@@ -508,6 +577,13 @@ packages:
node: '>=4'
resolution:
integrity: sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==
/buffer/5.7.1:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
dev: false
resolution:
integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==
/busboy/0.3.1:
dependencies:
dicer: 0.3.0
@@ -543,7 +619,6 @@ packages:
dependencies:
ansi-styles: 4.3.0
supports-color: 7.2.0
dev: true
engines:
node: '>=10'
resolution:
@@ -554,12 +629,30 @@ packages:
dev: false
resolution:
integrity: sha1-x84o821LzZdE5f/CxfzeHHMmH8A=
/chardet/0.7.0:
dev: false
resolution:
integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==
/chownr/2.0.0:
dev: false
engines:
node: '>=10'
resolution:
integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==
/cli-cursor/3.1.0:
dependencies:
restore-cursor: 3.1.0
dev: false
engines:
node: '>=8'
resolution:
integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==
/cli-spinners/2.6.0:
dev: false
engines:
node: '>=6'
resolution:
integrity: sha512-t+4/y50K/+4xcCRosKkA7W4gTr1MySvLV0q+PxmG7FJ5g+66ChKurYjxBCjHggHH3HA5Hh9cy+lcUGWDqVH+4Q==
/cli-table/0.3.6:
dependencies:
colors: 1.0.3
@@ -568,6 +661,18 @@ packages:
node: '>= 0.2.0'
resolution:
integrity: sha512-ZkNZbnZjKERTY5NwC2SeMeLeifSPq/pubeRoTpdr3WchLlnZg6hEgvHkK5zL7KNFdd9PmHN8lxrENUwI3cE8vQ==
/cli-width/3.0.0:
dev: false
engines:
node: '>= 10'
resolution:
integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==
/clone/1.0.4:
dev: false
engines:
node: '>=0.8'
resolution:
integrity: sha1-2jCcwmPfFZlMaIypAheco8fNfH4=
/code-point-at/1.1.0:
dev: false
engines:
@@ -583,7 +688,6 @@ packages:
/color-convert/2.0.1:
dependencies:
color-name: 1.1.4
dev: true
engines:
node: '>=7.0.0'
resolution:
@@ -593,7 +697,6 @@ packages:
resolution:
integrity: sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=
/color-name/1.1.4:
dev: true
resolution:
integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
/colors/1.0.3:
@@ -626,6 +729,11 @@ packages:
dev: false
resolution:
integrity: sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==
/core-js/3.15.2:
dev: false
requiresBuild: true
resolution:
integrity: sha512-tKs41J7NJVuaya8DxIOCnl8QuPHx5/ZVbFo1oKgVl1qHFBBrDctzQGtuLjPpRdNTWmKPH6oEvgN/MUID+l485Q==
/core-util-is/1.0.2:
dev: false
resolution:
@@ -670,6 +778,12 @@ packages:
dev: true
resolution:
integrity: sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=
/defaults/1.0.3:
dependencies:
clone: 1.0.4
dev: false
resolution:
integrity: sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=
/delegates/1.0.0:
dev: false
resolution:
@@ -722,7 +836,6 @@ packages:
resolution:
integrity: sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==
/emoji-regex/8.0.0:
dev: true
resolution:
integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
/enquirer/2.3.6:
@@ -734,7 +847,6 @@ packages:
resolution:
integrity: sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==
/escape-string-regexp/1.0.5:
dev: true
engines:
node: '>=0.8.0'
resolution:
@@ -883,6 +995,16 @@ packages:
node: '>=0.10.0'
resolution:
integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
/external-editor/3.1.0:
dependencies:
chardet: 0.7.0
iconv-lite: 0.4.24
tmp: 0.0.33
dev: false
engines:
node: '>=4'
resolution:
integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==
/fast-deep-equal/3.1.3:
dev: true
resolution:
@@ -914,6 +1036,14 @@ packages:
dev: true
resolution:
integrity: sha512-7Eczs8gIPDrVzT+EksYBcupqMyxSHXXrHOLRRxU2/DicV8789MRBRR8+Hc2uWzUupOs4YS4JzBmBxjjCVBxD/g==
/figures/3.2.0:
dependencies:
escape-string-regexp: 1.0.5
dev: false
engines:
node: '>=8'
resolution:
integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==
/file-entry-cache/6.0.1:
dependencies:
flat-cache: 3.0.4
@@ -943,6 +1073,16 @@ packages:
dev: true
resolution:
integrity: sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA==
/fs-extra/10.0.0:
dependencies:
graceful-fs: 4.2.6
jsonfile: 6.1.0
universalify: 2.0.0
dev: false
engines:
node: '>=12'
resolution:
integrity: sha512-C5owb14u9eJwizKGdchcDUQeFtlSHHthBk8pbX9Vc1PFZrLombudjDnNns88aYslCyF6IY5SUw3Roz6xShcEIQ==
/fs-extra/9.1.0:
dependencies:
at-least-node: 1.0.0
@@ -1066,7 +1206,6 @@ packages:
resolution:
integrity: sha1-tdRU3CGZriJWmfNGfloH87lVuv0=
/has-flag/4.0.0:
dev: true
engines:
node: '>=8'
resolution:
@@ -1098,6 +1237,18 @@ packages:
node: '>= 6'
resolution:
integrity: sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==
/iconv-lite/0.4.24:
dependencies:
safer-buffer: 2.1.2
dev: false
engines:
node: '>=0.10.0'
resolution:
integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
/ieee754/1.2.1:
dev: false
resolution:
integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
/ignore/4.0.6:
dev: true
engines:
@@ -1134,6 +1285,27 @@ packages:
/inherits/2.0.4:
resolution:
integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
/inquirer/8.1.1:
dependencies:
ansi-escapes: 4.3.2
chalk: 4.1.1
cli-cursor: 3.1.0
cli-width: 3.0.0
external-editor: 3.1.0
figures: 3.2.0
lodash: 4.17.21
mute-stream: 0.0.8
ora: 5.4.1
run-async: 2.4.1
rxjs: 6.6.7
string-width: 4.2.2
strip-ansi: 6.0.0
through: 2.3.8
dev: false
engines:
node: '>=8.0.0'
resolution:
integrity: sha512-hUDjc3vBkh/uk1gPfMAD/7Z188Q8cvTGl0nxwaCdwSbzFh6ZKkZh+s2ozVxbE5G9ZNRyeY0+lgbAIOUFsFf98w==
/interpret/1.4.0:
dev: false
engines:
@@ -1168,7 +1340,6 @@ packages:
resolution:
integrity: sha1-754xOG8DGn8NZDr4L95QxFfvAMs=
/is-fullwidth-code-point/3.0.0:
dev: true
engines:
node: '>=8'
resolution:
@@ -1181,6 +1352,12 @@ packages:
node: '>=0.10.0'
resolution:
integrity: sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==
/is-interactive/1.0.0:
dev: false
engines:
node: '>=8'
resolution:
integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==
/is-number/7.0.0:
dev: true
engines:
@@ -1200,6 +1377,12 @@ packages:
node: '>= 0.4'
resolution:
integrity: sha512-axvdhb5pdhEVThqJzYXwMlVuZwC+FF2DpcOhTS+y/8jVq4trxyPgfcwIxIKiyeuLlSQYKkmUaPQJ8ZE4yNKXDg==
/is-unicode-supported/0.1.0:
dev: false
engines:
node: '>=10'
resolution:
integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==
/isarray/1.0.0:
dev: false
resolution:
@@ -1275,6 +1458,15 @@ packages:
/lodash/4.17.21:
resolution:
integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
/log-symbols/4.1.0:
dependencies:
chalk: 4.1.1
is-unicode-supported: 0.1.0
dev: false
engines:
node: '>=10'
resolution:
integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==
/lru-cache/5.1.1:
dependencies:
yallist: 3.1.1
@@ -1326,6 +1518,26 @@ packages:
node: '>=8.6'
resolution:
integrity: sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==
/mime-db/1.48.0:
dev: false
engines:
node: '>= 0.6'
resolution:
integrity: sha512-FM3QwxV+TnZYQ2aRqhlKBMHxk10lTbMt3bBkMAp54ddrNeVSfcQYOOKuGuy3Ddrm38I04If834fOUSq1yzslJQ==
/mime-types/2.1.31:
dependencies:
mime-db: 1.48.0
dev: false
engines:
node: '>= 0.6'
resolution:
integrity: sha512-XGZnNzm3QvgKxa8dpzyhFTHmpP3l5YNusmne07VUOXxou9CqUqYa/HBy124RqtVh/O2pECas/MOcsDgpilPOPg==
/mimic-fn/2.1.0:
dev: false
engines:
node: '>=6'
resolution:
integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
/minimatch/3.0.4:
dependencies:
brace-expansion: 1.1.11
@@ -1362,6 +1574,10 @@ packages:
/ms/2.1.2:
resolution:
integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
/mute-stream/0.0.8:
dev: false
resolution:
integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==
/nan/2.14.2:
dev: false
optional: true
@@ -1426,6 +1642,14 @@ packages:
wrappy: 1.0.2
resolution:
integrity: sha1-WDsap3WWHUsROsF9nFC6753Xa9E=
/onetime/5.1.2:
dependencies:
mimic-fn: 2.1.0
dev: false
engines:
node: '>=6'
resolution:
integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==
/onigasm/2.2.5:
dependencies:
lru-cache: 5.1.1
@@ -1445,6 +1669,28 @@ packages:
node: '>= 0.8.0'
resolution:
integrity: sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==
/ora/5.4.1:
dependencies:
bl: 4.1.0
chalk: 4.1.1
cli-cursor: 3.1.0
cli-spinners: 2.6.0
is-interactive: 1.0.0
is-unicode-supported: 0.1.0
log-symbols: 4.1.0
strip-ansi: 6.0.0
wcwidth: 1.0.1
dev: false
engines:
node: '>=10'
resolution:
integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==
/os-tmpdir/1.0.2:
dev: false
engines:
node: '>=0.10.0'
resolution:
integrity: sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=
/packet-reader/1.0.0:
dev: false
resolution:
@@ -1731,6 +1977,10 @@ packages:
dev: false
resolution:
integrity: sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==
/regenerator-runtime/0.13.7:
dev: false
resolution:
integrity: sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==
/regexpp/3.1.0:
dev: true
engines:
@@ -1756,6 +2006,15 @@ packages:
dev: false
resolution:
integrity: sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==
/restore-cursor/3.1.0:
dependencies:
onetime: 5.1.2
signal-exit: 3.0.3
dev: false
engines:
node: '>=8'
resolution:
integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==
/reusify/1.0.4:
dev: true
engines:
@@ -1769,12 +2028,26 @@ packages:
hasBin: true
resolution:
integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==
/run-async/2.4.1:
dev: false
engines:
node: '>=0.12.0'
resolution:
integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==
/run-parallel/1.2.0:
dependencies:
queue-microtask: 1.2.3
dev: true
resolution:
integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==
/rxjs/6.6.7:
dependencies:
tslib: 1.14.1
dev: false
engines:
npm: '>=2.0.0'
resolution:
integrity: sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==
/safe-buffer/5.1.2:
dev: false
resolution:
@@ -1913,7 +2186,6 @@ packages:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.0
dev: true
engines:
node: '>=8'
resolution:
@@ -1941,7 +2213,6 @@ packages:
/strip-ansi/6.0.0:
dependencies:
ansi-regex: 5.0.0
dev: true
engines:
node: '>=8'
resolution:
@@ -1963,11 +2234,19 @@ packages:
/supports-color/7.2.0:
dependencies:
has-flag: 4.0.0
dev: true
engines:
node: '>=8'
resolution:
integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
/supports-hyperlinks/2.2.0:
dependencies:
has-flag: 4.0.0
supports-color: 7.2.0
dev: false
engines:
node: '>=8'
resolution:
integrity: sha512-6sXEzV5+I5j8Bmq9/vUphGRM/RJNT9SCURJLjwfOg51heRtguGWDzcaBlgAzKhQa0EVNpPEKzQuBwZ8S8WaCeQ==
/table/6.7.1:
dependencies:
ajv: 8.5.0
@@ -1994,10 +2273,31 @@ packages:
node: '>= 10'
resolution:
integrity: sha512-DUCttfhsnLCjwoDoFcI+B2iJgYa93vBnDUATYEeRx6sntCTdN01VnqsIuTlALXla/LWooNg0yEGeB+Y8WdFxGA==
/terminal-link/3.0.0:
dependencies:
ansi-escapes: 5.0.0
supports-hyperlinks: 2.2.0
dev: false
engines:
node: '>=12'
resolution:
integrity: sha512-flFL3m4wuixmf6IfhFJd1YPiLiMuxEc8uHRM1buzIeZPm22Au2pDqBJQgdo7n1WfPU1ONFGv7YDwpFBmHGF6lg==
/text-table/0.2.0:
dev: true
resolution:
integrity: sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=
/through/2.3.8:
dev: false
resolution:
integrity: sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=
/tmp/0.0.33:
dependencies:
os-tmpdir: 1.0.2
dev: false
engines:
node: '>=0.6.0'
resolution:
integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==
/to-fast-properties/2.0.0:
dev: false
engines:
@@ -2034,9 +2334,12 @@ packages:
resolution:
integrity: sha512-hPlt7ZACERQGf03M253ytLY3dHbGNGrAq9qIHWUY9XHYl1z7wYngSr3OQ5xmui8o2AaxsONxIzjafLUiWBo1Fg==
/tslib/1.14.1:
dev: true
resolution:
integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
/tslib/2.3.0:
dev: false
resolution:
integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==
/tsutils/3.21.0_typescript@4.2.3:
dependencies:
tslib: 1.14.1
@@ -2066,12 +2369,24 @@ packages:
node: '>=10'
resolution:
integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==
/type-fest/0.21.3:
dev: false
engines:
node: '>=10'
resolution:
integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==
/type-fest/0.8.1:
dev: true
engines:
node: '>=8'
resolution:
integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==
/type-fest/1.2.1:
dev: false
engines:
node: '>=10'
resolution:
integrity: sha512-SbmIRuXhJs8KTneu77Ecylt9zuqL683tuiLYpTRil4H++eIhqCmx6ko6KAFem9dty8sOdnEiX7j4K1nRE628fQ==
/typedoc-default-themes/0.10.2:
dependencies:
lunr: 2.3.9
@@ -2173,6 +2488,12 @@ packages:
dev: false
resolution:
integrity: sha512-c0Q4zYZkcLizeYJ3hNyaVUM2AA8KDhNCA3JvXY8CeZSJuBdAy3bAvSbv46RClC4P3dSO9BdwhnKEx2zOo6vP/w==
/wcwidth/1.0.1:
dependencies:
defaults: 1.0.3
dev: false
resolution:
integrity: sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=
/which/2.0.2:
dependencies:
isexe: 2.0.0
@@ -2232,12 +2553,14 @@ packages:
resolution:
integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==
specifiers:
'@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
@@ -2252,6 +2575,7 @@ specifiers:
colors: ^1.4.0
dotenv: ^8.2.0
eslint: ^7.27.0
mime-types: ^2.1.31
mkdirp: ^1.0.4
negotiator: ^0.6.2
pg: ^8.6.0

View File

@@ -7,6 +7,7 @@ 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
@@ -17,7 +18,11 @@ 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()

View File

@@ -1,7 +1,8 @@
import {Instantiable} from './types'
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
@@ -36,6 +37,16 @@ export class ContainerBlueprint {
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,11 +1,13 @@
import {Dispatchable} from './types'
import {JSONState} from '../util'
import {Awaitable, JSONState} from '../util'
/**
* Abstract class representing an event that may be fired.
*/
export abstract class Event implements Dispatchable {
abstract dehydrate(): Promise<JSONState>
abstract rehydrate(state: JSONState): void | Promise<void>
abstract dehydrate(): Awaitable<JSONState>
abstract rehydrate(state: JSONState): 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

@@ -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))
}

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,4 +1,5 @@
export * from './util'
export * from './lib'
export * from './di'
export * from './event/types'
@@ -42,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'
@@ -56,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'
@@ -70,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'

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

@@ -48,6 +48,12 @@ 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 ) {
@@ -205,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

@@ -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

@@ -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') ) {
@@ -63,4 +72,55 @@ export class Routing extends Unit {
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

@@ -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
}
}