structured-fw 1.1.0 → 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
- emit(eventName: Extract<keyof T, string>, payload?: any): void;
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) {
@@ -10,31 +12,47 @@ export class EventEmitter {
10
12
  if (!Array.isArray(this.listeners[eventName])) {
11
13
  this.listeners[eventName] = [];
12
14
  }
15
+ if (this.listeners[eventName].indexOf(callback) > -1) {
16
+ return;
17
+ }
13
18
  this.listeners[eventName].push(callback);
14
19
  }
15
- emit(eventName, payload) {
20
+ async emit(eventName, payload) {
16
21
  if (this.destroyed) {
17
22
  return;
18
23
  }
19
- if (Array.isArray(this.listeners[eventName]) || Array.isArray(this.listeners['*'])) {
20
- (this.listeners[eventName] || []).concat(this.listeners['*'] || []).forEach((callback) => {
21
- callback(payload, eventName);
24
+ if (!this.ready) {
25
+ this.eventQueue.push({
26
+ event: eventName,
27
+ payload
22
28
  });
29
+ return;
30
+ }
31
+ if (Array.isArray(this.listeners[eventName]) || Array.isArray(this.listeners['*'])) {
32
+ const listeners = (this.listeners[eventName] || []).concat(this.listeners['*'] || []);
33
+ for (let i = 0; i < listeners.length; i++) {
34
+ await listeners[i](payload, eventName);
35
+ }
23
36
  }
24
37
  }
25
38
  off(eventName, callback) {
26
39
  if (Array.isArray(this.listeners[eventName])) {
27
- while (true) {
28
- const index = this.listeners[eventName].indexOf(callback);
29
- if (index > -1) {
30
- this.listeners[eventName].splice(index, 1);
31
- }
32
- else {
33
- break;
34
- }
40
+ const index = this.listeners[eventName].indexOf(callback);
41
+ if (index > -1) {
42
+ this.listeners[eventName].splice(index, 1);
35
43
  }
36
44
  }
37
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
+ }
38
56
  unbindAllListeners() {
39
57
  this.listeners = {};
40
58
  }
@@ -1,6 +1 @@
1
- import { App } from './App.js';
2
- import { Net } from './Net.js';
3
- export declare class Client {
4
- Components: App;
5
- Net: Net;
6
- }
1
+ export {};
@@ -1,9 +1,2 @@
1
- import { App } from './App.js';
2
- import { Net } from './Net.js';
3
- export class Client {
4
- constructor() {
5
- this.Components = new App();
6
- this.Net = new Net();
7
- }
8
- }
9
- new App();
1
+ import { ClientApplication } from './ClientApplication.js';
2
+ new ClientApplication();
@@ -0,0 +1,13 @@
1
+ import { DataStore } from './DataStore.js';
2
+ import { ClientComponent } from './ClientComponent.js';
3
+ import { InitializerFunction } from '../types/component.types.js';
4
+ export declare class ClientApplication {
5
+ root: ClientComponent;
6
+ store: DataStore;
7
+ initializers: Record<string, InitializerFunction>;
8
+ constructor();
9
+ getInitializer(componentName: string): InitializerFunction | null;
10
+ private loadInitializers;
11
+ hasInitializer(componentName: string): boolean;
12
+ registerInitializer(componentName: string, initializerFunctionString: string): void;
13
+ }
@@ -0,0 +1,43 @@
1
+ import { DataStore } from './DataStore.js';
2
+ import { ClientComponent } from './ClientComponent.js';
3
+ export class ClientApplication {
4
+ constructor() {
5
+ this.store = new DataStore();
6
+ this.initializers = {};
7
+ this.loadInitializers();
8
+ this.root = new ClientComponent(null, 'root', document.body, this);
9
+ }
10
+ getInitializer(componentName) {
11
+ if (!this.hasInitializer(componentName)) {
12
+ return null;
13
+ }
14
+ return this.initializers[componentName];
15
+ }
16
+ loadInitializers() {
17
+ if (!!window.initializers) {
18
+ for (const componentName in window.initializers) {
19
+ this.registerInitializer(componentName, window.initializers[componentName]);
20
+ }
21
+ }
22
+ }
23
+ hasInitializer(componentName) {
24
+ return componentName in this.initializers;
25
+ }
26
+ registerInitializer(componentName, initializerFunctionString) {
27
+ if (this.hasInitializer(componentName)) {
28
+ return;
29
+ }
30
+ const AsyncFunction = async function () { }.constructor;
31
+ const initializerFunction = new AsyncFunction(`
32
+ if (!this.destroyed) {
33
+ const init = ${initializerFunctionString};
34
+ try {
35
+ await init.apply(this, [...arguments]);
36
+ } catch(e) {
37
+ console.error('Error in component ${componentName}: ' + e.message, this);
38
+ }
39
+ }
40
+ `);
41
+ this.initializers[componentName] = initializerFunction;
42
+ }
43
+ }
@@ -1,9 +1,10 @@
1
1
  import { LooseObject } from '../types/general.types.js';
2
2
  import { ClientComponentEventCallback } from '../types/component.types.js';
3
3
  import { DataStoreView } from './DataStoreView.js';
4
- import { DataStore } from './DataStore.js';
5
4
  import { Net } from './Net.js';
6
5
  import { EventEmitter } from '../EventEmitter.js';
6
+ import { ClientApplication } from './ClientApplication.js';
7
+ import { EventEmitterCallback } from '../types/eventEmitter.types.js';
7
8
  export declare class ClientComponent extends EventEmitter {
8
9
  readonly name: string;
9
10
  children: Array<ClientComponent>;
@@ -12,9 +13,10 @@ export declare class ClientComponent extends EventEmitter {
12
13
  readonly isRoot: boolean;
13
14
  readonly root: ClientComponent;
14
15
  store: DataStoreView;
15
- private storeGlobal;
16
+ private app;
16
17
  readonly net: Net;
17
18
  private initializerExecuted;
19
+ readonly fn: Record<string, (...args: Array<any>) => any | undefined>;
18
20
  destroyed: boolean;
19
21
  private redrawRequest;
20
22
  private bound;
@@ -25,7 +27,7 @@ export declare class ClientComponent extends EventEmitter {
25
27
  private refsArray;
26
28
  isReady: boolean;
27
29
  private data;
28
- constructor(parent: ClientComponent | null, name: string, domNode: HTMLElement, store: DataStore, runInitializer?: boolean);
30
+ constructor(parent: ClientComponent | null, name: string, domNode: HTMLElement, app: ClientApplication);
29
31
  private init;
30
32
  private reset;
31
33
  private runInitializer;
@@ -46,7 +48,6 @@ export declare class ClientComponent extends EventEmitter {
46
48
  private execCondition;
47
49
  conditionalCallback(name: string, callback: (args?: any) => boolean, updateConditionals?: boolean): void;
48
50
  private updateConditionals;
49
- remove(): Promise<void>;
50
51
  parentFind(parentName: string): ClientComponent | null;
51
52
  find(componentName: string, recursive?: boolean): null | ClientComponent;
52
53
  query(componentName: string, recursive?: boolean, results?: Array<ClientComponent>): Array<ClientComponent>;
@@ -57,8 +58,11 @@ export declare class ClientComponent extends EventEmitter {
57
58
  private transitionAttributes;
58
59
  private transitionAxis;
59
60
  private destroy;
60
- bind<T extends LooseObject | undefined = undefined>(element: HTMLElement | Window | Array<HTMLElement | Window>, event: keyof HTMLElementEventMap | Array<keyof HTMLElementEventMap>, callback: ClientComponentEventCallback<T>): void;
61
- unbind<T extends LooseObject | undefined = undefined>(element: HTMLElement, event: keyof HTMLElementEventMap | Array<keyof HTMLElementEventMap>, callback: ClientComponentEventCallback<T>): void;
61
+ remove(): Promise<void>;
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;
64
+ unbind<T extends LooseObject | undefined = undefined>(element: HTMLElement | Window | ClientComponent, event: keyof HTMLElementEventMap | Array<keyof HTMLElementEventMap>, callback: ClientComponentEventCallback<T> | EventEmitterCallback<T>): void;
65
+ private unbindOwn;
62
66
  private unbindAll;
63
67
  log(msg: any): void;
64
68
  warn(msg: any): void;
@@ -4,7 +4,7 @@ import { Net } from './Net.js';
4
4
  import { NetRequest } from './NetRequest.js';
5
5
  import { EventEmitter } from '../EventEmitter.js';
6
6
  export class ClientComponent extends EventEmitter {
7
- constructor(parent, name, domNode, store, runInitializer = true) {
7
+ constructor(parent, name, domNode, app) {
8
8
  super();
9
9
  this.children = [];
10
10
  this.net = new Net();
@@ -31,15 +31,35 @@ export class ClientComponent extends EventEmitter {
31
31
  this.root = parent.root;
32
32
  this.parent = parent;
33
33
  }
34
- this.storeGlobal = store;
35
- this.store = new DataStoreView(this.storeGlobal, this);
34
+ this.app = app;
35
+ this.store = new DataStoreView(this.app.store, this);
36
+ const self = this;
37
+ this.fn = new Proxy(this.store, {
38
+ set(target, key, val) {
39
+ const fnKey = `fn_${key}`;
40
+ if (target.has(fnKey)) {
41
+ return true;
42
+ }
43
+ target.set(fnKey, val);
44
+ return true;
45
+ },
46
+ get(target, key) {
47
+ return target.get(`fn_${key}`) || (() => {
48
+ self.warn(`Function ${key} not defined`);
49
+ });
50
+ },
51
+ });
36
52
  if (this.isRoot) {
37
53
  this.init(false);
38
54
  }
39
55
  }
40
- async init(isRedraw) {
41
- const initializerExists = window.initializers !== undefined && this.name in window.initializers;
56
+ async init(isRedraw, data = {}, isRedrawRoot = false) {
57
+ const initializerExists = this.app.hasInitializer(this.name);
42
58
  this.reset();
59
+ this.initData();
60
+ objectEach(data, (key, val) => {
61
+ this.setData(key, val, false);
62
+ });
43
63
  this.initChildren();
44
64
  await Promise.all(this.children.map(async (child) => {
45
65
  await child.init(isRedraw);
@@ -48,12 +68,8 @@ export class ClientComponent extends EventEmitter {
48
68
  this.store.import(undefined, false, false);
49
69
  }
50
70
  this.initRefs();
51
- this.initData();
52
71
  this.initModels();
53
- this.promoteRefs();
54
72
  this.initConditionals();
55
- await this.runInitializer(isRedraw);
56
- this.updateConditionals(false);
57
73
  this.store.onChange('*', () => {
58
74
  this.updateConditionals(true);
59
75
  });
@@ -62,6 +78,9 @@ export class ClientComponent extends EventEmitter {
62
78
  this.redraw();
63
79
  }
64
80
  this.isReady = true;
81
+ if (this.isRoot || isRedrawRoot) {
82
+ await this.runInitializer(isRedraw);
83
+ }
65
84
  this.emit('ready');
66
85
  }
67
86
  reset() {
@@ -69,45 +88,32 @@ export class ClientComponent extends EventEmitter {
69
88
  this.isReady = false;
70
89
  this.refs = {};
71
90
  this.refsArray = {};
72
- this.conditionalClassNames = [];
91
+ this.conditionalClassNames.length = 0;
73
92
  this.conditionalCallbacks = {};
74
- this.conditionals = [];
93
+ this.conditionals.length = 0;
75
94
  this.redrawRequest = null;
76
95
  this.initializerExecuted = false;
77
- this.bound = [];
78
- this.children = [];
96
+ this.bound.length = 0;
97
+ this.children.length = 0;
79
98
  }
80
99
  async runInitializer(isRedraw = false) {
81
- const initializer = window.initializers[this.name];
82
- if (!initializer) {
100
+ if (this.destroyed || this.initializerExecuted) {
83
101
  return;
84
102
  }
85
- if (!this.initializerExecuted && !this.destroyed) {
86
- let initializerFunction = null;
87
- if (typeof initializer === 'string') {
88
- const AsyncFunction = async function () { }.constructor;
89
- initializerFunction = new AsyncFunction(`
90
- const init = ${initializer};
91
- if (!this.destroyed) {
92
- try {
93
- await init.apply(this, [...arguments]);
94
- } catch(e) {
95
- console.error('Error in component ${this.name}: ' + e.message, this);
96
- }
97
- }
98
- `);
99
- }
100
- else {
101
- initializerFunction = initializer;
102
- }
103
- if (initializerFunction) {
104
- await initializerFunction.apply(this, [{
105
- net: this.net,
106
- isRedraw
107
- }]);
108
- }
103
+ const initializer = this.app.getInitializer(this.name);
104
+ if (initializer !== null) {
105
+ await initializer.apply(this, [{
106
+ net: this.net,
107
+ isRedraw
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);
109
114
  }
110
115
  this.initializerExecuted = true;
116
+ this.emit('initializerExecuted');
111
117
  }
112
118
  initData() {
113
119
  objectEach(this.attributeData(this.domNode), (key, val) => {
@@ -159,7 +165,7 @@ export class ClientComponent extends EventEmitter {
159
165
  const childNode = scope.childNodes[i];
160
166
  if (childNode.nodeType == 1) {
161
167
  if (childNode.hasAttribute(`data-${window.structuredClientConfig.componentNameAttribute}`)) {
162
- const component = new ClientComponent(this, childNode.getAttribute(`data-${window.structuredClientConfig.componentNameAttribute}`) || '', childNode, this.storeGlobal, false);
168
+ const component = new ClientComponent(this, childNode.getAttribute(`data-${window.structuredClientConfig.componentNameAttribute}`) || '', childNode, this.app);
163
169
  this.children.push(component);
164
170
  if (typeof callback === 'function') {
165
171
  callback(component);
@@ -179,11 +185,12 @@ export class ClientComponent extends EventEmitter {
179
185
  if (this.destroyed) {
180
186
  return;
181
187
  }
182
- this.emit('beforeRedraw');
188
+ await this.emit('beforeRedraw');
183
189
  if (this.redrawRequest !== null) {
184
190
  this.redrawRequest.abort();
185
191
  this.redrawRequest = null;
186
192
  }
193
+ this.unbindOwn();
187
194
  const redrawRequest = new NetRequest('POST', window.structuredClientConfig.componentRender, {
188
195
  'content-type': 'application/json'
189
196
  });
@@ -204,19 +211,14 @@ export class ClientComponent extends EventEmitter {
204
211
  for (let i = 0; i < childrenOld.length; i++) {
205
212
  const child = childrenOld[i];
206
213
  childStoreChangeCallbacks[child.getData('componentId')] = child.store.onChangeCallbacks();
207
- await child.remove();
214
+ await child.destroy();
208
215
  }
209
216
  const componentData = JSON.parse(componentDataJSON);
210
217
  this.domNode.innerHTML = componentData.html;
211
- objectEach(componentData.data, (key, val) => {
212
- this.setData(key, val, false);
213
- });
214
- for (const key in componentData.initializers) {
215
- if (!window.initializers[key]) {
216
- window.initializers[key] = componentData.initializers[key];
217
- }
218
+ for (const componentName in componentData.initializers) {
219
+ this.app.registerInitializer(componentName, componentData.initializers[componentName]);
218
220
  }
219
- await this.init(true);
221
+ await this.init(true, componentData.data, true);
220
222
  for (let i = 0; i < this.children.length; i++) {
221
223
  const childNew = this.children[i];
222
224
  const childNewId = childNew.getData('componentId');
@@ -229,7 +231,7 @@ export class ClientComponent extends EventEmitter {
229
231
  });
230
232
  }
231
233
  }
232
- this.emit('afterRedraw');
234
+ await this.emit('afterRedraw');
233
235
  }
234
236
  initConditionals(node) {
235
237
  const isSelf = node === undefined;
@@ -274,6 +276,7 @@ export class ClientComponent extends EventEmitter {
274
276
  this.initRefs(child);
275
277
  }
276
278
  });
279
+ this.promoteRefs();
277
280
  }
278
281
  initModels(node, modelNodes = []) {
279
282
  const isSelf = node === undefined;
@@ -503,19 +506,6 @@ export class ClientComponent extends EventEmitter {
503
506
  }
504
507
  });
505
508
  }
506
- async remove() {
507
- if (!this.isRoot) {
508
- const children = Array.from(this.children);
509
- for (let i = 0; i < children.length; i++) {
510
- await children[i].remove();
511
- }
512
- if (this.parent) {
513
- this.parent.children.splice(this.parent.children.indexOf(this), 1);
514
- }
515
- this.domNode.parentElement?.removeChild(this.domNode);
516
- await this.destroy();
517
- }
518
- }
519
509
  parentFind(parentName) {
520
510
  let parent = this.parent;
521
511
  while (true) {
@@ -578,17 +568,15 @@ export class ClientComponent extends EventEmitter {
578
568
  unwrap: false
579
569
  }));
580
570
  const res = JSON.parse(componentDataJSON);
581
- for (let key in res.initializers) {
582
- if (!window.initializers[key]) {
583
- window.initializers[key] = res.initializers[key];
584
- }
571
+ for (let componentName in res.initializers) {
572
+ this.app.registerInitializer(componentName, res.initializers[componentName]);
585
573
  }
586
574
  const tmpContainer = document.createElement('div');
587
575
  tmpContainer.innerHTML = res.html;
588
576
  const componentNode = tmpContainer.firstChild;
589
- const component = new ClientComponent(this, componentName, componentNode, this.storeGlobal);
577
+ const component = new ClientComponent(this, componentName, componentNode, this.app);
590
578
  this.children.push(component);
591
- await component.init(false);
579
+ await component.init(false, res.data, true);
592
580
  container.appendChild(componentNode);
593
581
  return component;
594
582
  }
@@ -775,19 +763,26 @@ export class ClientComponent extends EventEmitter {
775
763
  this.redrawRequest.abort();
776
764
  this.redrawRequest = null;
777
765
  }
778
- this.emit('beforeDestroy');
766
+ await this.emit('beforeDestroy');
767
+ const children = Array.from(this.children);
768
+ for (let i = 0; i < children.length; i++) {
769
+ await children[i].destroy();
770
+ }
771
+ this.domNode.parentElement?.removeChild(this.domNode);
772
+ this.domNode.innerHTML = '';
773
+ if (this.parent) {
774
+ this.parent.children.splice(this.parent.children.indexOf(this), 1);
775
+ }
779
776
  this.store.destroy();
780
777
  this.unbindAll();
781
- this.conditionals = [];
782
- this.conditionalClassNames = [];
783
- this.conditionalCallbacks = {};
784
- this.refs = {};
785
- this.refsArray = {};
786
- this.data = {};
778
+ this.reset();
787
779
  this.destroyed = true;
788
- this.emit('afterDestroy');
780
+ await this.emit('afterDestroy');
789
781
  this.emitterDestroy();
790
782
  }
783
+ async remove() {
784
+ await this.destroy();
785
+ }
791
786
  bind(element, event, callback) {
792
787
  if (Array.isArray(element)) {
793
788
  element.forEach((el) => {
@@ -801,19 +796,33 @@ export class ClientComponent extends EventEmitter {
801
796
  });
802
797
  return;
803
798
  }
804
- const isWindow = element instanceof Window;
805
- if (element instanceof HTMLElement || isWindow) {
799
+ if (element instanceof HTMLElement || element instanceof Window) {
800
+ const cb = callback;
806
801
  const callbackWrapper = (e) => {
807
- callback.apply(this, [e, isWindow ? undefined : this.attributeData(element), element]);
802
+ cb.apply(this, [
803
+ e,
804
+ element instanceof Window ? undefined : this.attributeData(element),
805
+ element
806
+ ]);
808
807
  };
809
808
  this.bound.push({
810
809
  element,
811
- event,
810
+ event: event,
812
811
  callback: callbackWrapper,
813
812
  callbackOriginal: callback
814
813
  });
815
814
  element.addEventListener(event, callbackWrapper);
816
815
  }
816
+ else if (element instanceof ClientComponent) {
817
+ const cb = callback;
818
+ this.bound.push({
819
+ element,
820
+ event: event,
821
+ callback: cb,
822
+ callbackOriginal: cb
823
+ });
824
+ element.on(event, cb);
825
+ }
817
826
  }
818
827
  unbind(element, event, callback) {
819
828
  if (Array.isArray(event)) {
@@ -827,13 +836,25 @@ export class ClientComponent extends EventEmitter {
827
836
  });
828
837
  if (boundIndex > -1) {
829
838
  const bound = this.bound[boundIndex];
830
- bound.element.removeEventListener(bound.event, bound.callback);
831
- this.bound.splice(boundIndex, 1);
839
+ if (bound.element instanceof ClientComponent) {
840
+ bound.element.off(bound.event, bound.callback);
841
+ }
842
+ else {
843
+ bound.element.removeEventListener(bound.event, bound.callback);
844
+ this.bound.splice(boundIndex, 1);
845
+ }
832
846
  }
833
847
  }
848
+ unbindOwn() {
849
+ this.bound.forEach((bound) => {
850
+ if (bound.element === this) {
851
+ this.unbind(bound.element, bound.event, bound.callback);
852
+ }
853
+ });
854
+ }
834
855
  unbindAll() {
835
856
  this.bound.forEach((bound) => {
836
- bound.element.removeEventListener(bound.event, bound.callback);
857
+ this.unbind(bound.element, bound.event, bound.callbackOriginal);
837
858
  });
838
859
  this.bound = [];
839
860
  }
@@ -10,6 +10,7 @@ export declare class DataStoreView {
10
10
  get<T>(key: string): T | undefined;
11
11
  toggle(key: string): void;
12
12
  keys(): Array<string>;
13
+ has(key: string): boolean;
13
14
  import(fields?: Array<string>, force?: boolean, triggerListeners?: boolean): void;
14
15
  clear(): void;
15
16
  destroy(): void;
@@ -23,7 +23,10 @@ export class DataStoreView {
23
23
  if (this.destroyed) {
24
24
  return [];
25
25
  }
26
- return Object.keys(this.store.get(this.componentId()));
26
+ return Object.keys(this.store.get(this.componentId()) || {});
27
+ }
28
+ has(key) {
29
+ return this.keys().includes(key);
27
30
  }
28
31
  import(fields, force = false, triggerListeners = true) {
29
32
  const fieldsImported = Array.isArray(fields) ? fields : Object.keys(this.component.getData());
@@ -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) {
@@ -2,8 +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 { LooseObject } from './general.types.js';
6
- import { RequestContext, RequestBodyArguments } from "./request.types.js";
5
+ import { EventEmitterCallback } from "./eventEmitter.types.js";
6
+ import { KeysOfUnion, LooseObject } from './general.types.js';
7
+ import { RequestContext } from "./request.types.js";
7
8
  export type ComponentEntry = {
8
9
  name: string;
9
10
  path: {
@@ -21,18 +22,18 @@ export type ComponentEntry = {
21
22
  static: boolean;
22
23
  renderTagName?: string;
23
24
  exportData: boolean;
24
- exportFields?: Array<string>;
25
+ exportFields?: ReadonlyArray<string>;
25
26
  attributes?: Record<string, string>;
26
27
  initializer?: InitializerFunction;
27
28
  };
28
- export interface ComponentScaffold {
29
+ export interface ComponentScaffold<T extends LooseObject = LooseObject, K extends KeysOfUnion<T> = KeysOfUnion<T>> {
29
30
  tagName?: string;
30
31
  exportData?: boolean;
31
- exportFields?: Array<string>;
32
+ exportFields?: ReadonlyArray<K>;
32
33
  static?: boolean;
33
34
  deferred?: (data: Record<string, any>, ctx: RequestContext | undefined, app: Application) => boolean;
34
35
  attributes?: Record<string, string>;
35
- getData: (this: ComponentScaffold, data: RequestBodyArguments | LooseObject, ctx: undefined | RequestContext, app: Application, component: Component) => Promise<LooseObject | null>;
36
+ getData: (this: ComponentScaffold, data: LooseObject, ctx: undefined | RequestContext, app: Application, component: Component) => Promise<T | void>;
36
37
  [key: string]: any;
37
38
  }
38
39
  export type ClientComponentTransition = {
@@ -42,11 +43,11 @@ export type ClientComponentTransition = {
42
43
  };
43
44
  export type ClientComponentTransitionEvent = 'show' | 'hide';
44
45
  export type ClientComponentTransitions = Record<ClientComponentTransitionEvent, ClientComponentTransition>;
45
- export type ClientComponentBoundEvent<T extends LooseObject | undefined = undefined> = {
46
- element: HTMLElement | Window;
46
+ export type ClientComponentBoundEvent<T extends LooseObject | undefined, E extends HTMLElement | Window | ClientComponent> = {
47
+ element: E;
47
48
  event: keyof HTMLElementEventMap;
48
- callback: (e: Event) => void;
49
- callbackOriginal: ClientComponentEventCallback<T>;
49
+ callback: E extends ClientComponent ? EventEmitterCallback<T> : (e: Event) => void;
50
+ callbackOriginal: E extends ClientComponent ? EventEmitterCallback<T> : ClientComponentEventCallback<T>;
50
51
  };
51
52
  export type ClientComponentEventCallback<T> = (e: Event, data: T, element: HTMLElement | Window) => void;
52
53
  export type InitializerFunction = (this: ClientComponent, ctx: InitializerFunctionContext) => Promise<void>;
@@ -1 +1 @@
1
- export type EventEmitterCallback<T> = (payload: T, eventName: string) => void;
1
+ export type EventEmitterCallback<T> = (payload: T, eventName: string) => void | Promise<void>;
@@ -1 +1,2 @@
1
1
  export type LooseObject = Record<string, any>;
2
+ export type KeysOfUnion<T> = T extends T ? keyof T : never;
package/package.json CHANGED
@@ -19,13 +19,13 @@
19
19
  "license": "MIT",
20
20
  "type": "module",
21
21
  "main": "build/index",
22
- "version": "1.1.0",
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",
26
26
  "start": "cd build && node index.js",
27
27
  "compileAndPack": "tsc && npm pack",
28
- "compileAndPublish": "tsc && npm publish"
28
+ "compileAndPublish": "rm -r build && tsc && npm publish"
29
29
  },
30
30
  "bin": {
31
31
  "structured": "./build/system/bin/structured.js"
@@ -1,7 +0,0 @@
1
- import { DataStore } from './DataStore.js';
2
- import { ClientComponent } from './ClientComponent.js';
3
- export declare class App {
4
- root: ClientComponent;
5
- store: DataStore;
6
- constructor();
7
- }
@@ -1,8 +0,0 @@
1
- import { DataStore } from './DataStore.js';
2
- import { ClientComponent } from './ClientComponent.js';
3
- export class App {
4
- constructor() {
5
- this.store = new DataStore();
6
- this.root = new ClientComponent(null, 'root', document.body, this.store);
7
- }
8
- }