structured-fw 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Config.ts +47 -0
- package/LICENSE +21 -0
- package/README.md +332 -0
- package/app/Types.ts +1 -0
- package/app/models/README.md +9 -0
- package/app/routes/README.md +19 -0
- package/app/views/README.md +1 -0
- package/app/views/layout.html +1 -0
- package/bin/structured +114 -0
- package/build/Config.d.ts +2 -0
- package/build/Config.js +31 -0
- package/build/app/Types.d.ts +1 -0
- package/build/app/Types.js +1 -0
- package/build/app/models/Users.d.ts +0 -0
- package/build/app/models/Users.js +1 -0
- package/build/app/routes/Auth.d.ts +0 -0
- package/build/app/routes/Auth.js +1 -0
- package/build/app/routes/Test.d.ts +2 -0
- package/build/app/routes/Test.js +101 -0
- package/build/app/routes/Todo.d.ts +0 -0
- package/build/app/routes/Todo.js +1 -0
- package/build/app/routes/Upload.d.ts +0 -0
- package/build/app/routes/Upload.js +1 -0
- package/build/app/routes/Validation.d.ts +2 -0
- package/build/app/routes/Validation.js +34 -0
- package/build/app/views/components/ClientImport/ClientImport.client.d.ts +2 -0
- package/build/app/views/components/ClientImport/ClientImport.client.js +4 -0
- package/build/app/views/components/ClientImport/Export.d.ts +1 -0
- package/build/app/views/components/ClientImport/Export.js +1 -0
- package/build/app/views/components/Conditionals/Conditionals.client.d.ts +2 -0
- package/build/app/views/components/Conditionals/Conditionals.client.js +43 -0
- package/build/app/views/components/FormTest/FormTestNested/FormTestNested.d.ts +8 -0
- package/build/app/views/components/FormTest/FormTestNested/FormTestNested.js +7 -0
- package/build/app/views/components/ModelsTest/ModelsTest.client.d.ts +2 -0
- package/build/app/views/components/ModelsTest/ModelsTest.client.js +5 -0
- package/build/app/views/components/MultipartForm/MultipartForm.client.d.ts +0 -0
- package/build/app/views/components/MultipartForm/MultipartForm.client.js +1 -0
- package/build/app/views/components/PassObject/PassObject.d.ts +10 -0
- package/build/app/views/components/PassObject/PassObject.js +10 -0
- package/build/app/views/components/PassObject/ReceiveObj/ReceiveObj.d.ts +6 -0
- package/build/app/views/components/PassObject/ReceiveObj/ReceiveObj.js +6 -0
- package/build/app/views/components/RedrawAbort/RedrawAbort.client.d.ts +2 -0
- package/build/app/views/components/RedrawAbort/RedrawAbort.client.js +6 -0
- package/build/app/views/components/RedrawAbort/RedrawAbort.d.ts +8 -0
- package/build/app/views/components/RedrawAbort/RedrawAbort.js +8 -0
- package/build/app/views/components/ServerSideContext/ServerSideContext.d.ts +7 -0
- package/build/app/views/components/ServerSideContext/ServerSideContext.js +10 -0
- package/build/assets/ts/Export.d.ts +1 -0
- package/build/assets/ts/Export.js +1 -0
- package/build/index.d.ts +1 -0
- package/build/index.js +3 -0
- package/build/system/Helpers.d.ts +3 -0
- package/build/system/Helpers.js +72 -0
- package/build/system/Symbols.d.ts +3 -0
- package/build/system/Symbols.js +3 -0
- package/build/system/Types.d.ts +171 -0
- package/build/system/Types.js +1 -0
- package/build/system/Util.d.ts +20 -0
- package/build/system/Util.js +336 -0
- package/build/system/client/App.d.ts +7 -0
- package/build/system/client/App.js +8 -0
- package/build/system/client/Client.d.ts +6 -0
- package/build/system/client/Client.js +9 -0
- package/build/system/client/ClientComponent.d.ts +68 -0
- package/build/system/client/ClientComponent.js +734 -0
- package/build/system/client/DataStore.d.ts +22 -0
- package/build/system/client/DataStore.js +64 -0
- package/build/system/client/DataStoreView.d.ts +19 -0
- package/build/system/client/DataStoreView.js +56 -0
- package/build/system/client/EventEmitter.d.ts +7 -0
- package/build/system/client/EventEmitter.js +31 -0
- package/build/system/client/Net.d.ts +13 -0
- package/build/system/client/Net.js +39 -0
- package/build/system/client/NetRequest.d.ts +13 -0
- package/build/system/client/NetRequest.js +45 -0
- package/build/system/server/Application.d.ts +31 -0
- package/build/system/server/Application.js +171 -0
- package/build/system/server/Component.d.ts +27 -0
- package/build/system/server/Component.js +249 -0
- package/build/system/server/Components.d.ts +12 -0
- package/build/system/server/Components.js +77 -0
- package/build/system/server/Cookies.d.ts +6 -0
- package/build/system/server/Cookies.js +19 -0
- package/build/system/server/Document.d.ts +24 -0
- package/build/system/server/Document.js +107 -0
- package/build/system/server/DocumentHead.d.ts +32 -0
- package/build/system/server/DocumentHead.js +118 -0
- package/build/system/server/FormValidation.d.ts +16 -0
- package/build/system/server/FormValidation.js +197 -0
- package/build/system/server/Handlebars.d.ts +11 -0
- package/build/system/server/Handlebars.js +34 -0
- package/build/system/server/Request.d.ts +21 -0
- package/build/system/server/Request.js +356 -0
- package/build/system/server/Session.d.ts +23 -0
- package/build/system/server/Session.js +114 -0
- package/build/system/server/dom/DOMFragment.d.ts +4 -0
- package/build/system/server/dom/DOMFragment.js +6 -0
- package/build/system/server/dom/DOMNode.d.ts +31 -0
- package/build/system/server/dom/DOMNode.js +110 -0
- package/build/system/server/dom/HTMLParser.d.ts +21 -0
- package/build/system/server/dom/HTMLParser.js +204 -0
- package/index.ts +4 -0
- package/package.json +31 -0
- package/system/Helpers.ts +97 -0
- package/system/Symbols.ts +6 -0
- package/system/Types.ts +234 -0
- package/system/Util.ts +488 -0
- package/system/client/App.ts +11 -0
- package/system/client/Client.ts +9 -0
- package/system/client/ClientComponent.ts +1117 -0
- package/system/client/DataStore.ts +101 -0
- package/system/client/DataStoreView.ts +82 -0
- package/system/client/EventEmitter.ts +38 -0
- package/system/client/Net.ts +58 -0
- package/system/client/NetRequest.ts +64 -0
- package/system/server/Application.ts +230 -0
- package/system/server/Component.ts +404 -0
- package/system/server/Components.ts +111 -0
- package/system/server/Cookies.ts +29 -0
- package/system/server/Document.ts +163 -0
- package/system/server/DocumentHead.ts +150 -0
- package/system/server/FormValidation.ts +231 -0
- package/system/server/Handlebars.ts +51 -0
- package/system/server/Request.ts +497 -0
- package/system/server/Session.ts +151 -0
- package/system/server/dom/DOMFragment.ts +7 -0
- package/system/server/dom/DOMNode.ts +140 -0
- package/system/server/dom/HTMLParser.ts +238 -0
- package/tsconfig.json +35 -0
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
import { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { PostedDataDecoded, RequestBodyFile, RequestCallback, RequestContext, RequestHandler, RequestMethod, URIArguments, URISegmentPattern } from "../Types.js";
|
|
3
|
+
import { mergeDeep, queryStringDecode, queryStringDecodedSetValue } from "../Util.js";
|
|
4
|
+
import { RequestContextData } from "../../app/Types.js";
|
|
5
|
+
import { Application } from "./Application.js";
|
|
6
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { Buffer } from "node:buffer";
|
|
9
|
+
import { Document } from "./Document.js";
|
|
10
|
+
|
|
11
|
+
export class Request {
|
|
12
|
+
|
|
13
|
+
private app: Application;
|
|
14
|
+
|
|
15
|
+
constructor(app: Application) {
|
|
16
|
+
this.app = app;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
pageNotFoundCallback: RequestCallback = async ({ response }) => {
|
|
20
|
+
response.statusCode = 404;
|
|
21
|
+
response.write('Page not found');
|
|
22
|
+
response.end();
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// registered request handlers
|
|
26
|
+
private readonly handlers: Array<RequestHandler> = [];
|
|
27
|
+
|
|
28
|
+
// registers a request handler for given request method(s) + pattern(s)
|
|
29
|
+
// when a request is made that matches the request method(s) and pattern(s) callback is executed
|
|
30
|
+
// pattern can have matches in it which will later populate ctx.args, eg. /users/(id:num) or /example/(argName)
|
|
31
|
+
// callback.this will be the scope if scope is provided, otherwise scope is the Application instance
|
|
32
|
+
// if pattern is given as array, one request handler will be created for each element of the array
|
|
33
|
+
// if isStaticAsset = true, (before/after)RequestHandler event not emitted, body and GET args not parsed
|
|
34
|
+
public on(
|
|
35
|
+
methods: RequestMethod|Array<RequestMethod>,
|
|
36
|
+
pattern: string|RegExp|Array<string|RegExp>,
|
|
37
|
+
callback: RequestCallback,
|
|
38
|
+
scope?: any,
|
|
39
|
+
isStaticAsset: boolean = false
|
|
40
|
+
): void {
|
|
41
|
+
|
|
42
|
+
if (! (methods instanceof Array)) {
|
|
43
|
+
methods = [methods];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (scope === undefined) {
|
|
47
|
+
scope = this.app;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// if pattern was given as an array, call addRequestHandler with each item in array
|
|
51
|
+
if (pattern instanceof Array) {
|
|
52
|
+
pattern.forEach((p) => {
|
|
53
|
+
this.on(methods, p, callback, scope);
|
|
54
|
+
});
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const match = ((typeof pattern === 'string' ? this.patternToSegments(pattern) : pattern) as RegExp|Array<URISegmentPattern>);
|
|
59
|
+
|
|
60
|
+
const handler: RequestHandler = {
|
|
61
|
+
match,
|
|
62
|
+
methods,
|
|
63
|
+
callback,
|
|
64
|
+
scope,
|
|
65
|
+
staticAsset: isStaticAsset
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
this.handlers.push(handler);
|
|
69
|
+
|
|
70
|
+
// sort request handlers so that non-regexp uri's come first, to speed up search
|
|
71
|
+
this.handlers.sort((a, b) => {
|
|
72
|
+
const valA: number = a.match instanceof RegExp ? 1 : 0;
|
|
73
|
+
const valB: number = b.match instanceof RegExp ? 1 : 0;
|
|
74
|
+
|
|
75
|
+
return valA - valB;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// if there is a handler registered for the given URI, returns the handler, null otherwise
|
|
80
|
+
private getHandler(uri: string, method: RequestMethod): null|RequestHandler {
|
|
81
|
+
const segments = uri.split('/');
|
|
82
|
+
|
|
83
|
+
// narrowing down the possible handlers, first iteration
|
|
84
|
+
// any that have the correct number of segments and RegExp's
|
|
85
|
+
let possible = this.handlers.filter((handler) => {
|
|
86
|
+
return handler.methods.includes(method) && (handler.match instanceof RegExp || handler.match.length === segments.length);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// check uri, segment-by-segment
|
|
90
|
+
for (let i = 0; i < segments.length; i++) {
|
|
91
|
+
possible = possible.filter((handler) => {
|
|
92
|
+
if (handler.match instanceof RegExp) {
|
|
93
|
+
// match is a RegExp, keep if matches the uri
|
|
94
|
+
return handler.match.test(uri);
|
|
95
|
+
} else {
|
|
96
|
+
// handler.match is an array, check against current segment patter
|
|
97
|
+
// it can be a string or RegExp
|
|
98
|
+
const pattern = handler.match[i].pattern;
|
|
99
|
+
if (typeof pattern === 'string') {
|
|
100
|
+
return pattern === segments[i];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// current segment pattern is a RegExp
|
|
104
|
+
return pattern.test(segments[i]);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// no possible request handlers registered
|
|
110
|
+
if (possible.length === 0) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// multiple requests handlers match
|
|
115
|
+
if (possible.length > 1) {
|
|
116
|
+
console.warn(`Multiple request handlers for ${uri}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// prefer constant matches over RegExp
|
|
120
|
+
possible.sort((a, b) => {
|
|
121
|
+
if (a.match instanceof Array && b.match instanceof Array) {
|
|
122
|
+
// a an b are of same length
|
|
123
|
+
for (let i = a.match.length - 1; i > -1; i--) {
|
|
124
|
+
const aVal = a.match[i].pattern instanceof RegExp ? 1 : 0;
|
|
125
|
+
const bVal = b.match[i].pattern instanceof RegExp ? 1 : 0;
|
|
126
|
+
if (aVal != bVal) {
|
|
127
|
+
return aVal - bVal;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const aVal = a.match instanceof RegExp ? 1 : 0;
|
|
134
|
+
const bVal = b.match instanceof RegExp ? 1 : 0;
|
|
135
|
+
return aVal - bVal;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// return the best match
|
|
139
|
+
return possible[0];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// handle a request
|
|
143
|
+
// checks whether there is a registered handler for the URL
|
|
144
|
+
// if not then it tries to serve a static asset if path is allowd by Config.assets.allow
|
|
145
|
+
// if it's not allowed or the asset does not exits, 404 callback is executed
|
|
146
|
+
public async handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
|
|
147
|
+
const requestMethod = request.method as RequestMethod;
|
|
148
|
+
|
|
149
|
+
let uri = request.url || '/';
|
|
150
|
+
|
|
151
|
+
if (this.app.config.url.removeTrailingSlash && uri.length > 1 && uri.endsWith('/')) {
|
|
152
|
+
uri = uri.substring(0, uri.length - 1);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// extract any GET args from the URL-provided query string eg. /page?key=val
|
|
156
|
+
// once extracted, remove the query string part of the URL
|
|
157
|
+
let getArgs = {};
|
|
158
|
+
if (uri.indexOf('?') > -1) {
|
|
159
|
+
const uriParts = uri.split('?');
|
|
160
|
+
uri = uriParts[0];
|
|
161
|
+
getArgs = queryStringDecode(uriParts[1]);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// get the best matching request handler
|
|
165
|
+
const handler = this.getHandler(uri, requestMethod);
|
|
166
|
+
|
|
167
|
+
const context: RequestContext = {
|
|
168
|
+
request,
|
|
169
|
+
response,
|
|
170
|
+
handler,
|
|
171
|
+
args: {},
|
|
172
|
+
// RequestContext.data is place for user defined data
|
|
173
|
+
// it is initialized as an empty object here and
|
|
174
|
+
// potentially falsely declared as RequestContextData
|
|
175
|
+
// user will fill this out, usually on beforeRequestHandler
|
|
176
|
+
data: {} as RequestContextData,
|
|
177
|
+
getArgs,
|
|
178
|
+
cookies: this.app.cookies.parse(request),
|
|
179
|
+
isAjax : request.headers['x-requested-with'] == 'xmlhttprequest',
|
|
180
|
+
respondWith: function (data: any) {
|
|
181
|
+
if (typeof data === 'string' || Buffer.isBuffer(data)) {
|
|
182
|
+
response.write(data);
|
|
183
|
+
} else if (typeof data === 'number') {
|
|
184
|
+
response.write(data.toString());
|
|
185
|
+
} else if (data instanceof Document) {
|
|
186
|
+
response.setHeader('Content-Type', 'text/html');
|
|
187
|
+
response.write(data.toString());
|
|
188
|
+
} else if (data === undefined || data === null) {
|
|
189
|
+
response.write('');
|
|
190
|
+
} else {
|
|
191
|
+
response.setHeader('Content-Type', 'application/json');
|
|
192
|
+
response.write(JSON.stringify(data, null, 4));
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
redirect: (to: string, statusCode: number = 302) => {
|
|
196
|
+
this.redirect(response, to, statusCode);
|
|
197
|
+
},
|
|
198
|
+
show404: async() => {
|
|
199
|
+
await this.pageNotFoundCallback.apply(this.app, [context]);
|
|
200
|
+
this.app.emit('pageNotFound', context);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
if (handler !== null) {
|
|
206
|
+
// handler exists
|
|
207
|
+
if (! handler.staticAsset) {
|
|
208
|
+
// run beforeRequestHandler callbacks
|
|
209
|
+
const results = await this.app.emit('beforeRequestHandler', context);
|
|
210
|
+
|
|
211
|
+
// if any of the beforeRequestHandler callbacks returned false, end the request here
|
|
212
|
+
// this provides a way for the developer to prevent the request handler from being executed
|
|
213
|
+
// which is useful in some cases, for example:
|
|
214
|
+
// beforeRequestHandler checks if user is logged in, if not redirects to login
|
|
215
|
+
// and returns false to prevent unauthorized access to user-only page
|
|
216
|
+
if (results.includes(false)) {
|
|
217
|
+
context.response.end();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
// parse request body, this will populate ctx.bodyRaw and if possible ctx.body
|
|
223
|
+
await this.parseBody(context);
|
|
224
|
+
} catch(e) {
|
|
225
|
+
console.error(`Error parsing request body: ${e.message}`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// extract URI arguments, if pattern included capture groups, those will be included
|
|
229
|
+
// for example pattern /users/(userId:num) -> { userId: number }
|
|
230
|
+
const URIArgs = this.extractURIArguments(uri, handler.match);
|
|
231
|
+
context.args = URIArgs;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
// run the request handler callback
|
|
236
|
+
try {
|
|
237
|
+
const response = await handler.callback.apply(handler.scope, [context]);
|
|
238
|
+
if (! context.response.headersSent) {
|
|
239
|
+
// if the response was not sent from the request handler
|
|
240
|
+
// respond with whatever the handler has returned
|
|
241
|
+
context.respondWith(response);
|
|
242
|
+
}
|
|
243
|
+
} catch(e) {
|
|
244
|
+
console.log('Error executing request handler ', e, handler.callback.toString());
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (! handler.staticAsset) {
|
|
248
|
+
await this.app.emit('afterRequestHandler', context);
|
|
249
|
+
}
|
|
250
|
+
} else {
|
|
251
|
+
// handler not found, check if a static asset is requested
|
|
252
|
+
let staticAsset = false;
|
|
253
|
+
|
|
254
|
+
if (this.app.config.url.isAsset(context.request.url || '')) {
|
|
255
|
+
// static asset
|
|
256
|
+
// unless accessing /assets/ts/* go directory up to get out of build
|
|
257
|
+
const basePath = context.request.url?.startsWith('/assets/ts/') ? './' : '../';
|
|
258
|
+
const assetPath = path.resolve(basePath + context.request.url);
|
|
259
|
+
if (existsSync(assetPath)) {
|
|
260
|
+
await this.app.emit('beforeAssetAccess', context);
|
|
261
|
+
const extension = (context.request.url || '').split('.').pop();
|
|
262
|
+
if (extension) {
|
|
263
|
+
const contentType = this.app.contentType(extension);
|
|
264
|
+
if (contentType) {
|
|
265
|
+
response.setHeader('Content-Type', contentType);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
response.write(readFileSync(assetPath));
|
|
269
|
+
staticAsset = true;
|
|
270
|
+
await this.app.emit('afterAssetAccess', context);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (! staticAsset) {
|
|
275
|
+
// no request handler found nor a static asset - 404
|
|
276
|
+
await context.show404();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// end the response
|
|
282
|
+
response.end();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// extract variables from the given URI, using provided match which is defined by current request handler
|
|
286
|
+
// hence this only gets executed for requests that have a registered handler
|
|
287
|
+
private extractURIArguments(uri: string, match: Array<URISegmentPattern>|RegExp): URIArguments {
|
|
288
|
+
if (match instanceof RegExp) {
|
|
289
|
+
const matches = match.exec(uri);
|
|
290
|
+
if (matches) {
|
|
291
|
+
return {
|
|
292
|
+
matches
|
|
293
|
+
};
|
|
294
|
+
} else {
|
|
295
|
+
return {};
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const uriArgs:URIArguments = {};
|
|
300
|
+
|
|
301
|
+
const segments = uri.split('/');
|
|
302
|
+
|
|
303
|
+
match.forEach((segmentPattern, i) => {
|
|
304
|
+
if (segmentPattern.name) {
|
|
305
|
+
uriArgs[segmentPattern.name] = segmentPattern.type === 'number' ? parseInt(segments[i]) : segments[i];
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
return uriArgs;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// allows easy access to URI segments
|
|
313
|
+
// (varname) - match any value
|
|
314
|
+
// (varname:num) - match a number
|
|
315
|
+
private patternToSegments(pattern: string): Array<URISegmentPattern> {
|
|
316
|
+
const segments: Array<URISegmentPattern> = [];
|
|
317
|
+
|
|
318
|
+
const segmentsIn = pattern.split('/');
|
|
319
|
+
|
|
320
|
+
segmentsIn.forEach((segmentIn) => {
|
|
321
|
+
const named = /^\([^\/]+\)$/.test(segmentIn);
|
|
322
|
+
const segmentPattern: URISegmentPattern = {
|
|
323
|
+
pattern: segmentIn,
|
|
324
|
+
type: 'string'
|
|
325
|
+
}
|
|
326
|
+
if (named) {
|
|
327
|
+
const nameParts = /^\(([^\/:\)]+)/.exec(segmentIn);
|
|
328
|
+
const isNumber = /:num\)$/.test(segmentIn);
|
|
329
|
+
if (nameParts) {
|
|
330
|
+
segmentPattern.name = nameParts[1];
|
|
331
|
+
if (isNumber) {
|
|
332
|
+
segmentPattern.pattern = /^(\d+)$/;
|
|
333
|
+
segmentPattern.type = 'number';
|
|
334
|
+
} else {
|
|
335
|
+
segmentPattern.pattern = /^([^\/]+)$/;
|
|
336
|
+
segmentPattern.type = 'string';
|
|
337
|
+
}
|
|
338
|
+
} else {
|
|
339
|
+
console.warn(`Invalid URI segment pattern ${segmentIn} in URI ${pattern}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
segments.push(segmentPattern);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
return segments;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// parse raw request body
|
|
349
|
+
// if there is a parser for received Content-Type
|
|
350
|
+
// then ctx.body is populated with data: URIArgs
|
|
351
|
+
private async parseBody(ctx: Omit<RequestContext, 'data'>): Promise<void> {
|
|
352
|
+
if (ctx.request.headers['content-type']) {
|
|
353
|
+
|
|
354
|
+
ctx.bodyRaw = await this.dataRaw(ctx.request);
|
|
355
|
+
|
|
356
|
+
if (ctx.request.headers['content-type'].indexOf('urlencoded') > -1) {
|
|
357
|
+
// application/x-www-form-urlencoded
|
|
358
|
+
ctx.body = queryStringDecode(ctx.bodyRaw.toString('utf-8'));
|
|
359
|
+
} else if (ctx.request.headers['content-type'].indexOf('multipart/form-data') > -1) {
|
|
360
|
+
// multipart/form-data
|
|
361
|
+
let boundary: RegExpExecArray|null|string = /^multipart\/form-data; boundary=(.+)$/.exec(ctx.request.headers['content-type']);
|
|
362
|
+
if (boundary) {
|
|
363
|
+
boundary = `--${boundary[1]}`;
|
|
364
|
+
ctx.body = Request.parseBodyMultipart(ctx.bodyRaw.toString('utf-8'), boundary);
|
|
365
|
+
ctx.files = Request.multipartBodyFiles(ctx.bodyRaw.toString('binary'), boundary);
|
|
366
|
+
}
|
|
367
|
+
} else if (ctx.request.headers['content-type'].indexOf('application/json') > -1) {
|
|
368
|
+
// application/json
|
|
369
|
+
try {
|
|
370
|
+
ctx.body = JSON.parse(ctx.bodyRaw.toString());
|
|
371
|
+
} catch (e) {
|
|
372
|
+
// failed to parse the body
|
|
373
|
+
ctx.body = undefined;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// returns the raw request data (eg. POST'ed data)
|
|
381
|
+
private dataRaw(request: IncomingMessage): Promise<Buffer> {
|
|
382
|
+
const chunks: Array<Buffer> = [];
|
|
383
|
+
|
|
384
|
+
return new Promise((resolve, reject) => {
|
|
385
|
+
request.on('data', (chunk) => {
|
|
386
|
+
chunks.push(chunk);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
request.on('close', () => {
|
|
390
|
+
// calculate the total size of all chunks
|
|
391
|
+
const size = chunks.reduce((prev, curr) => {
|
|
392
|
+
return prev + curr.length;
|
|
393
|
+
}, 0);
|
|
394
|
+
|
|
395
|
+
// combine the chunks to form final data
|
|
396
|
+
const data = Buffer.concat(chunks, size);
|
|
397
|
+
|
|
398
|
+
resolve(data);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
request.on('error', (e) => {
|
|
402
|
+
reject(e);
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// send the headers to redirect the client, 302 redirect by default
|
|
408
|
+
// should be called before any output (before any res.write)
|
|
409
|
+
public redirect(response: ServerResponse, to: string, statusCode: number = 302): void {
|
|
410
|
+
response.setHeader('Location', to);
|
|
411
|
+
response.writeHead(statusCode);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// load request handlers from given directory recursively
|
|
415
|
+
// if directory is omitted, loads from conf.routes.path
|
|
416
|
+
public async loadHandlers(basePath?: string): Promise<void> {
|
|
417
|
+
let routesPath:string;
|
|
418
|
+
if (basePath) {
|
|
419
|
+
routesPath = basePath;
|
|
420
|
+
} else {
|
|
421
|
+
routesPath = path.resolve((this.app.config.runtime === 'Node.js' ? '../build/' : './') + this.app.config.routes.path);
|
|
422
|
+
}
|
|
423
|
+
const files = readdirSync(routesPath);
|
|
424
|
+
|
|
425
|
+
for (let i = 0; i < files.length; i++) {
|
|
426
|
+
const file = files[i];
|
|
427
|
+
if (! (file.endsWith('.js') || file.endsWith('.ts'))) {
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
const filePath = path.resolve(routesPath + '/' + file);
|
|
431
|
+
const isDirectory = statSync(filePath).isDirectory();
|
|
432
|
+
if (isDirectory) {
|
|
433
|
+
await this.loadHandlers(filePath);
|
|
434
|
+
} else {
|
|
435
|
+
const fn = (await import('file:///' + filePath)).default;
|
|
436
|
+
if (typeof fn === 'function') {
|
|
437
|
+
fn(this.app);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
public static queryStringDecode(queryString: string, initialValue: PostedDataDecoded = {}, trimValues: boolean = true): PostedDataDecoded {
|
|
446
|
+
return queryStringDecode(queryString, initialValue, trimValues);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// process raw multipart/form-data body into an object
|
|
450
|
+
// boundary has to be provided as second argument
|
|
451
|
+
public static parseBodyMultipart(bodyRaw: string, boundary: string): PostedDataDecoded {
|
|
452
|
+
const pairsRaw = bodyRaw.split(boundary);
|
|
453
|
+
const pairs = pairsRaw.map((pair) => {
|
|
454
|
+
const parts = /Content-Disposition: form-data; name="([^\r\n"]+)"\r?\n\r?\n([^$]+)/m.exec(pair);
|
|
455
|
+
if (parts) {
|
|
456
|
+
return {
|
|
457
|
+
key: parts[1],
|
|
458
|
+
value: parts[2]
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return null;
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
// convert data to query string
|
|
465
|
+
const urlEncoded = pairs.reduce((prev, curr) => {
|
|
466
|
+
if (curr !== null) {
|
|
467
|
+
prev.push(`${curr.key}=${encodeURIComponent(curr.value.replaceAll('&', '%26'))}`);
|
|
468
|
+
}
|
|
469
|
+
return prev;
|
|
470
|
+
}, [] as Array<string>).join('&');
|
|
471
|
+
|
|
472
|
+
return queryStringDecode(urlEncoded);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
public static multipartBodyFiles(bodyRaw: string, boundary: string): Record<string, RequestBodyFile> {
|
|
476
|
+
let files: Record<string, RequestBodyFile> = {}
|
|
477
|
+
const pairsRaw = bodyRaw.split(boundary);
|
|
478
|
+
pairsRaw.map((pair) => {
|
|
479
|
+
const parts = /Content-Disposition: form-data; name="(.+?)"; filename="(.+?)"\r\nContent-Type: (.*)\r\n\r\n([\s\S]+)$/m.exec(pair);
|
|
480
|
+
if (parts) {
|
|
481
|
+
const file: RequestBodyFile = {
|
|
482
|
+
data: Buffer.from(parts[4].substring(0, parts[4].length - 2).trim(), 'binary'),
|
|
483
|
+
fileName: parts[2],
|
|
484
|
+
type: parts[3]
|
|
485
|
+
}
|
|
486
|
+
// we can't just set the file as files[parts[1]] = file
|
|
487
|
+
// that would work if parts[1] is a simple key without "[.*]" in it
|
|
488
|
+
// but in reality key will often be an object or an array
|
|
489
|
+
// so we need to recursively create the object and fill it with file
|
|
490
|
+
// then merge that result with resulting files object
|
|
491
|
+
files = mergeDeep(files, queryStringDecodedSetValue(parts[1], file));
|
|
492
|
+
}
|
|
493
|
+
return null;
|
|
494
|
+
})
|
|
495
|
+
return files;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { randomString } from '../Util.js';
|
|
2
|
+
import { LooseObject, RequestContext, SessionEntry } from '../Types.js';
|
|
3
|
+
import { Application } from './Application.js';
|
|
4
|
+
|
|
5
|
+
export class Session {
|
|
6
|
+
|
|
7
|
+
application: Application;
|
|
8
|
+
enabled: boolean = false;
|
|
9
|
+
|
|
10
|
+
sessions: {
|
|
11
|
+
[key: string] : SessionEntry
|
|
12
|
+
} = {};
|
|
13
|
+
|
|
14
|
+
constructor(app: Application) {
|
|
15
|
+
this.application = app;
|
|
16
|
+
|
|
17
|
+
// bind the event listener to beforeRequestHandler
|
|
18
|
+
this.application.on('beforeRequestHandler', async (ctx: RequestContext) => {
|
|
19
|
+
if (this.enabled) {
|
|
20
|
+
const sessionCookie = ctx.cookies[this.application.config.session.cookieName];
|
|
21
|
+
|
|
22
|
+
const invalidSessionId = sessionCookie && ! this.sessions[sessionCookie];
|
|
23
|
+
|
|
24
|
+
if (! sessionCookie || invalidSessionId) {
|
|
25
|
+
// user has no started session, initialize session
|
|
26
|
+
this.sessionInit(ctx);
|
|
27
|
+
} else {
|
|
28
|
+
ctx.sessionId = sessionCookie;
|
|
29
|
+
if (ctx.sessionId) {
|
|
30
|
+
// refresh cookie
|
|
31
|
+
this.application.cookies.set(
|
|
32
|
+
ctx.response,
|
|
33
|
+
this.application.config.session.cookieName,
|
|
34
|
+
ctx.sessionId,
|
|
35
|
+
this.application.config.session.durationSeconds
|
|
36
|
+
);
|
|
37
|
+
this.sessions[ctx.sessionId].lastRequest = new Date().getTime();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// start garbage collecting
|
|
44
|
+
this.garbageCollect();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
public start(): void {
|
|
48
|
+
this.enabled = true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
public stop(): void {
|
|
52
|
+
this.enabled = false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private sessionInit(ctx: RequestContext): void {
|
|
56
|
+
ctx.sessionId = this.generateId();
|
|
57
|
+
this.application.cookies.set(ctx.response, this.application.config.session.cookieName, ctx.sessionId, this.application.config.session.durationSeconds);
|
|
58
|
+
|
|
59
|
+
// create and store session entry
|
|
60
|
+
const sessionEntry: SessionEntry = {
|
|
61
|
+
sessionId: ctx.sessionId,
|
|
62
|
+
lastRequest: new Date().getTime(),
|
|
63
|
+
data: {}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
this.sessions[ctx.sessionId] = sessionEntry;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private generateId(): string {
|
|
70
|
+
return randomString(this.application.config.session.keyLength);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// remove expired session entries
|
|
74
|
+
private garbageCollect(): void {
|
|
75
|
+
const time = new Date().getTime();
|
|
76
|
+
const sessDurationMilliseconds = this.application.config.session.garbageCollectAfterSeconds * 1000;
|
|
77
|
+
|
|
78
|
+
for (const sessionId in this.sessions) {
|
|
79
|
+
const sess = this.sessions[sessionId];
|
|
80
|
+
if (time - sess.lastRequest > sessDurationMilliseconds) {
|
|
81
|
+
// expired session
|
|
82
|
+
delete this.sessions[sessionId];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// resume garbage collection after configured interval
|
|
87
|
+
setTimeout(() => {
|
|
88
|
+
this.garbageCollect();
|
|
89
|
+
}, this.application.config.session.garbageCollectIntervalSeconds * 1000);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// reason for sessionId being allowed as undefined|null is that RequestContext.sessionId can be undefined
|
|
93
|
+
public setValue(sessionId: string|undefined, key: string, value: any): void {
|
|
94
|
+
if (sessionId === undefined) {return;}
|
|
95
|
+
if (this.sessions[sessionId]) {
|
|
96
|
+
const session = this.sessions[sessionId];
|
|
97
|
+
session.data[key] = value;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// value or null if session does not exist
|
|
102
|
+
public getValue<T>(sessionId: string|undefined, key: string): T|null {
|
|
103
|
+
if (sessionId === undefined) {return null;}
|
|
104
|
+
if (this.sessions[sessionId]) {
|
|
105
|
+
const session = this.sessions[sessionId];
|
|
106
|
+
return typeof session.data[key] !== 'undefined' ? session.data[key] : null;
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// return value and clear it from session
|
|
112
|
+
public getClear<T>(sessionId: string|undefined, key: string): T | null {
|
|
113
|
+
const val = this.getValue<T>(sessionId, key);
|
|
114
|
+
this.removeValue(sessionId, key);
|
|
115
|
+
return val;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
public removeValue(sessionId: string|undefined, key: string): void {
|
|
119
|
+
if (sessionId === undefined) {return;}
|
|
120
|
+
if (this.sessions[sessionId] && this.sessions[sessionId].data[key]) {
|
|
121
|
+
delete this.sessions[sessionId].data[key];
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// remove all stored data for the given session
|
|
126
|
+
public clear(sessionId: string|undefined): void {
|
|
127
|
+
if (sessionId === undefined) {return;}
|
|
128
|
+
if (this.sessions[sessionId]) {
|
|
129
|
+
this.sessions[sessionId].data = {};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// extract given keys from session and return them as an object
|
|
134
|
+
// key in keys can be a string in which case the key will remain the same in returned object
|
|
135
|
+
// or it can be an object { keyInSession : keyInReturnedData } in which case key in returned data will be keyInReturnedData
|
|
136
|
+
public extract(sessionId: string|undefined, keys: Array<string|{ [keyInSession: string] : string }>): LooseObject {
|
|
137
|
+
if (sessionId === undefined) {return {};}
|
|
138
|
+
const data: LooseObject = {};
|
|
139
|
+
keys.forEach((key) => {
|
|
140
|
+
if (typeof key === 'string') {
|
|
141
|
+
data[key] = this.getValue(sessionId, key);
|
|
142
|
+
} else {
|
|
143
|
+
const keyInSession = Object.keys(key)[0];
|
|
144
|
+
const keyReturned = key[keyInSession];
|
|
145
|
+
data[keyReturned] = this.getValue(sessionId, keyInSession);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
return data;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
}
|