structured-fw 1.3.4 → 1.5.0

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/README.md CHANGED
@@ -29,6 +29,13 @@ npm init -y
29
29
  npm install @types/node
30
30
  ```
31
31
 
32
+ ## Set type to "module" in package.json
33
+ ```
34
+ ...
35
+ "type": "module",
36
+ ...
37
+ ```
38
+
32
39
  *If you have TypeScript installed globally then you can skip the following*\
33
40
  `npm install --save-dev typescript`
34
41
 
@@ -258,44 +265,66 @@ Route file name has no effect on how the route (request handler) behaves, the on
258
265
  RequestContext is created for every received request. It contains all the data related to the request, as well as data you include in `RequestContextData`.
259
266
  All request handlers receive a `RequestContext` as the first argument. Your component server side part also receives `RequestContext` as the second argument.
260
267
  ```
261
- type RequestContext<Body extends LooseObject | undefined = LooseObject> = {
262
- request: IncomingMessage,
263
- response: ServerResponse,
264
- args: URIArguments,
265
- handler: null|RequestHandler,
268
+ class RequestContext<Body extends LooseObject | undefined = LooseObject> = {
269
+ app: Application;
270
+ uri: string;
266
271
 
267
- cookies: Record<string, string>,
272
+ request: IncomingMessage;
273
+ response: ServerResponse;
274
+
275
+ // captured URI arguments
276
+ // for example if the requested URI was /users/8 and the pattern was /users/(userId:num)
277
+ // args will be { userId: 8 }
278
+ args: URIArguments;
279
+
280
+ // request cookies parsed into an object
281
+ cookies: Record<string, string>;
268
282
 
269
283
  // POSTed data, parsed to object
270
- body?: LooseObject,
284
+ body?: LooseObject;
271
285
 
272
- bodyRaw?: Buffer,
286
+ bodyRaw?: Buffer;
273
287
 
274
288
  // files extracted from request body
275
- files?: Record<string, RequestBodyRecordValue>,
289
+ files?: Record<string, RequestBodyRecordValue>;
276
290
 
277
291
  // user defined data
278
- data: RequestContextData,
292
+ data: RequestContextData;
279
293
 
280
- // if session is started and user has visited any page
281
- sessionId?: string,
294
+ // defined if session is started and user has visited any page
295
+ sessionId?: string;
282
296
 
283
- // true if x-requested-with header is received and it equals 'xmlhttprequest'
284
- isAjax: boolean,
297
+ // true if x-requested-with header value is 'xmlhttprequest'
298
+ isAjax: () => boolean;
285
299
 
286
300
  // time when request was received (unix timestamp in milliseconds)
287
- timeStart: number,
301
+ timeStart: number;
302
+
303
+ // URL GET arguments, for example if the URI isss /users?id=8
304
+ // getArgs would be { id: '8' }
305
+ getArgs: PostedDataDecoded;
288
306
 
289
- // URL GET arguments
290
- getArgs: PostedDataDecoded,
307
+ // create a Document and load given component
308
+ createDocument: (title: string, component: string, data?: LooseObject) => Promise<Document>;
309
+
310
+ // create a Document using provided layout
311
+ layoutDocument: layoutDocument(
312
+ layout: Layout,
313
+ title: string,
314
+ component: string,
315
+ data?: LooseObject,
316
+ attributes?: Record<string, string>
317
+ ) => Promise<Document>;
291
318
 
292
319
  // send given data as a response
293
- respondWith: (data: any) => Promise<void>,
320
+ respondWith: (data: any) => Promise<void>;
294
321
 
295
- // redirect to given url, with given statusCode
296
- redirect: (to: string, statusCode?: number) => void,
322
+ // redirect to given URI, with given statusCode (default 302)
323
+ redirect: (to: string, statusCode?: number) => void;
297
324
 
298
- // show a 404 page
325
+ // show a 404 page and send a 404 status code
326
+ // by default it will be a page with content "Page not found"
327
+ // to change this you can set app.request.pageNotFoundCallback to a function that returns a Document
299
328
  show404: () => Promise<void>
300
329
  }
301
330
  ```
@@ -371,6 +400,15 @@ app.request.on('GET', '/home', async (ctx) => {
371
400
  });
372
401
  ```
373
402
 
403
+ Above code could be simplified using RequestContext.createDocument method as:
404
+ ```
405
+ app.request.on('GET', '/home', async (ctx) => {
406
+ return await ctx.createDocument('Home', 'Home');
407
+ });
408
+ ```
409
+
410
+ Much shorter, but slightly confusing due to bad example (both arguments being 'Home'). First argument is the document title, second argument is the name of the component you want to load.
411
+
374
412
  > [!TIP]
375
413
  > Since version 0.8.4 Document extends EventEmitter, and "componentCreated" event is emitted whenever a component instance is created within the Document.\
376
414
  > This makes the following possible:
@@ -444,13 +482,24 @@ That was the simplest possible example, let's make it more interesting by adding
444
482
  ### Component server-side code
445
483
  Create a new file `/app/views/HelloWorld/HelloWorld.ts` (server side component code):
446
484
  ```
485
+ import { Application } from 'structured-fw/Application';
447
486
  import { ComponentScaffold } from 'structured-fw/Types';
448
- export default class HelloWorld implements ComponentScaffold {
449
- async getData(): Promise<{
450
- luckyNumber: number
451
- }> {
487
+ import { RequestContext } from 'structured-fw/RequestContext';
488
+
489
+ type ComponentInput = {
490
+ name: string,
491
+ }
492
+
493
+ type ComponentOutput = {
494
+ greetName: string,
495
+ luckyNumber: number,
496
+ }
497
+
498
+ export default class HelloWorld implements ComponentScaffold<ComponentInput, ComponentOutput> {
499
+ async getData(data: ComponentInput, ctx: RequestContext, app: Application): Promise<ComponentOutput> {
452
500
  return {
453
- luckyNumber: this.num()
501
+ greetName: data.name,
502
+ luckyNumber: this.num(),
454
503
  }
455
504
  }
456
505
 
@@ -462,7 +511,7 @@ export default class HelloWorld implements ComponentScaffold {
462
511
 
463
512
  Update `HelloWorld.html`:
464
513
  ```
465
- Hello, World!<br>
514
+ Hello, {{greetName}}!<br>
466
515
  Your lucky number is {{luckyNumber}}
467
516
  ```
468
517
 
@@ -60,7 +60,7 @@ export declare class ClientComponent extends EventEmitter {
60
60
  private destroy;
61
61
  remove(): Promise<void>;
62
62
  bind<T extends any>(element: ClientComponent, event: string, callback: EventEmitterCallback<T>): void;
63
- bind<T extends LooseObject | undefined>(element: HTMLElement | Window | Array<HTMLElement | Window>, event: keyof HTMLElementEventMap | Array<keyof HTMLElementEventMap>, callback: ClientComponentEventCallback<T>): void;
63
+ bind<T extends LooseObject | undefined, Evt extends Event = Event>(element: HTMLElement | Window | Array<HTMLElement | Window>, event: keyof HTMLElementEventMap | Array<keyof HTMLElementEventMap>, callback: ClientComponentEventCallback<T, Evt>): void;
64
64
  unbind<T extends LooseObject | undefined = undefined>(element: HTMLElement | Window | ClientComponent, event: keyof HTMLElementEventMap | Array<keyof HTMLElementEventMap>, callback: ClientComponentEventCallback<T> | EventEmitterCallback<T>): void;
65
65
  private unbindOwn;
66
66
  private unbindAll;
@@ -2,13 +2,13 @@ import { Server } from 'node:http';
2
2
  import { ApplicationEvents } from '../types/application.types.js';
3
3
  import { StructuredConfig } from '../types/structured.types.js';
4
4
  import { LooseObject } from '../types/general.types.js';
5
- import { RequestContext } from "../types/request.types.js";
6
5
  import { Document } from './Document.js';
7
6
  import { Components } from './Components.js';
8
7
  import { Session } from './Session.js';
9
8
  import { Request } from './Request.js';
10
9
  import { Handlebars } from './Handlebars.js';
11
10
  import { Cookies } from './Cookies.js';
11
+ import { RequestContext } from './RequestContext.js';
12
12
  export declare class Application {
13
13
  readonly config: StructuredConfig;
14
14
  initialized: boolean;
@@ -32,6 +32,7 @@ export declare class Application {
32
32
  contentType(extension: string): string | false;
33
33
  registerPlugin<Opt extends Readonly<LooseObject>>(callback: (app: Application, options: Opt) => void | Promise<void>, opts: NoInfer<Opt>): Promise<void>;
34
34
  private respondWithComponent;
35
+ directory(uri?: string): string;
35
36
  memoryUsage(): NodeJS.MemoryUsage;
36
37
  printMemoryUsage(): void;
37
38
  }
@@ -167,6 +167,13 @@ export class Application {
167
167
  }
168
168
  return false;
169
169
  }
170
+ directory(uri) {
171
+ const dir = path.resolve('../');
172
+ if (typeof uri === 'string') {
173
+ return path.resolve(dir, uri.startsWith('/') ? uri.substring(1) : uri);
174
+ }
175
+ return dir;
176
+ }
170
177
  memoryUsage() {
171
178
  return process.memoryUsage();
172
179
  }
@@ -1,10 +1,10 @@
1
1
  import { ServerResponse } from 'node:http';
2
2
  import { LooseObject } from '../types/general.types.js';
3
3
  import { Initializers } from '../types/component.types.js';
4
- import { RequestContext } from "../types/request.types.js";
5
4
  import { Application } from './Application.js';
6
5
  import { DocumentHead } from './DocumentHead.js';
7
6
  import { Component } from './Component.js';
7
+ import { RequestContext } from './RequestContext.js';
8
8
  export declare class Document extends Component<{
9
9
  'componentCreated': Component;
10
10
  'beforeRender': void;
@@ -17,9 +17,9 @@ export declare class Document extends Component<{
17
17
  initializers: Initializers;
18
18
  initializersInitialized: boolean;
19
19
  componentIds: Array<string>;
20
- ctx: undefined | RequestContext;
20
+ ctx: RequestContext;
21
21
  appendHTML: string;
22
- constructor(app: Application, title: string, ctx?: RequestContext<any>);
22
+ constructor(app: Application, title: string, ctx: RequestContext<any>);
23
23
  push(response: ServerResponse): void;
24
24
  body(): string;
25
25
  initInitializers(): Record<string, string>;
@@ -1,7 +1,7 @@
1
1
  import { LooseObject } from '../types/general.types.js';
2
- import { RequestContext } from "../types/request.types.js";
3
2
  import { Application } from "./Application.js";
4
3
  import { Document } from "./Document.js";
4
+ import { RequestContext } from './RequestContext.js';
5
5
  export declare class Layout {
6
6
  layoutComponent: string;
7
7
  app: Application;
@@ -9,5 +9,5 @@ export declare class Layout {
9
9
  attributes: Record<string, string>;
10
10
  constructor(app: Application, layoutComponent: string, language?: string, attributes?: Record<string, string>);
11
11
  private layoutComponentExists;
12
- document(ctx: RequestContext, title: string, componentName: string, data?: LooseObject, attributes?: Record<string, string>): Promise<Document>;
12
+ document(ctx: RequestContext<any>, title: string, componentName: string, data?: LooseObject, attributes?: Record<string, string>): Promise<Document>;
13
13
  }
@@ -1,24 +1,16 @@
1
1
  import { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { LooseObject } from '../types/general.types.js';
3
- import { RequestMethod, PostedDataDecoded, RequestBodyFile, RequestCallback } from "../types/request.types.js";
3
+ import { RequestMethod, RequestCallback } from "../types/request.types.js";
4
4
  import { Application } from "./Application.js";
5
5
  import { Document } from "./Document.js";
6
6
  export declare class Request {
7
7
  private app;
8
+ pageNotFoundCallback: RequestCallback<void | Document, LooseObject | undefined>;
8
9
  constructor(app: Application);
9
- pageNotFoundCallback: RequestCallback<void | Document, LooseObject>;
10
10
  private readonly handlers;
11
11
  on<R extends any, Body extends LooseObject | undefined = LooseObject>(methods: RequestMethod | Array<RequestMethod>, pattern: string | RegExp | Array<string | RegExp>, callback: RequestCallback<R, Body>, scope?: any, isStaticAsset?: boolean): void;
12
12
  private getHandler;
13
13
  handle(request: IncomingMessage, response: ServerResponse): Promise<void>;
14
- private extractURIArguments;
15
14
  private patternToSegments;
16
- private parseBody;
17
- private dataRaw;
18
- redirect(response: ServerResponse, to: string, statusCode?: number): void;
19
15
  loadHandlers(basePath?: string): Promise<void>;
20
- static queryStringDecode(queryString: string, initialValue?: PostedDataDecoded, trimValues?: boolean): PostedDataDecoded;
21
- static parseBodyMultipart(bodyRaw: string, boundary: string): PostedDataDecoded;
22
- static multipartBodyFiles(bodyRaw: string, boundary: string): Record<string, RequestBodyFile>;
23
- private sendResponse;
24
16
  }
@@ -1,9 +1,6 @@
1
- import { mergeDeep, queryStringDecode, queryStringDecodedSetValue } from "../Util.js";
2
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
3
2
  import path from "node:path";
4
- import { Buffer } from "node:buffer";
5
- import { Document } from "./Document.js";
6
- import zlib from "node:zlib";
3
+ import { RequestContext } from "./RequestContext.js";
7
4
  export class Request {
8
5
  constructor(app) {
9
6
  this.pageNotFoundCallback = async ({ response }) => {
@@ -90,128 +87,12 @@ export class Request {
90
87
  if (this.app.config.url.removeTrailingSlash && uri.length > 1 && uri.endsWith('/')) {
91
88
  uri = uri.substring(0, uri.length - 1);
92
89
  }
93
- let getArgs = {};
94
90
  if (uri.indexOf('?') > -1) {
95
91
  const uriParts = uri.split('?');
96
92
  uri = uriParts[0];
97
- getArgs = queryStringDecode(uriParts[1]);
98
93
  }
99
94
  const handler = this.getHandler(uri, requestMethod);
100
- const context = {
101
- request,
102
- response,
103
- handler,
104
- args: {},
105
- data: {},
106
- body: undefined,
107
- getArgs,
108
- cookies: this.app.cookies.parse(request),
109
- timeStart: new Date().getTime(),
110
- isAjax: request.headers['x-requested-with'] == 'xmlhttprequest',
111
- respondWith: async (data) => {
112
- if (typeof data === 'string' || Buffer.isBuffer(data)) {
113
- this.sendResponse(request, response, data, 'text/plain; charset=utf-8');
114
- }
115
- else if (typeof data === 'number') {
116
- this.sendResponse(request, response, data.toString(), 'text/plain; charset=utf-8');
117
- }
118
- else if (data instanceof Document) {
119
- this.sendResponse(request, response, await data.toString(), 'text/html; charset=utf-8');
120
- }
121
- else if (data === undefined || data === null) {
122
- this.sendResponse(request, response, '', 'text/plain; charset=utf-8');
123
- }
124
- else {
125
- this.sendResponse(request, response, JSON.stringify(data, null, 4), 'application/json; charset=utf-8');
126
- }
127
- },
128
- redirect: (to, statusCode = 302) => {
129
- this.redirect(response, to, statusCode);
130
- },
131
- show404: async () => {
132
- this.app.emit('pageNotFound', context);
133
- response.statusCode = 404;
134
- const res = await this.pageNotFoundCallback.apply(this.app, [context]);
135
- if (res instanceof Document) {
136
- await context.respondWith(res);
137
- }
138
- }
139
- };
140
- if (handler !== null) {
141
- if (!handler.staticAsset) {
142
- const results = await this.app.emit('beforeRequestHandler', context);
143
- if (results.includes(false)) {
144
- context.response.end();
145
- return;
146
- }
147
- try {
148
- await this.parseBody(context);
149
- }
150
- catch (e) {
151
- console.error(`Error parsing request body: ${e.message}`);
152
- }
153
- const URIArgs = this.extractURIArguments(uri, handler.match);
154
- context.args = URIArgs;
155
- }
156
- try {
157
- const response = await handler.callback.apply(handler.scope, [context]);
158
- if (!context.response.headersSent) {
159
- await context.respondWith(response);
160
- }
161
- }
162
- catch (e) {
163
- console.log('Error executing request handler ', e, handler.callback.toString());
164
- }
165
- if (!handler.staticAsset) {
166
- await this.app.emit('afterRequestHandler', context);
167
- }
168
- }
169
- else {
170
- let staticAsset = false;
171
- if (this.app.config.url.isAsset(context.request.url || '')) {
172
- const basePath = context.request.url?.startsWith('/assets/ts/') ? './' : '../';
173
- const assetPath = path.resolve(basePath + context.request.url);
174
- if (existsSync(assetPath)) {
175
- await this.app.emit('beforeAssetAccess', context);
176
- const extension = (context.request.url || '').split('.').pop();
177
- let contentType = 'application/javascript';
178
- if (extension) {
179
- const typeByExtension = this.app.contentType(extension);
180
- if (typeByExtension) {
181
- contentType = typeByExtension;
182
- }
183
- }
184
- this.sendResponse(request, response, readFileSync(assetPath), contentType);
185
- staticAsset = true;
186
- await this.app.emit('afterAssetAccess', context);
187
- }
188
- }
189
- if (!staticAsset) {
190
- await context.show404();
191
- }
192
- }
193
- response.end();
194
- }
195
- extractURIArguments(uri, match) {
196
- if (match instanceof RegExp) {
197
- const matches = match.exec(uri);
198
- if (matches) {
199
- return {
200
- matches
201
- };
202
- }
203
- else {
204
- return {};
205
- }
206
- }
207
- const uriArgs = {};
208
- const segments = uri.split('/');
209
- match.forEach((segmentPattern, i) => {
210
- if (segmentPattern.name) {
211
- uriArgs[segmentPattern.name] = segmentPattern.type === 'number' ? parseInt(segments[i]) : segments[i];
212
- }
213
- });
214
- return uriArgs;
95
+ new RequestContext(this.app, request, response, handler, this.pageNotFoundCallback);
215
96
  }
216
97
  patternToSegments(pattern) {
217
98
  const segments = [];
@@ -244,81 +125,6 @@ export class Request {
244
125
  });
245
126
  return segments;
246
127
  }
247
- async parseBody(ctx) {
248
- if (ctx.request.headers['content-type']) {
249
- ctx.bodyRaw = await this.dataRaw(ctx.request);
250
- if (ctx.request.headers['content-type'].indexOf('urlencoded') > -1) {
251
- const bodyRaw = ctx.bodyRaw.toString('utf-8');
252
- try {
253
- ctx.body = queryStringDecode(bodyRaw);
254
- }
255
- catch (e) {
256
- console.error(`Error parsing urlencoded request body for request to ${ctx.request.url}, (${e.message}).
257
- Raw data:
258
- ${bodyRaw}
259
-
260
- `);
261
- }
262
- }
263
- else if (ctx.request.headers['content-type'].indexOf('multipart/form-data') > -1) {
264
- let boundary = /^multipart\/form-data; boundary=(.+)$/.exec(ctx.request.headers['content-type']);
265
- if (boundary) {
266
- boundary = `--${boundary[1]}`;
267
- try {
268
- ctx.body = Request.parseBodyMultipart(ctx.bodyRaw.toString('utf-8'), boundary);
269
- }
270
- catch (e) {
271
- console.error(`Error parsing multipart request body for request to ${ctx.request.url}, (${e.message}).
272
- Raw data:
273
- ${ctx.bodyRaw.toString('utf-8')}
274
-
275
- `);
276
- }
277
- try {
278
- ctx.files = Request.multipartBodyFiles(ctx.bodyRaw.toString('binary'), boundary);
279
- }
280
- catch (e) {
281
- console.error(`Error parsing multipart request body files for request to ${ctx.request.url}, (${e.message}).
282
- Raw data:
283
- ${ctx.bodyRaw.toString('utf-8')}
284
-
285
- `);
286
- }
287
- }
288
- }
289
- else if (ctx.request.headers['content-type'].indexOf('application/json') > -1) {
290
- try {
291
- ctx.body = JSON.parse(ctx.bodyRaw.toString());
292
- }
293
- catch (e) {
294
- ctx.body = undefined;
295
- }
296
- }
297
- }
298
- return;
299
- }
300
- dataRaw(request) {
301
- const chunks = [];
302
- return new Promise((resolve, reject) => {
303
- request.on('data', (chunk) => {
304
- chunks.push(chunk);
305
- });
306
- request.on('close', () => {
307
- const size = chunks.reduce((prev, curr) => {
308
- return prev + curr.length;
309
- }, 0);
310
- const data = Buffer.concat(chunks, size);
311
- resolve(data);
312
- });
313
- request.on('error', (e) => {
314
- reject(e);
315
- });
316
- });
317
- }
318
- redirect(response, to, statusCode = 302) {
319
- response.setHeader('Location', to);
320
- response.writeHead(statusCode);
321
- }
322
128
  async loadHandlers(basePath) {
323
129
  let routesPath;
324
130
  if (basePath) {
@@ -348,76 +154,5 @@ export class Request {
348
154
  }
349
155
  }
350
156
  }
351
- return;
352
- }
353
- static queryStringDecode(queryString, initialValue = {}, trimValues = true) {
354
- return queryStringDecode(queryString, initialValue, trimValues);
355
- }
356
- static parseBodyMultipart(bodyRaw, boundary) {
357
- const pairsRaw = bodyRaw.split(boundary);
358
- const pairs = pairsRaw.map((pair) => {
359
- const parts = pair.split(/\r?\n\r?\n/, 2).filter((part) => { return part.length > 0; });
360
- if (parts.length > 0) {
361
- const header = parts[0];
362
- const data = typeof parts[1] === 'string' ? parts[1].trim() : '';
363
- const headerParts = /Content-Disposition: form-data; name="([^\r\n"]+)"/m.exec(header);
364
- if (headerParts) {
365
- return {
366
- key: headerParts[1],
367
- value: data
368
- };
369
- }
370
- }
371
- return null;
372
- });
373
- const urlEncoded = pairs.reduce((prev, curr) => {
374
- if (curr !== null) {
375
- prev.push(`${curr.key}=${encodeURIComponent(curr.value.replaceAll('&', '%26'))}`);
376
- }
377
- return prev;
378
- }, []).join('&');
379
- return queryStringDecode(urlEncoded);
380
- }
381
- static multipartBodyFiles(bodyRaw, boundary) {
382
- let files = {};
383
- const pairsRaw = bodyRaw.split(boundary);
384
- pairsRaw.map((pair) => {
385
- const parts = /Content-Disposition: form-data; name="(.+?)"; filename="(.+?)"\r\nContent-Type: (.*)\r\n\r\n([\s\S]+)$/m.exec(pair);
386
- if (parts) {
387
- const file = {
388
- data: Buffer.from(parts[4].substring(0, parts[4].length - 2).trim(), 'binary'),
389
- fileName: parts[2],
390
- type: parts[3]
391
- };
392
- files = mergeDeep(files, queryStringDecodedSetValue(parts[1], file));
393
- }
394
- return null;
395
- });
396
- return files;
397
- }
398
- sendResponse(request, response, buffer, contentType) {
399
- const mimeType = contentType.split(';')[0];
400
- const gzipResponse = this.app.config.gzip.enabled &&
401
- this.app.config.gzip.types.includes(mimeType) &&
402
- request.headers['accept-encoding']?.includes('gzip') &&
403
- buffer.length >= this.app.config.gzip.minSize;
404
- if (!response.hasHeader('Content-Type')) {
405
- response.setHeader('Content-Type', contentType);
406
- }
407
- if (typeof buffer === 'string') {
408
- buffer = Buffer.from(buffer, 'utf-8');
409
- }
410
- if (gzipResponse) {
411
- response.setHeader('Content-Encoding', 'gzip');
412
- const compressed = zlib.gzipSync(buffer, {
413
- level: this.app.config.gzip.compressionLevel
414
- });
415
- response.setHeader('Content-Length', compressed.length);
416
- response.write(compressed);
417
- }
418
- else {
419
- response.setHeader('Content-Length', buffer.length);
420
- response.write(buffer);
421
- }
422
157
  }
423
158
  }
@@ -0,0 +1,41 @@
1
+ import { IncomingMessage, ServerResponse } from "node:http";
2
+ import { LooseObject, PostedDataDecoded, RequestBodyRecordValue, RequestCallback, RequestHandler, URIArguments } from "../Types.js";
3
+ import { Application } from "./Application.js";
4
+ import { Document } from "./Document.js";
5
+ import { Layout } from "./Layout.js";
6
+ export declare class RequestContext<Body extends LooseObject | undefined = LooseObject> {
7
+ readonly app: Application;
8
+ uri: string;
9
+ private readonly pageNotFoundCallback;
10
+ private readonly handler;
11
+ readonly request: IncomingMessage;
12
+ readonly response: ServerResponse;
13
+ args: URIArguments;
14
+ cookies: Record<string, string>;
15
+ body: Body;
16
+ bodyRaw?: Buffer;
17
+ files?: Record<string, RequestBodyRecordValue>;
18
+ data: RequestContextData;
19
+ sessionId?: string;
20
+ getArgs: PostedDataDecoded;
21
+ readonly timeStart: number;
22
+ private streamingData;
23
+ constructor(app: Application, request: IncomingMessage, response: ServerResponse, handler: RequestHandler | null, pageNotFoundCallback: RequestCallback<void | Document, LooseObject | undefined>);
24
+ private exec;
25
+ respondWith(data: any): Promise<void>;
26
+ private sendResponse;
27
+ createDocument(title: string, component: string, data?: LooseObject): Promise<Document>;
28
+ layoutDocument(layout: Layout, title: string, component: string, data?: LooseObject, attributes?: Record<string, string>): Promise<Document>;
29
+ show404(): Promise<void>;
30
+ private initGetArgs;
31
+ private parseCookies;
32
+ redirect(to: string, statusCode?: number): void;
33
+ private dataRaw;
34
+ private parseBody;
35
+ private parseBodyMultipart;
36
+ private multipartBodyFiles;
37
+ private extractURIArguments;
38
+ private handle;
39
+ isAjax(): boolean;
40
+ private error;
41
+ }
@@ -0,0 +1,307 @@
1
+ import zlib from "node:zlib";
2
+ import { mergeDeep, queryStringDecode, queryStringDecodedSetValue } from "../Util.js";
3
+ import { Document } from "./Document.js";
4
+ import path from "node:path";
5
+ import { existsSync, readFileSync, ReadStream } from "node:fs";
6
+ export class RequestContext {
7
+ constructor(app, request, response, handler, pageNotFoundCallback) {
8
+ this.args = {};
9
+ this.cookies = {};
10
+ this.data = {};
11
+ this.getArgs = {};
12
+ this.streamingData = false;
13
+ this.timeStart = Date.now();
14
+ this.uri = request.url || '/';
15
+ this.app = app;
16
+ this.request = request;
17
+ this.response = response;
18
+ this.handler = handler;
19
+ this.body = undefined;
20
+ this.pageNotFoundCallback = pageNotFoundCallback;
21
+ this.exec();
22
+ }
23
+ async exec() {
24
+ this.initGetArgs();
25
+ this.parseCookies();
26
+ if (this.handler) {
27
+ try {
28
+ await this.parseBody();
29
+ }
30
+ catch (e) {
31
+ this.error(e);
32
+ }
33
+ }
34
+ await this.handle();
35
+ }
36
+ async respondWith(data) {
37
+ if (typeof data === 'string' || Buffer.isBuffer(data)) {
38
+ this.sendResponse(data, 'text/plain; charset=utf-8');
39
+ }
40
+ else if (typeof data === 'number') {
41
+ this.sendResponse(data.toString(), 'text/plain; charset=utf-8');
42
+ }
43
+ else if (data instanceof Document) {
44
+ this.sendResponse(await data.toString(), 'text/html; charset=utf-8');
45
+ }
46
+ else if (data === undefined || data === null) {
47
+ this.sendResponse('', 'text/plain; charset=utf-8');
48
+ }
49
+ else if (data instanceof ReadStream) {
50
+ this.streamingData = true;
51
+ data.once('end', () => {
52
+ this.response.end();
53
+ });
54
+ data.pipe(this.response);
55
+ }
56
+ else {
57
+ this.sendResponse(JSON.stringify(data, null, 4), 'application/json; charset=utf-8');
58
+ }
59
+ }
60
+ sendResponse(buffer, contentType) {
61
+ const mimeType = contentType.split(';')[0];
62
+ const gzipResponse = this.app.config.gzip.enabled &&
63
+ this.app.config.gzip.types.includes(mimeType) &&
64
+ this.request.headers['accept-encoding']?.includes('gzip') &&
65
+ buffer.length >= this.app.config.gzip.minSize;
66
+ if (!this.response.hasHeader('Content-Type')) {
67
+ this.response.setHeader('Content-Type', contentType);
68
+ }
69
+ if (typeof buffer === 'string') {
70
+ buffer = Buffer.from(buffer, 'utf-8');
71
+ }
72
+ if (gzipResponse) {
73
+ this.response.setHeader('Content-Encoding', 'gzip');
74
+ const compressed = zlib.gzipSync(buffer, {
75
+ level: this.app.config.gzip.compressionLevel
76
+ });
77
+ this.response.setHeader('Content-Length', compressed.length);
78
+ this.response.write(compressed);
79
+ }
80
+ else {
81
+ this.response.setHeader('Content-Length', buffer.length);
82
+ this.response.write(buffer);
83
+ }
84
+ }
85
+ async createDocument(title, component, data) {
86
+ const doc = new Document(this.app, title, this);
87
+ await doc.loadComponent(component, data);
88
+ return doc;
89
+ }
90
+ async layoutDocument(layout, title, component, data, attributes) {
91
+ return await layout.document(this, title, component, data, attributes);
92
+ }
93
+ async show404() {
94
+ this.app.emit('pageNotFound', this);
95
+ this.response.statusCode = 404;
96
+ const res = await this.pageNotFoundCallback.apply(this.app, [this]);
97
+ if (res instanceof Document) {
98
+ await this.respondWith(res);
99
+ }
100
+ }
101
+ initGetArgs() {
102
+ if (this.uri.indexOf('?') > -1) {
103
+ const uriParts = this.uri.split('?');
104
+ this.uri = uriParts[0];
105
+ this.getArgs = queryStringDecode(uriParts[1]);
106
+ }
107
+ }
108
+ parseCookies() {
109
+ this.cookies = this.app.cookies.parse(this.request);
110
+ }
111
+ redirect(to, statusCode = 302) {
112
+ this.response.setHeader('Location', to);
113
+ this.response.writeHead(statusCode);
114
+ }
115
+ dataRaw(request) {
116
+ const chunks = [];
117
+ return new Promise((resolve, reject) => {
118
+ request.on('data', (chunk) => {
119
+ chunks.push(chunk);
120
+ });
121
+ request.on('close', () => {
122
+ const size = chunks.reduce((prev, curr) => {
123
+ return prev + curr.length;
124
+ }, 0);
125
+ const data = Buffer.concat(chunks, size);
126
+ resolve(data);
127
+ });
128
+ request.on('error', (e) => {
129
+ reject(e);
130
+ });
131
+ });
132
+ }
133
+ async parseBody() {
134
+ if (this.request.headers['content-type']) {
135
+ this.bodyRaw = await this.dataRaw(this.request);
136
+ if (this.request.headers['content-type'].indexOf('urlencoded') > -1) {
137
+ const bodyRaw = this.bodyRaw.toString('utf-8');
138
+ try {
139
+ this.body = queryStringDecode(bodyRaw);
140
+ }
141
+ catch (e) {
142
+ console.error(`Error parsing urlencoded request body for request to ${this.request.url}, (${e.message}).
143
+ Raw data:
144
+ ${bodyRaw}
145
+
146
+ `);
147
+ }
148
+ }
149
+ else if (this.request.headers['content-type'].indexOf('multipart/form-data') > -1) {
150
+ let boundary = /^multipart\/form-data; boundary=(.+)$/.exec(this.request.headers['content-type']);
151
+ if (boundary) {
152
+ boundary = `--${boundary[1]}`;
153
+ try {
154
+ this.body = this.parseBodyMultipart(this.bodyRaw.toString('utf-8'), boundary);
155
+ }
156
+ catch (e) {
157
+ console.error(`Error parsing multipart request body for request to ${this.request.url}, (${e.message}).
158
+ Raw data:
159
+ ${this.bodyRaw.toString('utf-8')}
160
+
161
+ `);
162
+ }
163
+ try {
164
+ this.files = this.multipartBodyFiles(this.bodyRaw.toString('binary'), boundary);
165
+ }
166
+ catch (e) {
167
+ this.error(`Error parsing multipart request body files: (${e.message}).
168
+ Raw data:
169
+ ${this.bodyRaw.toString('utf-8')}
170
+
171
+ `);
172
+ }
173
+ }
174
+ }
175
+ else if (this.request.headers['content-type'].indexOf('application/json') > -1) {
176
+ try {
177
+ this.body = JSON.parse(this.bodyRaw.toString());
178
+ }
179
+ catch (e) {
180
+ this.body = undefined;
181
+ }
182
+ }
183
+ }
184
+ }
185
+ parseBodyMultipart(bodyRaw, boundary) {
186
+ const pairsRaw = bodyRaw.split(boundary);
187
+ const pairs = pairsRaw.map((pair) => {
188
+ const parts = pair.split(/\r?\n\r?\n/, 2).filter((part) => { return part.length > 0; });
189
+ if (parts.length > 0) {
190
+ const header = parts[0];
191
+ const data = typeof parts[1] === 'string' ? parts[1].trim() : '';
192
+ const headerParts = /Content-Disposition: form-data; name="([^\r\n"]+)"/m.exec(header);
193
+ if (headerParts) {
194
+ return {
195
+ key: headerParts[1],
196
+ value: data
197
+ };
198
+ }
199
+ }
200
+ return null;
201
+ });
202
+ const urlEncoded = pairs.reduce((prev, curr) => {
203
+ if (curr !== null) {
204
+ prev.push(`${curr.key}=${encodeURIComponent(curr.value.replaceAll('&', '%26'))}`);
205
+ }
206
+ return prev;
207
+ }, []).join('&');
208
+ return queryStringDecode(urlEncoded);
209
+ }
210
+ multipartBodyFiles(bodyRaw, boundary) {
211
+ let files = {};
212
+ const pairsRaw = bodyRaw.split(boundary);
213
+ pairsRaw.map((pair) => {
214
+ const parts = /Content-Disposition: form-data; name="(.+?)"; filename="(.+?)"\r\nContent-Type: (.*)\r\n\r\n([\s\S]+)$/m.exec(pair);
215
+ if (parts) {
216
+ const file = {
217
+ data: Buffer.from(parts[4].substring(0, parts[4].length - 2).trim(), 'binary'),
218
+ fileName: parts[2],
219
+ type: parts[3]
220
+ };
221
+ files = mergeDeep(files, queryStringDecodedSetValue(parts[1], file));
222
+ }
223
+ return null;
224
+ });
225
+ return files;
226
+ }
227
+ extractURIArguments(uri, match) {
228
+ if (match instanceof RegExp) {
229
+ const matches = match.exec(uri);
230
+ if (matches) {
231
+ return {
232
+ matches
233
+ };
234
+ }
235
+ else {
236
+ return {};
237
+ }
238
+ }
239
+ const uriArgs = {};
240
+ const segments = uri.split('/');
241
+ match.forEach((segmentPattern, i) => {
242
+ if (segmentPattern.name) {
243
+ uriArgs[segmentPattern.name] = segmentPattern.type === 'number' ? parseInt(segments[i]) : segments[i];
244
+ }
245
+ });
246
+ return uriArgs;
247
+ }
248
+ async handle() {
249
+ if (this.handler !== null) {
250
+ if (!this.handler.staticAsset) {
251
+ const results = await this.app.emit('beforeRequestHandler', this);
252
+ if (results.includes(false)) {
253
+ this.response.end();
254
+ return;
255
+ }
256
+ const URIArgs = this.extractURIArguments(this.uri, this.handler.match);
257
+ this.args = URIArgs;
258
+ }
259
+ try {
260
+ const response = await this.handler.callback.apply(this.handler.scope, [this]);
261
+ if (!this.response.headersSent) {
262
+ await this.respondWith(response);
263
+ }
264
+ }
265
+ catch (e) {
266
+ console.log('Error executing request handler ', e, this.handler.callback.toString());
267
+ }
268
+ if (!this.handler.staticAsset) {
269
+ await this.app.emit('afterRequestHandler', this);
270
+ }
271
+ }
272
+ else {
273
+ let staticAsset = false;
274
+ if (this.app.config.url.isAsset(this.request.url || '')) {
275
+ const basePath = this.request.url?.startsWith('/assets/ts/') ? './' : '../';
276
+ const assetPath = path.resolve(basePath + this.request.url);
277
+ if (existsSync(assetPath)) {
278
+ await this.app.emit('beforeAssetAccess', this);
279
+ const extension = (this.request.url || '').split('.').pop();
280
+ let contentType = 'application/javascript';
281
+ if (extension) {
282
+ const typeByExtension = this.app.contentType(extension);
283
+ if (typeByExtension) {
284
+ contentType = typeByExtension;
285
+ }
286
+ }
287
+ this.sendResponse(readFileSync(assetPath), contentType);
288
+ staticAsset = true;
289
+ await this.app.emit('afterAssetAccess', this);
290
+ }
291
+ }
292
+ if (!staticAsset) {
293
+ await this.show404();
294
+ }
295
+ }
296
+ if (!this.streamingData) {
297
+ this.response.end();
298
+ }
299
+ }
300
+ isAjax() {
301
+ return this.request.headers['x-requested-with'] == 'xmlhttprequest';
302
+ }
303
+ error(e) {
304
+ const message = typeof e === 'string' ? e : e.message;
305
+ console.error(`Error in request to ${this.uri}: ${message}`);
306
+ }
307
+ }
@@ -2,9 +2,9 @@ import { ClientComponent } from "../client/ClientComponent.js";
2
2
  import { Net } from "../client/Net.js";
3
3
  import { Application } from "../server/Application.js";
4
4
  import { Component } from "../server/Component.js";
5
+ import { RequestContext } from "../server/RequestContext.js";
5
6
  import { EventEmitterCallback } from "./eventEmitter.types.js";
6
7
  import { KeysOfUnion, LooseObject } from './general.types.js';
7
- import { RequestContext } from "./request.types.js";
8
8
  export type ComponentEntry = {
9
9
  name: string;
10
10
  path: {
@@ -26,15 +26,14 @@ export type ComponentEntry = {
26
26
  attributes?: Record<string, string>;
27
27
  initializer?: InitializerFunction;
28
28
  };
29
- export interface ComponentScaffold<T extends LooseObject = LooseObject, K extends KeysOfUnion<T> = KeysOfUnion<T>> {
29
+ export interface ComponentScaffold<I extends LooseObject = LooseObject, O extends LooseObject = LooseObject, K extends KeysOfUnion<O> = KeysOfUnion<O>> {
30
30
  tagName?: string;
31
31
  exportData?: boolean;
32
32
  exportFields?: ReadonlyArray<K>;
33
33
  static?: boolean;
34
- deferred?: (data: Record<string, any>, ctx: RequestContext | undefined, app: Application) => boolean;
34
+ deferred?: (data: Record<string, any>, ctx: RequestContext, app: Application) => boolean;
35
35
  attributes?: Record<string, string>;
36
- getData: (this: ComponentScaffold, data: LooseObject, ctx: undefined | RequestContext, app: Application, component: Component) => Promise<T | void>;
37
- [key: string]: any;
36
+ getData: (this: ComponentScaffold<I, O>, data: I, ctx: RequestContext, app: Application, component: Component) => Promise<O | void>;
38
37
  }
39
38
  export type ComponentEvents = {
40
39
  componentCreated: Component;
@@ -47,13 +46,13 @@ export type ClientComponentTransition = {
47
46
  };
48
47
  export type ClientComponentTransitionEvent = 'show' | 'hide';
49
48
  export type ClientComponentTransitions = Record<ClientComponentTransitionEvent, ClientComponentTransition>;
50
- export type ClientComponentBoundEvent<T extends LooseObject | undefined, E extends HTMLElement | Window | ClientComponent> = {
51
- element: E;
49
+ export type ClientComponentBoundEvent<Data extends LooseObject | undefined, Element extends HTMLElement | Window | ClientComponent, Evt extends Event = Event> = {
50
+ element: Element;
52
51
  event: keyof HTMLElementEventMap;
53
- callback: E extends ClientComponent ? EventEmitterCallback<T> : (e: Event) => void;
54
- callbackOriginal: E extends ClientComponent ? EventEmitterCallback<T> : ClientComponentEventCallback<T>;
52
+ callback: Element extends ClientComponent ? EventEmitterCallback<Data> : (e: Event) => void;
53
+ callbackOriginal: Element extends ClientComponent ? EventEmitterCallback<Data> : ClientComponentEventCallback<Data, Evt>;
55
54
  };
56
- export type ClientComponentEventCallback<T> = (e: Event, data: T, element: HTMLElement | Window) => void;
55
+ export type ClientComponentEventCallback<T, E extends Event = Event> = (e: E, data: T, element: HTMLElement | Window) => void;
57
56
  export type InitializerFunction = (this: ClientComponent, ctx: InitializerFunctionContext) => Promise<void>;
58
57
  export type Initializers = {
59
58
  [key: string]: InitializerFunction;
@@ -1,6 +1,6 @@
1
- import { IncomingMessage, ServerResponse } from "http";
2
1
  import { LooseObject } from './general.types.js';
3
2
  import { symbolArrays } from "../Symbols.js";
3
+ import { RequestContext } from '../server/RequestContext.js';
4
4
  export type RequestMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
5
5
  export type RequestCallback<R extends any, Body extends LooseObject | undefined> = (ctx: RequestContext<Body>) => Promise<R>;
6
6
  export type RequestHandler = {
@@ -10,24 +10,6 @@ export type RequestHandler = {
10
10
  scope: any;
11
11
  staticAsset: boolean;
12
12
  };
13
- export type RequestContext<Body extends LooseObject | undefined = LooseObject> = {
14
- request: IncomingMessage;
15
- response: ServerResponse;
16
- args: URIArguments;
17
- handler: null | RequestHandler;
18
- cookies: Record<string, string>;
19
- body: Body;
20
- bodyRaw?: Buffer;
21
- files?: Record<string, RequestBodyRecordValue>;
22
- data: RequestContextData;
23
- sessionId?: string;
24
- isAjax: boolean;
25
- getArgs: PostedDataDecoded;
26
- timeStart: number;
27
- respondWith: (data: any) => Promise<void>;
28
- redirect: (to: string, statusCode?: number) => void;
29
- show404: () => Promise<void>;
30
- };
31
13
  export type PostedDataDecoded = Record<string, string | boolean | Array<string | boolean | PostedDataDecoded> | Record<string, string | boolean | Array<string | boolean | PostedDataDecoded>> | Record<string, string | boolean | Array<string | boolean>>>;
32
14
  export type RequestBodyRecordValue = string | Array<RequestBodyRecordValue> | {
33
15
  [key: string]: RequestBodyRecordValue;
package/package.json CHANGED
@@ -19,7 +19,7 @@
19
19
  "license": "MIT",
20
20
  "type": "module",
21
21
  "main": "build/index",
22
- "version": "1.3.4",
22
+ "version": "1.5.0",
23
23
  "scripts": {
24
24
  "develop": "tsc --watch",
25
25
  "startDev": "cd build && nodemon --watch '../app/**/*' --watch '../build/**/*' -e js,html,hbs,css index.js",
@@ -49,6 +49,7 @@
49
49
  ],
50
50
  "exports": {
51
51
  "./Types": "./build/system/Types.js",
52
+ "./RequestContext": "./build/system/server/RequestContext.js",
52
53
  "./Symbols": "./build/system/Symbols.js",
53
54
  "./Util": "./build/system/Util.js",
54
55
  "./Application": "./build/system/server/Application.js",