structured-fw 1.1.5 → 1.2.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
@@ -51,7 +51,7 @@ export default function(app: Application) {
51
51
 
52
52
  ### Compile
53
53
  `tsc`\
54
- This will create a directory `build` (or whatever you have in tsconfig.json as compilerOptions.outputDir)
54
+ This will create a directory `build` (which can be changed in tsconfig.json, compilerOptions.outputDir)
55
55
 
56
56
  ### Run
57
57
  ```
@@ -115,11 +115,11 @@ new Application(config);
115
115
  ```
116
116
 
117
117
  ### Properties
118
- - `cookies` - Instance of Cookies, allows you to set a cookie
119
- - [`session`](#session) - Instance of Session, utilities to manage sessions and data
120
- - `request` - Instance of Request, you will use this to add routes, but usually not directly by accessing Application.request, more on that in [routes](#route) section
121
- - `handlebars` - Instance of Handlebars (wrapper around Handlebars templating engine)
122
- - `components` - Instance of Components, this is the components registry, you should never need to use this directly
118
+ - `cookies` - Instance of `Cookies`, allows you to set a cookie
119
+ - [`session`](#session) - Instance of `Session`, utilities to manage sessions and data
120
+ - `request` - Instance of `Request`, you will use this to add routes, which is usually done in routes files, more on that in [routes](#route) section
121
+ - `handlebars` - Instance of `Handlebars` (wrapper around Handlebars templating engine)
122
+ - `components` - Instance of `Components`, this is the components registry, you should never need to use this directly
123
123
 
124
124
  ### Methods
125
125
  - `init(): Promise<void>` - initializes application, you only need to run this if you set `autoInit = false` in config, otherwise this will be ran when you create the Application instance
@@ -188,7 +188,7 @@ app.exportContextFields('user');
188
188
  ### Session
189
189
  Session allows you to store temporary data for the users of your web application. You don't need to create an instance of Session, you will always use the instance `Application.session`.
190
190
 
191
- Session data is tied to a visitor via sessionId, which is always available on `RequestContext`, which means you can interact with session data from routes and server side code of your components.
191
+ Session data is tied to a visitor via sessionId, which is always available on `RequestContext`, which means you can interact with session data from routes and server side part of your components.
192
192
 
193
193
  **Configuration**\
194
194
  `StructuredConfig`.`session`:
@@ -247,8 +247,16 @@ export default function(app: Application) {
247
247
 
248
248
  Route file name has no effect on how the route (request handler) behaves, the only purpose of splitting your routes in separate files is making your code more maintainable.
249
249
 
250
+ > [!TIP]
251
+ > You can, and should, define the output/input types for your routes.
252
+ > ```
253
+ > app.request.on<OutputType, InputType>(...)
254
+ > ```
255
+ > This allows TypeScript to validate that you are returning the desired type in all return paths and makes interaction with received (POST, PUT) data easier.
256
+
250
257
  ### RequestContext
251
- All request handlers receive a `RequestContext` as the first argument.
258
+ 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
+ All request handlers receive a `RequestContext` as the first argument. Your component server side part also receives `RequestContext` as the second argument.
252
260
  ```
253
261
  type RequestContext<Body extends LooseObject | undefined = LooseObject> = {
254
262
  request: IncomingMessage,
@@ -306,12 +314,13 @@ Capture group in URL pattern is (name) in above example. It makes data available
306
314
  ```
307
315
  app.request.on('GET', '/greet/(userId:num)', async (ctx) => {
308
316
  const userId = ctx.args.userId as number;
317
+
309
318
  // fetch user from DB
310
319
  const user = await userModel.get(userId);
311
320
  return `Hello, ${user.name}!`;
312
321
  });
313
322
  ```
314
- It is safe to cast `ctx.args.userId` as `number` in above example because the route would not get executed if the second segment of the URL is not a numeric value, and in case :num modifier is used, URL-provided value is parsed to a number and you don't need to parseInt manually.
323
+ It is safe to cast `ctx.args.userId` as `number` in above example because the route would not get executed if the second segment of the URL is not a numeric value, and in case :num modifier is used, URL-provided value is parsed to a number.
315
324
 
316
325
 
317
326
  **Doing more with less code**\
@@ -346,13 +355,15 @@ In some edge cases you may need more control of when a route is executed, in whi
346
355
  > ```
347
356
 
348
357
  ## Document
349
- Document does not differ much from a component, in fact, it extends Component. It has a more user-friendly API than Component. Each Document represents a web page. It has a head and body. Structured intentionally does not differentiate between a page and a Component - page is just a component that loads many other components in a desired layout. DocumentHead (each document has one at Document.head) allows adding content to `<head>` section of the output HTML page.
358
+ Document does not differ much from a component, in fact, it extends Component. It has a more user-friendly API than Component. Each Document represents a web page. It has a head and body. Structured intentionally does not differentiate between a page and a Component - page is just a component that loads many other components in a desired layout. `DocumentHead` (each document has one at Document.head) allows adding content to `<head>` section of the output HTML page.
350
359
 
351
360
  Creating a document:
352
361
  `const doc = new Document(app, 'HelloWorld page', ctx);`
353
362
 
354
363
  Send document as a response:
355
364
  ```
365
+ import { Document } from 'structured-fw/Document';
366
+
356
367
  app.request.on('GET', '/home', async (ctx) => {
357
368
  const doc = new Document(app, 'Home', ctx);
358
369
  await doc.loadComponent('Home');
@@ -373,7 +384,7 @@ app.request.on('GET', '/home', async (ctx) => {
373
384
 
374
385
  ## Component
375
386
  A component is comprised of [1-3 files](#component-parts). It always must include one HTML file, while server side and client side files are optional.
376
- * HTML file probably requires no explanation
387
+ * HTML file requires no explanation
377
388
  * server side file, code that runs on the server and makes data available to HTML and client side code
378
389
  * client side file, code that runs on the client (in the browser)
379
390
 
@@ -404,11 +415,13 @@ It is recommended, but not necessary, that you contain each component in it's ow
404
415
  - [Component client-side code](#component-client-side-code) (_ComponentName.client.ts_)
405
416
 
406
417
  ### Component HTML
407
- Let's create a HelloWorld Component `/app/views/HelloWorld/HelloWorld.html`:\
418
+ Let's create a HelloWorld Component `/app/views/HelloWorld/HelloWorld.html` with contents:\
408
419
  `Hello, World!`
409
420
 
410
421
  Let's load this Component into a Document and send it as a response `/app/routes/HelloWorld.ts`:
411
422
  ```
423
+ import { Document } from 'structured-fw/Document';
424
+
412
425
  export default function(app: Application) {
413
426
  app.request.on('GET', '/hello/world', async (ctx) => {
414
427
  const doc = new Document(app, 'Hello, World! From a Component', ctx);
@@ -466,7 +479,7 @@ We just generated a random number, but the data could be anything and will more
466
479
  > Server side `getData` will receive the following arguments:
467
480
  > - `data: LooseObject` any data passed in (either by attributes, ClientComponent.add or ClientComponent.redraw)
468
481
  > - `ctx: RequestContext` - current `RequestContext`, you will often use this to access for example ctx.data (`RequestContextData`) or ctx.sessionId to interact with session
469
- > - `app: Application` - your Application instance. You can use it to, for example, access the session in combination with ctx.sessionId
482
+ > - `app: Application` - `Application` instance. You can use it to, for example, access the session in combination with ctx.sessionId
470
483
 
471
484
  Let's make it even more interesting by adding some client side code to it.
472
485
 
@@ -551,7 +564,7 @@ which is now available in `AnotherComponent` HTML, we assigned the received numb
551
564
  What about client side? **By default, data returned by server side code is not available in client side code** for obvious reasons, let's assume your server side code returns sensitive data such as user's password, you would not like that exposed on the client side, hence exporting data needs to be explicitly requested in the server side code. There are two ways to achieve this, setting `exportData = true` (exports all data), or `exportFields: Array<string> = [...keysToExport]` (export only given fields).
552
565
 
553
566
  > [!NOTE]
554
- > Whenever a component with server-side code is rendered, `getData` is automatically called and anything it returns is available in HTML. You can export all returned data to client-side code by setting `exportData = true` or you can export some of the fields by setting `exportFields = ["field1", "field2", ...]` as a direct property of the class. To access the exported data from client-side use `ClientComponent`.`getData(key: string)` which will be `this.getData(key:string)` within client side code.
567
+ > Whenever a component with server-side code is rendered, `getData` is automatically called and anything it returns is available in HTML. You can export all returned data to client-side code by setting `exportData = true` or you can export some of the fields by setting `exportFields = ["field1", "field2", ...] as const` as a direct property of the class. To access the exported data from client-side use `ClientComponent`.`getData(key: string)` which will be `this.getData(key:string)` within client side part.
555
568
 
556
569
  Let's create a client side code for `AnotherComponent` and export the `betterNumber` to it, create `/app/views/AnotherComponent/AnotherComponent.client.ts`:
557
570
  ```
@@ -567,7 +580,7 @@ And let's update `AnotherComponent.ts` to export `betterNumber`:
567
580
  ```
568
581
  import { ComponentScaffold } from 'structured-fw/Types';
569
582
  export default class AnotherComponent implements ComponentScaffold {
570
- exportFields = ['betterNumber'];
583
+ exportFields = ['betterNumber'] as const;
571
584
  async getData(data: { number: number }): Promise<{
572
585
  parentSuggests: number,
573
586
  betterNumber: number
@@ -580,7 +593,7 @@ export default class AnotherComponent implements ComponentScaffold {
580
593
  }
581
594
  ```
582
595
 
583
- The only change is we added `exportFields = ['betterNumber'];`, that's all there is to it, better number is now available to component's client side code, again, any type of data can be exported and type of data is preserved in the process.
596
+ The only change is we added `exportFields = ['betterNumber'] as const;`, that's all there is to it, better number is now available to component's client side part, again, any type of data can be exported and type of data is preserved in the process.
584
597
 
585
598
  **What about passing data from children to parent?**\
586
599
  This concept is wrong to start with, if we want a component to be independent, it should not assume it's parent to exist, or behave in any specific way. That being said, components can access each other, and communicate, even from child to parent (only in client side code).
@@ -628,7 +641,11 @@ export const init: InitializerFunction = async function() {
628
641
 
629
642
  That's it. If there is `AnotherComponent` found within `HelloWorld` (which there is in our case) we are subscribing to "truth" event and capturing the payload. Payload is optional, sometimes we just want to inform anyone interested that a certain event has occurred, without the need to pass any extra data with it. We used `this.find(componentName: string)`, this will recursively find the first instance of a component with `componentName`, optionally you can make it non-recursive by passing `false` as the second argument to `find` method in which case it will look for a direct child with given name.
630
643
 
631
- We have only scratched the surface of what client-side code of a component is capable of. Which brings us to `this`. In client-side code of a component, `this` is the instance of a `ClientComponent`.
644
+ > [!IMPORTANT]
645
+ > To bind an event listener, in example above, we used `child.on`, while this works, direct use of `child.on` is discouraged, it is highly recommended to use `this.bind<PayloadType>(child, eventName, callback)` _(possible since v1.1.5)_. Using `.bind` makes sure event listener is re-bound on component redraw, using `.on` will bind the same event listener every time the listener component is redrawn.
646
+
647
+
648
+ We have only scratched the surface of what client-side part of a component is capable of. Which brings us to `this`. In client-side code of a component, `this` is the instance of a `ClientComponent`.
632
649
 
633
650
  I won't list all of it's properties here, but a few notable mentions are:
634
651
 
@@ -647,7 +664,11 @@ Methods:
647
664
  - `find(componentName: string, recursive: boolean = true): ClientComponent | null` - find a child component
648
665
  - `findParent(componentName: string): ClientComponent | null` - find the first parent with given name
649
666
  - `query(componentName: string, recursive: boolean = true): Array<ClientComponent>` - return all components with given name found within this component, if `recursive = false`, only direct children are considered
650
- - `bind<T extends LooseObject | undefined = undefined>(element: HTMLElement | Window | Array<HTMLElement | Window>, eventName: string | Array<string>, callback: (e: Event, data: T) => void): void` - adds event listener(s) to given element(s). This is preferred over addEventListener because when the component is redrawn/removed, the event listeners added using bind method are automatically restored/removed. Callback receives event as the first argument. Any "data-" prefixed attributes found on `element` are parsed into an object and provided as second argument to callback (you can specify data using attr helper if you want to pass in something other than a string). Third argument provided to callback is the `element`. The method is generic, allowing you to specify expected data type received as the second argument.
667
+ - `bind<T extends LooseObject | undefined = undefined>`(\
668
+ `element: HTMLElement | Window | Array<HTMLElement | Window> | ClientComponent`,\
669
+ `event: keyof HTMLElementEventMap | Array<keyof HTMLElementEventMap> | string`,\
670
+ `callback: ClientComponentEventCallback<T> | EventEmitterCallback<T>`\
671
+ ): `void` - adds event listener(s) to given HTML element(s) / components. This is preferred over `HTMLElement`.`.addEventListener` / `ClientComponent`.`on` because when the component is redrawn/removed, the event listeners added using bind method are automatically restored/removed. Callback receives event as the first argument. Any "data-" prefixed attributes found on `element` are parsed into an object and provided as second argument to callback (you can specify data using attr helper if you want to pass in something other than a string). Third argument provided to callback is the `element`. The method is generic, allowing you to specify expected data type received as the second argument.
651
672
  - `ref<T>(refName: string): T` - get a HTMLElement or ClientComponent that has attribute `ref="[refName]"`
652
673
  - `arrayRef<T>(refName: string): Array<T>` - get an array of HTMLElement or ClientComponent that have attribute `array:ref="[refName]"`
653
674
  - `add(appendTo: HTMLElement, componentName: string, data?: LooseObject): Promise<ClientComponent | null>` - add `componentName` component to `appendTo` element, optionally passing `data` to the component when it's being rendered. Returns a promise that resolves with added ClientComponent or null if something went wrong
@@ -730,7 +751,7 @@ You can use two modifier attributes with `data-model`:
730
751
  - `data-nullable`
731
752
 
732
753
  `data-type` - cast value to given type. Can be one of number | boolean | string, string has no effect as HTMLInput values are already a string by default.\
733
- If number: if input is empty or value casts to `NaN` then `0` (unless `data-nullable` in which case `null`), othrwise the casted number (uses parseFloat so it works with decimal numbers)\
754
+ If number: if input is empty or value casts to `NaN` then `0` (unless `data-nullable` in which case `null`), otherwise the casted number (uses parseFloat so it works with decimal numbers)\
734
755
  If boolean: `"1"` and `"true"` casted to `true`, otherwise `false`\
735
756
  If string no type casting is attempted.
736
757
 
@@ -2,9 +2,12 @@ import { EventEmitterCallback } from './types/eventEmitter.types.js';
2
2
  export declare class EventEmitter<T extends Record<string, any> = Record<string, any>> {
3
3
  protected listeners: Partial<Record<Extract<keyof T, string>, Array<EventEmitterCallback<any>>>>;
4
4
  protected destroyed: boolean;
5
+ protected ready: boolean;
6
+ private eventQueue;
5
7
  on<K extends Extract<keyof T, string>>(eventName: K, callback: EventEmitterCallback<T[K]>): void;
6
8
  emit(eventName: Extract<keyof T, string>, payload?: any): Promise<void>;
7
9
  off(eventName: keyof T, callback: EventEmitterCallback<any>): void;
10
+ emitterReady(): Promise<void>;
8
11
  unbindAllListeners(): void;
9
12
  emitterDestroy(): void;
10
13
  }
@@ -2,6 +2,8 @@ export class EventEmitter {
2
2
  constructor() {
3
3
  this.listeners = {};
4
4
  this.destroyed = false;
5
+ this.ready = false;
6
+ this.eventQueue = [];
5
7
  }
6
8
  on(eventName, callback) {
7
9
  if (this.destroyed) {
@@ -19,6 +21,13 @@ export class EventEmitter {
19
21
  if (this.destroyed) {
20
22
  return;
21
23
  }
24
+ if (!this.ready) {
25
+ this.eventQueue.push({
26
+ event: eventName,
27
+ payload
28
+ });
29
+ return;
30
+ }
22
31
  if (Array.isArray(this.listeners[eventName]) || Array.isArray(this.listeners['*'])) {
23
32
  const listeners = (this.listeners[eventName] || []).concat(this.listeners['*'] || []);
24
33
  for (let i = 0; i < listeners.length; i++) {
@@ -34,6 +43,16 @@ export class EventEmitter {
34
43
  }
35
44
  }
36
45
  }
46
+ async emitterReady() {
47
+ if (this.ready) {
48
+ return;
49
+ }
50
+ this.ready = true;
51
+ for (let i = 0; i < this.eventQueue.length; i++) {
52
+ await this.emit(this.eventQueue[i].event, this.eventQueue[i].payload);
53
+ }
54
+ this.eventQueue.length = 0;
55
+ }
37
56
  unbindAllListeners() {
38
57
  this.listeners = {};
39
58
  }
@@ -53,7 +53,7 @@ export class ClientComponent extends EventEmitter {
53
53
  this.init(false);
54
54
  }
55
55
  }
56
- async init(isRedraw, data = {}) {
56
+ async init(isRedraw, data = {}, isRedrawRoot = false) {
57
57
  const initializerExists = this.app.hasInitializer(this.name);
58
58
  this.reset();
59
59
  this.initData();
@@ -70,8 +70,6 @@ export class ClientComponent extends EventEmitter {
70
70
  this.initRefs();
71
71
  this.initModels();
72
72
  this.initConditionals();
73
- await this.runInitializer(isRedraw);
74
- this.updateConditionals(false);
75
73
  this.store.onChange('*', () => {
76
74
  this.updateConditionals(true);
77
75
  });
@@ -80,6 +78,9 @@ export class ClientComponent extends EventEmitter {
80
78
  this.redraw();
81
79
  }
82
80
  this.isReady = true;
81
+ if (this.isRoot || isRedrawRoot) {
82
+ await this.runInitializer(isRedraw);
83
+ }
83
84
  this.emit('ready');
84
85
  }
85
86
  reset() {
@@ -87,26 +88,32 @@ export class ClientComponent extends EventEmitter {
87
88
  this.isReady = false;
88
89
  this.refs = {};
89
90
  this.refsArray = {};
90
- this.conditionalClassNames = [];
91
+ this.conditionalClassNames.length = 0;
91
92
  this.conditionalCallbacks = {};
92
- this.conditionals = [];
93
+ this.conditionals.length = 0;
93
94
  this.redrawRequest = null;
94
95
  this.initializerExecuted = false;
95
- this.bound = [];
96
- this.children = [];
96
+ this.bound.length = 0;
97
+ this.children.length = 0;
97
98
  }
98
99
  async runInitializer(isRedraw = false) {
99
- if (!this.initializerExecuted && !this.destroyed) {
100
- const initializer = this.app.getInitializer(this.name);
101
- if (initializer === null) {
102
- return;
103
- }
100
+ if (this.destroyed || this.initializerExecuted) {
101
+ return;
102
+ }
103
+ const initializer = this.app.getInitializer(this.name);
104
+ if (initializer !== null) {
104
105
  await initializer.apply(this, [{
105
106
  net: this.net,
106
107
  isRedraw
107
108
  }]);
109
+ this.updateConditionals(false);
110
+ }
111
+ await this.emitterReady();
112
+ for (let i = 0; i < this.children.length; i++) {
113
+ await this.children[i].runInitializer(isRedraw);
108
114
  }
109
115
  this.initializerExecuted = true;
116
+ this.emit('initializerExecuted');
110
117
  }
111
118
  initData() {
112
119
  objectEach(this.attributeData(this.domNode), (key, val) => {
@@ -211,7 +218,7 @@ export class ClientComponent extends EventEmitter {
211
218
  for (const componentName in componentData.initializers) {
212
219
  this.app.registerInitializer(componentName, componentData.initializers[componentName]);
213
220
  }
214
- await this.init(true, componentData.data);
221
+ await this.init(true, componentData.data, true);
215
222
  for (let i = 0; i < this.children.length; i++) {
216
223
  const childNew = this.children[i];
217
224
  const childNewId = childNew.getData('componentId');
@@ -569,7 +576,7 @@ export class ClientComponent extends EventEmitter {
569
576
  const componentNode = tmpContainer.firstChild;
570
577
  const component = new ClientComponent(this, componentName, componentNode, this.app);
571
578
  this.children.push(component);
572
- await component.init(false, res.data);
579
+ await component.init(false, res.data, true);
573
580
  container.appendChild(componentNode);
574
581
  return component;
575
582
  }
@@ -757,11 +764,12 @@ export class ClientComponent extends EventEmitter {
757
764
  this.redrawRequest = null;
758
765
  }
759
766
  await this.emit('beforeDestroy');
760
- this.domNode.parentElement?.removeChild(this.domNode);
761
767
  const children = Array.from(this.children);
762
768
  for (let i = 0; i < children.length; i++) {
763
769
  await children[i].destroy();
764
770
  }
771
+ this.domNode.parentElement?.removeChild(this.domNode);
772
+ this.domNode.innerHTML = '';
765
773
  if (this.parent) {
766
774
  this.parent.children.splice(this.parent.children.indexOf(this), 1);
767
775
  }
@@ -12,6 +12,7 @@ export class Component extends EventEmitter {
12
12
  this.data = {};
13
13
  const isDocument = this instanceof Document;
14
14
  this.name = name;
15
+ this.emitterReady();
15
16
  if (name === 'root') {
16
17
  this.dom = new DOMFragment();
17
18
  this.path.push('');
@@ -63,6 +63,9 @@ export class Components {
63
63
  entry.attributes = entry.serverPart?.attributes;
64
64
  entry.static = typeof entry.serverPart?.static === 'boolean' ? entry.serverPart.static : false;
65
65
  }
66
+ else {
67
+ entry.exportData = true;
68
+ }
66
69
  this.components[componentName.toUpperCase()] = entry;
67
70
  this.componentNames.push(entry.name);
68
71
  }
@@ -18,7 +18,7 @@ export declare class Document extends Component<{
18
18
  componentIds: Array<string>;
19
19
  ctx: undefined | RequestContext;
20
20
  appendHTML: string;
21
- constructor(app: Application, title: string, ctx?: RequestContext);
21
+ constructor(app: Application, title: string, ctx?: RequestContext<any>);
22
22
  push(response: ServerResponse): void;
23
23
  body(): string;
24
24
  initInitializers(): Record<string, string>;
@@ -2,10 +2,11 @@ import { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { LooseObject } from '../types/general.types.js';
3
3
  import { RequestMethod, PostedDataDecoded, RequestBodyFile, RequestCallback } from "../types/request.types.js";
4
4
  import { Application } from "./Application.js";
5
+ import { Document } from "./Document.js";
5
6
  export declare class Request {
6
7
  private app;
7
8
  constructor(app: Application);
8
- pageNotFoundCallback: RequestCallback<void, PostedDataDecoded | undefined>;
9
+ pageNotFoundCallback: RequestCallback<void | Document, LooseObject>;
9
10
  private readonly handlers;
10
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;
11
12
  private getHandler;
@@ -130,8 +130,11 @@ export class Request {
130
130
  this.redirect(response, to, statusCode);
131
131
  },
132
132
  show404: async () => {
133
- await this.pageNotFoundCallback.apply(this.app, [context]);
134
133
  this.app.emit('pageNotFound', context);
134
+ const res = await this.pageNotFoundCallback.apply(this.app, [context]);
135
+ if (res instanceof Document) {
136
+ context.respondWith(res);
137
+ }
135
138
  }
136
139
  };
137
140
  if (handler !== null) {
package/package.json CHANGED
@@ -19,7 +19,7 @@
19
19
  "license": "MIT",
20
20
  "type": "module",
21
21
  "main": "build/index",
22
- "version": "1.1.5",
22
+ "version": "1.2.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",