structured-fw 0.8.1 → 0.8.4

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
@@ -8,15 +8,20 @@ It works with Node.js and Deno runtimes. Other runtimes are not tested.
8
8
  - [Why Structured](#why-structured)
9
9
  - [Audience](#audience)
10
10
  - [Getting started](#getting-started)
11
+ - [Key concepts](#key-concepts)
12
+ - [Good to know](#good-to-know)
13
+
11
14
 
12
15
  ### Key concepts:
13
16
  * [Application](#application)
14
17
  * [Route](#route)
15
18
  * [Document](#document)
16
- * [ClientComponent](#component) (component)
19
+ * [Component](#component)
17
20
 
18
21
  ## Getting started
19
22
 
23
+ _Following getting started instructions are relevant for Node.js runtime, if you are using Deno skip to [runtimes](#runtimes) section._
24
+
20
25
  ### Initialize a Node.js project
21
26
  ```
22
27
  cd /path/to/project
@@ -105,13 +110,13 @@ new Application(config);
105
110
 
106
111
  ### Methods
107
112
  - `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
108
- - `on(evt: ApplicationEvents, callback: RequestCallback|((payload?: any) => void))` - allows you to add event listeners for specific `ApplicationEvenets`:
109
- - `serverStarted` - executed once the built-in http server is started and running. Callback receives no arguments
113
+ - `on(evt: ApplicationEvents, callback: RequestCallback|((payload?: any) => void))` - allows you to add event listeners for specific `ApplicationEvents`:
114
+ - `serverStarted` - executed once the built-in http server is started and running. Callback receives Server (exported from node:http) instance as the first argument
110
115
  - `beforeRequestHandler` - runs before any request handler (route) is executed. Callback receives `RequestContext` as the first argument. Useful for example to set `RequestContext.data: RequestContextData` (user defined data, to make it available to routes and components)
111
116
  - `afterRequestHandler` - runs after any request handler (route) is executed. Callback receives `RequestContext` as the first argument
112
117
  - `afterRoutes` - runs after all routes are loaded from `StructuredConfig.routes.path`. Callback receives no arguments
113
- - `beforeComponentLoad` - runs before components are loaded from `StructuredConfig.components.path`. Callback receives no arguments
114
- - `afterComponentLoad` - runs after all components are loaded from `StructuredConfig.components.path`. Callback receives no arguments
118
+ - `beforeComponentsLoad` - runs before components are loaded from `StructuredConfig.components.path`. Callback receives no arguments
119
+ - `afterComponentsLoaded` - runs after all components are loaded from `StructuredConfig.components.path`. Callback receives instance of Components as the first argument
115
120
  - `documentCreated` - runs whenever an instance of a [Document](#document) is created. Callback receives the Document instance as the first argument. You will often use this, for example if you want to include a CSS file to all pages `Document.head.addCSS(...)`
116
121
  - `beforeAssetAccess` - runs when assets are being accessed, before response is sent. Callback receives `RequestContext` as the first argument
117
122
  - `afterAssetAccess` - runs when assets are being accessed, after response is sent. Callback receives `RequestContext` as the first argument
@@ -168,7 +173,7 @@ app.exportContextFields('user');
168
173
  ```
169
174
 
170
175
  ### Session
171
- 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 instace `Application.session`.
176
+ 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`.
172
177
 
173
178
  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.
174
179
 
@@ -316,16 +321,16 @@ In some edge cases you may need more control of when a route is executed, in whi
316
321
  > email: string,
317
322
  > password: string,
318
323
  > age: number
319
- >}>('POST', '/users/create', asyc (ctx) => {
324
+ >}>('POST', '/users/create', async (ctx) => {
320
325
  > ctx.body.email // string
321
326
  > ctx.body.age // number
322
- > const doc = new Document(ctx, 'User', app);
327
+ > const doc = new Document(app, 'User', ctx);
323
328
  > return doc; // error if we return anything but Document
324
329
  > });
325
330
  > ```
326
331
 
327
332
  ## Document
328
- Document does not differ much from a component, in fact, it extends Component. It has a more user-firendly 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.
333
+ 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.
329
334
 
330
335
  Creating a document:
331
336
  `const doc = new Document(app, 'HelloWorld page', ctx);`
@@ -341,7 +346,7 @@ app.request.on('GET', '/home', async (ctx) => {
341
346
 
342
347
  ## Component
343
348
  A component is comprised of 1-3 files. It always must include one HTML file, while server side and client side files are optional.
344
- * HTML file preobably requires no explanation
349
+ * HTML file probably requires no explanation
345
350
  * server side file, code that runs on the server and makes data available to HTML and client side code
346
351
  * client side file, code that runs on the client (in the browser)
347
352
 
@@ -362,7 +367,7 @@ It is recommended, but not necessary, that you contain each component in it's ow
362
367
  \
363
368
  **Component rules:**
364
369
  - **Component names must be unique**
365
- - Components HTML file can have a `.hbs` extension (which allows for better Handlebars sytax highlighting)
370
+ - Components HTML file can have a `.hbs` extension (which allows for better Handlebars syntax highlighting)
366
371
  - Components can reside at any depth in the file structure
367
372
 
368
373
  Let's create a HelloWorld Component `/app/views/HelloWorld/HelloWorld.html`:\
@@ -494,7 +499,7 @@ What we did is, we accepted the number provided by parent component, and returne
494
499
  betterNumber: number
495
500
  }
496
501
  ```
497
- which is now avaialble in `AnotherComponent` HTML, we assigned the received number to `parentSuggests`, while `betterNumber` is `parentSuggests + 5`, we now have these 2 available and ready to use in our HTML template.
502
+ which is now available in `AnotherComponent` HTML, we assigned the received number to `parentSuggests`, while `betterNumber` is `parentSuggests + 5`, we now have these 2 available and ready to use in our HTML template.
498
503
 
499
504
  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).
500
505
 
@@ -550,7 +555,7 @@ export const init: InitializerFunction = async function() {
550
555
  ```
551
556
  Here we accessed the `parent` and obtained it's `name`.
552
557
 
553
- *"But we did not send any data to the parent here"* - correct, we did not, and we won't, instead we can inform them we have some data available, or that an event they might be interested in has ocurred, and if they care, so be it:
558
+ *"But we did not send any data to the parent here"* - correct, we did not, and we won't, instead we can inform them we have some data available, or that an event they might be interested in has occurred, and if they care, so be it:
554
559
  ```
555
560
  import { InitializerFunction } from 'system/Types.js';
556
561
  export const init: InitializerFunction = async function() {
@@ -580,7 +585,7 @@ export const init: InitializerFunction = async function() {
580
585
  }
581
586
  ```
582
587
 
583
- 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 ocurred, 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.
588
+ 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.
584
589
 
585
590
  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`.
586
591
 
@@ -600,11 +605,160 @@ Methods:
600
605
  - `store.set(key: string, value: any)` - set data in client side data store
601
606
  - `find(componentName: string, recursive: boolean = true): ClientComponent | null` - find a child component
602
607
  - `findParent(componentName: string): ClientComponent | null` - find the first parent with given name
603
- - `query(componentName: string, recursive: boolean = true): Array<ClientComponent>` - return all components with given name found within this component, if `recurive = false`, only direct children are considered
608
+ - `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
604
609
  - `ref<T>(refName: string): T` - get a HTMLElement or ClientComponent that has attribute `ref="[refName]"`
605
610
  - `arrayRef<T>(refName: string): Array<T>` - get an array of HTMLElement or ClientComponent that have attribute `array:ref="[refName]"`
606
611
  - `add(appendTo: HTMLElement, componentName: string, data?: LooseObject)` - add `componentName` component to `appendTo` element, optionally passing `data` to the component when it's being rendered
607
612
 
613
+ ## Good to know
614
+ - [Using CSS frameworks](#css-frameworks)
615
+ - [Using JS runtimes other than Node.js](#runtimes)
616
+ - [Why not JSR](#jsr)
617
+ - [Best practices](#best-practices)
618
+
619
+ ### CSS frameworks
620
+ We rarely write all CSS from scratch, usually we use a CSS framework to speed us up. Structured allows you to work with any CSS frameworks such as Tailwind, PostCSS or Bootstrap.
621
+
622
+ Your Tailwind configuration may look something like:
623
+ ```
624
+ /** @type {import('tailwindcss').Config} */
625
+ module.exports = {
626
+ content: ["./app/views/**/*.html", "./app/views/**/*.hbs"],
627
+ ...
628
+ }
629
+ ```
630
+
631
+ Above we just defined where all our HTML resides, which is within /app/views. That is all there is to it. From there, you can generate the CSS, for example:\
632
+ `npx tailwindcss -i ./assets/css/src/style.css -o ./assets/css/dist.css`
633
+
634
+ **Including the output CSS**\
635
+ To include the output CSS in all pages, you can add the following to `index.ts`:
636
+ ```
637
+ const app = new Application(config);
638
+
639
+ app.on('documentCreated', (doc) => {
640
+ doc.head.addCSS('/assets/css/dist.css');
641
+ });
642
+ ```
643
+
644
+ ### Runtimes
645
+ Structured is tested with Node.js and Deno. Other runtimes would likely work as well.
646
+
647
+ To use Structured with Deno, you can:
648
+ ```
649
+ cd /path/to/project
650
+ deno init
651
+ deno add npm:structured-fw
652
+ ```
653
+
654
+ With Deno, we can't use the cli to create the boilerplate, so you will need to create it yourself.
655
+ ```
656
+ mkdir app
657
+ mkdir app/views
658
+ mkdir app/routes
659
+ ```
660
+
661
+ Create `Config.ts`:
662
+ ```
663
+ import { StructuredConfig } from "structured-fw/Types";
664
+
665
+ export const config: StructuredConfig = {
666
+ // Application.importEnv will load all env variables starting with [envPrefix]_
667
+ envPrefix: 'STRUCTURED',
668
+
669
+ // whether to call Application.init when an instance of Application is created
670
+ autoInit: true,
671
+
672
+ url: {
673
+ removeTrailingSlash: true,
674
+
675
+ // if you want to enable individual component rendering set this to URI (string)
676
+ // to disable component rendering set it to false
677
+ // setting this to false disallows the use of ClientComponent.redraw and ClientComponent.add
678
+ componentRender: '/componentRender',
679
+
680
+ // function that receives the requested URL and returns boolean, if true, treat as static asset
681
+ // if there is a registered request handler that matches this same URL, it takes precedence over this
682
+ isAsset: function(uri: string) {
683
+ return uri.indexOf('/assets/') === 0;
684
+ }
685
+ },
686
+ routes: {
687
+ path: '/app/routes'
688
+ },
689
+ components : {
690
+ // relative to index.ts
691
+ path: '/app/views',
692
+
693
+ componentNameAttribute: 'structured-component'
694
+ },
695
+ session: {
696
+ cookieName: 'session',
697
+ keyLength: 24,
698
+ durationSeconds: 60 * 60,
699
+ garbageCollectIntervalSeconds: 60
700
+ },
701
+ http: {
702
+ port: 9191,
703
+ host: '0.0.0.0',
704
+ // used by Document.push, can be preload or preconnect
705
+ linkHeaderRel : 'preload'
706
+ },
707
+ runtime: 'Deno'
708
+ }
709
+ ```
710
+
711
+ Import `Config.ts` in `main.ts` and create the Application instance:
712
+ ```
713
+ import { Application } from 'structured-fw/Application';
714
+ import { config } from './Config.ts';
715
+
716
+ new Application(config);
717
+ ```
718
+
719
+ Run application using `deno main.ts`
720
+
721
+ ### JSR
722
+ It would make a lot of sense to have Structured hosted on JSR (JavaScript Registry) given Structured is a TypeScript framework, and JSR is a TypeScript-first registry, however, the issue is that Deno imposes [limitations with dynamic imports](https://docs.deno.com/deploy/api/dynamic-import/) with JSR-imported dependencies, which are required for the framework (to dynamically import your routes and components).\
723
+ This does not stop the framework from working with Deno, but for the time being, we have to stick with good old npm.
724
+
725
+ ### Best practices
726
+
727
+ **Entry point:**\
728
+ I suggest the following setup for your entry point:
729
+ 1) Set `autoInit = false` in your `/Config.ts`
730
+ 2) If you are using ENV variables, define a type `EnvConf` in `/app/Types.ts`
731
+ 3) In `/index.ts`, only create the Application instance and import ENV using `importEnv`, exporting both, as follows:
732
+ ```
733
+ import { EnvConf } from './app/Types.js';
734
+ import { Application } from 'structured-fw/Application';
735
+ import { config } from './Config.js';
736
+
737
+ export const app = new Application(config);
738
+ export const env = app.importEnv<EnvConf>();
739
+ ```
740
+ 4) Create `/main.ts` and import `app` and `env` from `/index.ts`, add `main.ts` to tsconfig.json include array, add any event listeners, and load helpers from within `/main.ts`. This makes sure you can use env in any imported modules in main.ts without having to use dynamic imports. You can later import `env` and `app` from `index.ts` wherever you want to use them.
741
+
742
+ \
743
+ **Component directories**\
744
+ You should always place your components in a directory named same as your component. While this is not required, it will keep things organized. You might think your component will only have a HTML part, but at some point you may decide you want to add client/server code to it, so it's better to start out with a directory.\
745
+ Feel free to group your components in directories and subdirectories. Structured loads all components recursively when Application is initialized, and allows you to load any existing component from any component/Document. You can even move your components to other directory later without having to worry about updating the imports.
746
+
747
+ **Type definitions**\
748
+ I suggest keeping your general type definitions in /app/Types.ts, but for more specific types you should probably create /app/types/[entity].types.ts to keep things clean easy to maintain.\
749
+ For example:\
750
+ `export type BooleanInt = 0 | 1;` - this is fine
751
+ in /app/Types.ts\
752
+ `export type User = {email: string, password: string}` - you should probably create /app/types/users.types.ts for this one
753
+
754
+ **Models**\
755
+ If you ran `npx structured init`, it has created /app/models for you. Structured does not use this directory, but I suggest keeping your models interfacing the DB/APIs there. While Structured framework is not an MVC in a traditional sense, it's a good idea to keep your models in one place, as you will want to import the same model from many routes and components.
756
+
757
+ > [!IMPORTANT]
758
+ > while it's true that with Structured, components take care of their own data, it does not mean that they need to contain the code to fetch said data, instead you are encouraged to keep data logic in your models, and use those models in components/routes.
759
+
760
+ You can create additional code separation, for example, it would make sense to have /app/lib for code that interfaces an API, or have /app/Util.ts where you export utility functions. Structured boilerplate does not include these as not all applications will need them.
761
+
608
762
  ## Why Structured
609
763
  Framework was developed by someone who has been a web developer for almost 20 years (me), and did not like the path web development has taken.
610
764
  \
package/build/index.js CHANGED
@@ -1,3 +1,8 @@
1
1
  import { Application } from "structured-fw/Application";
2
2
  import { config } from './Config.js';
3
- new Application(config);
3
+ const app = new Application(config);
4
+ app.on('documentCreated', (document) => {
5
+ document.on('componentCreated', (component) => {
6
+ document.head.add(`<script>console.log('${component.name}')</script>`);
7
+ });
8
+ });
@@ -0,0 +1,7 @@
1
+ import { EventEmitterCallback } from "./Types.js";
2
+ export declare class EventEmitter<T extends Record<string, any> = Record<string, any>> {
3
+ protected listeners: Partial<Record<keyof T, Array<EventEmitterCallback<any>>>>;
4
+ on<K extends keyof T>(eventName: K, callback: EventEmitterCallback<T[K]>): void;
5
+ emit(eventName: keyof T, payload?: any): void;
6
+ unbind(eventName: keyof T, callback: EventEmitterCallback<any>): void;
7
+ }
@@ -0,0 +1,31 @@
1
+ export class EventEmitter {
2
+ constructor() {
3
+ this.listeners = {};
4
+ }
5
+ on(eventName, callback) {
6
+ if (!Array.isArray(this.listeners[eventName])) {
7
+ this.listeners[eventName] = [];
8
+ }
9
+ this.listeners[eventName].push(callback);
10
+ }
11
+ emit(eventName, payload) {
12
+ if (Array.isArray(this.listeners[eventName])) {
13
+ this.listeners[eventName].forEach((callback) => {
14
+ callback(payload);
15
+ });
16
+ }
17
+ }
18
+ unbind(eventName, callback) {
19
+ if (Array.isArray(this.listeners[eventName])) {
20
+ while (true) {
21
+ const index = this.listeners[eventName].indexOf(callback);
22
+ if (index > -1) {
23
+ this.listeners[eventName].splice(index, 1);
24
+ }
25
+ else {
26
+ break;
27
+ }
28
+ }
29
+ }
30
+ }
31
+ }
@@ -127,7 +127,7 @@ export interface ComponentScaffold {
127
127
  [key: string]: any;
128
128
  }
129
129
  export type LooseObject = Record<string, any>;
130
- export type ApplicationEvents = 'serverStarted' | 'beforeRequestHandler' | 'afterRequestHandler' | 'beforeRoutes' | 'afterRoutes' | 'beforeComponentLoad' | 'afterComponentLoad' | 'documentCreated' | 'beforeAssetAccess' | 'afterAssetAccess' | 'pageNotFound';
130
+ export type ApplicationEvents = 'serverStarted' | 'beforeRequestHandler' | 'afterRequestHandler' | 'beforeRoutes' | 'afterRoutes' | 'beforeComponentsLoad' | 'afterComponentsLoaded' | 'documentCreated' | 'beforeAssetAccess' | 'afterAssetAccess' | 'pageNotFound';
131
131
  export type SessionEntry = {
132
132
  sessionId: string;
133
133
  lastRequest: number;
@@ -166,4 +166,4 @@ export type ClientComponentTransition = {
166
166
  };
167
167
  export type ClientComponentTransitionEvent = 'show' | 'hide';
168
168
  export type ClientComponentTransitions = Record<ClientComponentTransitionEvent, ClientComponentTransition>;
169
- export type EventEmitterCallback = (payload: any) => void;
169
+ export type EventEmitterCallback<T> = (payload: T) => void;
@@ -1,14 +1,8 @@
1
- import { InitializerFunction, LooseObject, StructuredClientConfig } from '../Types.js';
1
+ import { LooseObject } from '../Types.js';
2
2
  import { DataStoreView } from './DataStoreView.js';
3
3
  import { DataStore } from './DataStore.js';
4
4
  import { Net } from './Net.js';
5
- import { EventEmitter } from './EventEmitter.js';
6
- declare global {
7
- interface Window {
8
- initializers: Record<string, InitializerFunction | string>;
9
- structuredClientConfig: StructuredClientConfig;
10
- }
11
- }
5
+ import { EventEmitter } from '../EventEmitter.js';
12
6
  export declare class ClientComponent extends EventEmitter {
13
7
  readonly name: string;
14
8
  children: Array<ClientComponent>;
@@ -2,7 +2,7 @@ import { attributeValueFromString, attributeValueToString, mergeDeep, objectEach
2
2
  import { DataStoreView } from './DataStoreView.js';
3
3
  import { Net } from './Net.js';
4
4
  import { NetRequest } from './NetRequest.js';
5
- import { EventEmitter } from './EventEmitter.js';
5
+ import { EventEmitter } from '../EventEmitter.js';
6
6
  export class ClientComponent extends EventEmitter {
7
7
  constructor(parent, name, domNode, store, runInitializer = true) {
8
8
  super();
@@ -20,7 +20,7 @@ export declare class Application {
20
20
  constructor(config: StructuredConfig);
21
21
  init(): Promise<void>;
22
22
  private start;
23
- on<E extends ApplicationEvents>(evt: E, callback: (payload: E extends 'beforeRequestHandler' | 'afterRequestHandler' | 'beforeAssetAccess' | 'afterAssetAccess' | 'pageNotFound' ? RequestContext : E extends 'documentCreated' ? Document : undefined) => void): void;
23
+ on<E extends ApplicationEvents>(evt: E, callback: (payload: E extends 'beforeRequestHandler' | 'afterRequestHandler' | 'beforeAssetAccess' | 'afterAssetAccess' | 'pageNotFound' ? RequestContext : E extends 'documentCreated' ? Document : E extends 'afterComponentsLoaded' ? Components : E extends 'serverStarted' ? Server : undefined) => void): void;
24
24
  emit(eventName: ApplicationEvents, payload?: any): Promise<Array<any>>;
25
25
  importEnv<T extends LooseObject>(smartPrimitives?: boolean): T;
26
26
  exportContextFields(...fields: Array<keyof RequestContextData>): void;
@@ -35,12 +35,12 @@ export class Application {
35
35
  catch (e) {
36
36
  console.error(e.message);
37
37
  }
38
- await this.emit('beforeComponentLoad');
38
+ await this.emit('beforeComponentsLoad');
39
39
  this.components.loadComponents();
40
- await this.emit('afterComponentLoad');
40
+ await this.emit('afterComponentsLoaded', this.components);
41
41
  await this.emit('beforeRoutes');
42
42
  await this.request.loadHandlers();
43
- await this.emit('afterRoutes');
43
+ await this.emit('afterRoutes', this.request);
44
44
  if (this.config.url.componentRender !== false) {
45
45
  this.request.on('POST', `${this.config.url.componentRender}`, async (ctx) => {
46
46
  const input = ctx.body;
@@ -49,16 +49,18 @@ export class Application {
49
49
  }
50
50
  this.request.on('GET', /^\/assets\/client-js/, async ({ request, response }) => {
51
51
  const uri = request.url?.substring(18);
52
- const filePath = path.resolve('./system/', uri);
52
+ if (uri.includes('..')) {
53
+ return '';
54
+ }
55
+ const filePath = path.resolve(import.meta.dirname, '..', uri);
53
56
  if (existsSync(filePath)) {
54
57
  response.setHeader('Content-Type', 'application/javascript');
55
- response.write(readFileSync(filePath));
56
- response.end();
58
+ return readFileSync(filePath);
57
59
  }
58
60
  else {
59
61
  response.statusCode = 404;
60
62
  }
61
- return;
63
+ return '';
62
64
  }, this, true);
63
65
  await this.start();
64
66
  }
@@ -69,7 +71,7 @@ export class Application {
69
71
  });
70
72
  this.server.listen(this.config.http.port, this.config.http.host || '127.0.0.1', async () => {
71
73
  const address = (this.config.http.host !== undefined ? this.config.http.host : '') + ':' + this.config.http.port;
72
- await this.emit('serverStarted');
74
+ await this.emit('serverStarted', this.server);
73
75
  console.log(`Server started on ${address}`);
74
76
  resolve();
75
77
  });
@@ -1,7 +1,10 @@
1
1
  import { Document } from './Document.js';
2
2
  import { ComponentEntry, LooseObject } from '../Types.js';
3
3
  import { DOMNode } from './dom/DOMNode.js';
4
- export declare class Component {
4
+ import { EventEmitter } from '../EventEmitter.js';
5
+ export declare class Component<Events extends Record<string, any> = {
6
+ 'componentCreated': Component;
7
+ }> extends EventEmitter<Events> {
5
8
  id: string;
6
9
  name: string;
7
10
  document: Document;
@@ -20,8 +23,8 @@ export declare class Component {
20
23
  private initChildren;
21
24
  protected importedParentData(parentData: LooseObject): LooseObject;
22
25
  protected initAttributesData(domNode?: DOMNode): void;
23
- private attributePreffix;
26
+ private attributePrefix;
24
27
  private attributeDataType;
25
- private attributeUnpreffixed;
28
+ private attributeUnprefixed;
26
29
  protected fillData(data: LooseObject): void;
27
30
  }
@@ -1,13 +1,16 @@
1
1
  import { Document } from './Document.js';
2
2
  import { attributeValueFromString, attributeValueToString, objectEach, toCamelCase } from '../Util.js';
3
3
  import { DOMFragment } from './dom/DOMFragment.js';
4
- export class Component {
4
+ import { EventEmitter } from '../EventEmitter.js';
5
+ export class Component extends EventEmitter {
5
6
  constructor(name, node, parent, autoInit = true) {
7
+ super();
6
8
  this.children = [];
7
9
  this.path = [];
8
10
  this.attributesRaw = {};
9
11
  this.attributes = {};
10
12
  this.data = {};
13
+ const isDocument = this instanceof Document;
11
14
  this.name = name;
12
15
  if (name === 'root') {
13
16
  this.dom = new DOMFragment();
@@ -21,7 +24,7 @@ export class Component {
21
24
  }
22
25
  this.isRoot = false;
23
26
  }
24
- if (this instanceof Document) {
27
+ if (isDocument) {
25
28
  this.document = this;
26
29
  }
27
30
  else {
@@ -42,6 +45,9 @@ export class Component {
42
45
  else {
43
46
  this.entry = null;
44
47
  }
48
+ if (!isDocument) {
49
+ this.document.emit('componentCreated', this);
50
+ }
45
51
  }
46
52
  async init(html, data) {
47
53
  this.initAttributesData();
@@ -180,7 +186,7 @@ export class Component {
180
186
  }
181
187
  for (let i = 0; i < domNode.attributes.length; i++) {
182
188
  const attrNameRaw = domNode.attributes[i].name;
183
- const attrNameUnprefixed = this.attributeUnpreffixed(attrNameRaw);
189
+ const attrNameUnprefixed = this.attributeUnprefixed(attrNameRaw);
184
190
  if (attrNameUnprefixed.indexOf('data-') === 0) {
185
191
  const attrDataType = this.attributeDataType(attrNameRaw);
186
192
  const dataDecoded = attributeValueFromString(domNode.attributes[i].value.toString());
@@ -214,7 +220,7 @@ export class Component {
214
220
  this.attributesRaw[attrNameRaw] = domNode.attributes[i].value;
215
221
  }
216
222
  }
217
- attributePreffix(attrName) {
223
+ attributePrefix(attrName) {
218
224
  const index = attrName.indexOf(':');
219
225
  if (index < 0) {
220
226
  return null;
@@ -222,7 +228,7 @@ export class Component {
222
228
  return attrName.substring(0, index);
223
229
  }
224
230
  attributeDataType(attrName) {
225
- const prefix = this.attributePreffix(attrName);
231
+ const prefix = this.attributePrefix(attrName);
226
232
  if (prefix === 'string' ||
227
233
  prefix === 'number' ||
228
234
  prefix === 'object' ||
@@ -231,7 +237,7 @@ export class Component {
231
237
  }
232
238
  return 'any';
233
239
  }
234
- attributeUnpreffixed(attrName) {
240
+ attributeUnprefixed(attrName) {
235
241
  const index = attrName.indexOf(':');
236
242
  if (index < 0) {
237
243
  return attrName;
@@ -3,7 +3,9 @@ import { Initializers, LooseObject, RequestContext } from '../../system/Types.js
3
3
  import { Application } from './Application.js';
4
4
  import { DocumentHead } from './DocumentHead.js';
5
5
  import { Component } from './Component.js';
6
- export declare class Document extends Component {
6
+ export declare class Document extends Component<{
7
+ 'componentCreated': Component;
8
+ }> {
7
9
  head: DocumentHead;
8
10
  language: string;
9
11
  application: Application;
package/index.ts CHANGED
@@ -1,4 +1,21 @@
1
1
  import { Application } from "structured-fw/Application";
2
2
  import { config } from './Config.js';
3
3
 
4
- new Application(config);
4
+ const app = new Application(config);
5
+
6
+ // app.on('afterComponentsLoaded', (components) => {
7
+ // components.componentNames.forEach((componentName) => {
8
+ // console.log(componentName)
9
+ // console.log(components.getByName(componentName));
10
+ // });
11
+ // })
12
+
13
+ // app.on('componentCreated', (component) => {
14
+ // console.log(component.document.id);
15
+ // })
16
+
17
+ app.on('documentCreated', (document) => {
18
+ document.on('componentCreated', (component) => {
19
+ document.head.add(`<script>console.log('${component.name}')</script>`);
20
+ });
21
+ })
package/jsr.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@structured/structured-fw",
3
+ "version": "0.8.21",
4
+ "license": "MIT",
5
+ "exports" : {
6
+ "./Types": "./system/Types.ts",
7
+ "./Symbols": "./system/Symbols.ts",
8
+ "./Util": "./system/Util.ts",
9
+ "./Application": "./system/server/Application.ts",
10
+ "./Document": "./system/server/Document.ts",
11
+ "./FormValidation": "./system/server/FormValidation.ts",
12
+ "./ClientComponent": "./system/client/ClientComponent.ts"
13
+ },
14
+ "dependencies": {
15
+ "handlebars": "^4.7.8",
16
+ "mime-types": "^3.0.0",
17
+ "ts-md5": "^1.3.1",
18
+ "@types/node": "^22.9.3",
19
+ "@types/mime-types": "^2.1.4"
20
+ },
21
+ "exclude": [
22
+ "build",
23
+ "assets",
24
+ "node_modules",
25
+ "tsconfig.json",
26
+ "package.json",
27
+ "package.lock.json",
28
+ "tailwind.config.cjs",
29
+ ".vscode",
30
+ "app/Types.ts",
31
+ "app/views/*",
32
+ "app/routes/*",
33
+ "app/models/*"
34
+ ]
35
+ }
package/package.json CHANGED
@@ -14,12 +14,13 @@
14
14
  "license": "MIT",
15
15
  "type": "module",
16
16
  "main": "build/index",
17
- "version": "0.8.1",
17
+ "version": "0.8.4",
18
18
  "scripts": {
19
19
  "develop": "tsc --watch",
20
20
  "startDev": "cd build && nodemon --watch '../app/**/*' --watch '../build/**/*' -e js,html,css index.js",
21
21
  "start": "cd build && node index.js",
22
- "prepublish": "tsc"
22
+ "prepublish": "tsc && mv tsconfig.json _ts.json",
23
+ "postpublish": "mv _ts.json tsconfig.json"
23
24
  },
24
25
  "bin": {
25
26
  "structured": "./build/system/bin/structured.js"
@@ -1,10 +1,10 @@
1
- import { EventEmitterCallback } from "../Types.js";
1
+ import { EventEmitterCallback } from "./Types.js";
2
2
 
3
- export class EventEmitter {
4
- protected listeners: Record<string, Array<EventEmitterCallback>> = {}
3
+ export class EventEmitter<T extends Record<string, any> = Record<string, any>> {
4
+ protected listeners: Partial<Record<keyof T, Array<EventEmitterCallback<any>>>> = {}
5
5
 
6
6
  // add event listener
7
- public on(eventName: string, callback: EventEmitterCallback): void {
7
+ public on<K extends keyof T>(eventName: K, callback: EventEmitterCallback<T[K]>): void {
8
8
  if (! Array.isArray(this.listeners[eventName])) {
9
9
  this.listeners[eventName] = [];
10
10
  }
@@ -13,7 +13,7 @@ export class EventEmitter {
13
13
  }
14
14
 
15
15
  // emit event with given payload
16
- public emit(eventName: string, payload?: any): void {
16
+ public emit(eventName: keyof T, payload?: any): void {
17
17
  if (Array.isArray(this.listeners[eventName])) {
18
18
  this.listeners[eventName].forEach((callback) => {
19
19
  callback(payload);
@@ -22,7 +22,7 @@ export class EventEmitter {
22
22
  }
23
23
 
24
24
  // remove event listener
25
- public unbind(eventName: string, callback: EventEmitterCallback): void {
25
+ public unbind(eventName: keyof T, callback: EventEmitterCallback<any>): void {
26
26
  if (Array.isArray(this.listeners[eventName])) {
27
27
  while (true) {
28
28
  const index = this.listeners[eventName].indexOf(callback);
package/system/Types.ts CHANGED
@@ -177,7 +177,7 @@ export interface ComponentScaffold {
177
177
 
178
178
  export type LooseObject = Record<string, any>
179
179
 
180
- export type ApplicationEvents = 'serverStarted'|'beforeRequestHandler'|'afterRequestHandler'|'beforeRoutes'|'afterRoutes'|'beforeComponentLoad'|'afterComponentLoad'|'documentCreated'|'beforeAssetAccess'|'afterAssetAccess'|'pageNotFound';
180
+ export type ApplicationEvents = 'serverStarted'|'beforeRequestHandler'|'afterRequestHandler'|'beforeRoutes'|'afterRoutes'|'beforeComponentsLoad'|'afterComponentsLoaded'|'documentCreated'|'beforeAssetAccess'|'afterAssetAccess'|'pageNotFound';
181
181
 
182
182
  export type SessionEntry = {
183
183
  sessionId : string,
@@ -229,4 +229,4 @@ export type ClientComponentTransition = {
229
229
  export type ClientComponentTransitionEvent = 'show' | 'hide';
230
230
  export type ClientComponentTransitions = Record<ClientComponentTransitionEvent, ClientComponentTransition>;
231
231
 
232
- export type EventEmitterCallback = (payload: any) => void
232
+ export type EventEmitterCallback<T> = (payload: T) => void
@@ -1,20 +1,10 @@
1
- import { ClientComponentTransition, ClientComponentTransitions, InitializerFunction, LooseObject, StoreChangeCallback, StructuredClientConfig } from '../Types.js';
1
+ import { ClientComponentTransition, ClientComponentTransitions, InitializerFunction, LooseObject, StoreChangeCallback } from '../Types.js';
2
2
  import { attributeValueFromString, attributeValueToString, mergeDeep, objectEach, queryStringDecodedSetValue, toCamelCase } from '../Util.js';
3
3
  import { DataStoreView } from './DataStoreView.js';
4
4
  import { DataStore } from './DataStore.js';
5
5
  import { Net } from './Net.js';
6
6
  import { NetRequest } from './NetRequest.js';
7
- import { EventEmitter } from './EventEmitter.js';
8
-
9
- // window.initializers will always be present
10
- // each Document has a list of initializers used in components within it
11
- // and they will be output as initializers = { componentName : initializer }
12
- declare global {
13
- interface Window {
14
- initializers: Record<string, InitializerFunction | string>;
15
- structuredClientConfig: StructuredClientConfig;
16
- }
17
- }
7
+ import { EventEmitter } from '../EventEmitter.js';
18
8
 
19
9
  export class ClientComponent extends EventEmitter {
20
10
  readonly name: string;
@@ -0,0 +1,12 @@
1
+ import { InitializerFunction, StructuredClientConfig } from "./Types.js";
2
+
3
+ export {}
4
+ // window.initializers will always be present
5
+ // each Document has a list of initializers used in components within it
6
+ // and they will be output as initializers = { componentName : initializer }
7
+ declare global {
8
+ interface Window {
9
+ initializers: Record<string, InitializerFunction | string>;
10
+ structuredClientConfig: StructuredClientConfig;
11
+ }
12
+ }
@@ -59,14 +59,14 @@ export class Application {
59
59
  console.error(e.message);
60
60
  }
61
61
 
62
- await this.emit('beforeComponentLoad');
62
+ await this.emit('beforeComponentsLoad');
63
63
  this.components.loadComponents();
64
- await this.emit('afterComponentLoad');
64
+ await this.emit('afterComponentsLoaded', this.components);
65
65
 
66
66
 
67
67
  await this.emit('beforeRoutes');
68
68
  await this.request.loadHandlers();
69
- await this.emit('afterRoutes');
69
+ await this.emit('afterRoutes', this.request);
70
70
 
71
71
  if (this.config.url.componentRender !== false) {
72
72
  // special request handler, executed when ClientComponent.redraw is called
@@ -84,15 +84,15 @@ export class Application {
84
84
  // special request handler, serve the client side JS
85
85
  this.request.on('GET', /^\/assets\/client-js/, async ({ request, response }) => {
86
86
  const uri = request.url?.substring(18) as string;
87
- const filePath = path.resolve('./system/', uri);
87
+ if (uri.includes('..')) {return '';} // disallow having ".." in the URL
88
+ const filePath = path.resolve(import.meta.dirname, '..', uri);
88
89
  if (existsSync(filePath)) {
89
90
  response.setHeader('Content-Type', 'application/javascript');
90
- response.write(readFileSync(filePath));
91
- response.end();
91
+ return readFileSync(filePath);
92
92
  } else {
93
93
  response.statusCode = 404;
94
94
  }
95
- return;
95
+ return '';
96
96
  }, this, true);
97
97
 
98
98
  await this.start();
@@ -106,7 +106,7 @@ export class Application {
106
106
  });
107
107
  this.server.listen(this.config.http.port, this.config.http.host || '127.0.0.1', async () => {
108
108
  const address = (this.config.http.host !== undefined ? this.config.http.host : '') + ':' + this.config.http.port;
109
- await this.emit('serverStarted');
109
+ await this.emit('serverStarted', this.server);
110
110
  console.log(`Server started on ${address}`);
111
111
  resolve();
112
112
  });
@@ -120,6 +120,8 @@ export class Application {
120
120
  payload:
121
121
  E extends 'beforeRequestHandler' | 'afterRequestHandler' | 'beforeAssetAccess' | 'afterAssetAccess' | 'pageNotFound' ? RequestContext :
122
122
  E extends 'documentCreated' ? Document :
123
+ E extends 'afterComponentsLoaded' ? Components :
124
+ E extends 'serverStarted' ? Server :
123
125
  undefined
124
126
  ) => void
125
127
  ): void {
@@ -144,7 +146,7 @@ export class Application {
144
146
  });
145
147
  }
146
148
 
147
- // load envirnment variables
149
+ // load environment variables
148
150
  // if this.config.envPrefix is a string, load all ENV variables starting with [envPrefix]_
149
151
  // the method is generic, so user can define the expected return type
150
152
  public importEnv<T extends LooseObject>(smartPrimitives: boolean = true): T {
@@ -3,8 +3,9 @@ import { attributeValueFromString, attributeValueToString, objectEach, toCamelCa
3
3
  import { ComponentEntry, LooseObject } from '../Types.js';
4
4
  import { DOMFragment } from './dom/DOMFragment.js';
5
5
  import { DOMNode } from './dom/DOMNode.js';
6
+ import { EventEmitter } from '../EventEmitter.js';
6
7
 
7
- export class Component {
8
+ export class Component<Events extends Record<string, any> = {'componentCreated' : Component}> extends EventEmitter<Events> {
8
9
  id: string;
9
10
  name: string;
10
11
  document: Document;
@@ -29,6 +30,8 @@ export class Component {
29
30
  isRoot: boolean;
30
31
 
31
32
  constructor(name: string, node?: DOMNode, parent?: Document|Component, autoInit: boolean = true) {
33
+ super();
34
+ const isDocument = this instanceof Document;
32
35
  this.name = name;
33
36
 
34
37
  if (name === 'root') {
@@ -43,7 +46,7 @@ export class Component {
43
46
  this.isRoot = false;
44
47
  }
45
48
 
46
- if (this instanceof Document) {
49
+ if (isDocument) {
47
50
  // this will only happen if an instance of Document, as it extends component
48
51
  this.document = this;
49
52
  } else {
@@ -71,16 +74,20 @@ export class Component {
71
74
  } else {
72
75
  this.entry = null;
73
76
  }
77
+
78
+ if (! isDocument) {
79
+ this.document.emit('componentCreated', this);
80
+ }
74
81
  }
75
82
 
76
83
  // load component's data and fill it
77
84
  // load any nested components recursively
78
85
  public async init(html: string, data?: LooseObject): Promise<void> {
79
86
 
80
- // extract data-atributes and encode non-encoded attributes
87
+ // extract data-attributes and encode non-encoded attributes
81
88
  this.initAttributesData();
82
89
 
83
- // create component container replacng the original tag name with a div
90
+ // create component container replacing the original tag name with a div
84
91
  // (or whatever is set as renderTagName on ComponentEntry)
85
92
  this.dom.tagName = this.entry?.renderTagName || 'div';
86
93
 
@@ -88,7 +95,7 @@ export class Component {
88
95
  this.dom.innerHTML = html;
89
96
 
90
97
 
91
- // re-apply attributes the orignal tag had
98
+ // re-apply attributes the original tag had
92
99
  // no need to encode values at this point
93
100
  // any non-encoded attributes got encoded earlier by initAttributesData
94
101
  this.setAttributes(this.attributesRaw, '', false);
@@ -110,7 +117,7 @@ export class Component {
110
117
  this.id = this.attributes.componentId;
111
118
  }
112
119
 
113
- // export RequestContext.data fields specified in Application.exporteRequestContextData
120
+ // export RequestContext.data fields specified in Application.exportedRequestContextData
114
121
  const exportedContextData = this.document.application.exportedRequestContextData.reduce((prev, field) => {
115
122
  if (! this.document.ctx) {return prev;}
116
123
  if (field in this.document.ctx.data) {
@@ -295,7 +302,7 @@ export class Component {
295
302
 
296
303
  // attributes can have a data prefix eg. number:data-num="3"
297
304
  // return unprefixed attribute name
298
- const attrNameUnprefixed = this.attributeUnpreffixed(attrNameRaw);
305
+ const attrNameUnprefixed = this.attributeUnprefixed(attrNameRaw);
299
306
 
300
307
  if (attrNameUnprefixed.indexOf('data-') === 0) {
301
308
  // only attributes starting with data- are stored to this.attributes
@@ -353,7 +360,7 @@ export class Component {
353
360
 
354
361
  // component attributes can have a data type prefix [prefix]:data-[name]="[val]"
355
362
  // returns the prefix
356
- private attributePreffix(attrName: string): string|null {
363
+ private attributePrefix(attrName: string): string|null {
357
364
  const index = attrName.indexOf(':');
358
365
  if (index < 0) {
359
366
  return null;
@@ -364,7 +371,7 @@ export class Component {
364
371
  // returns the user defined data type of given attribute
365
372
  // for example number:data-total returns 'number'
366
373
  private attributeDataType(attrName: string): 'string'|'number'|'object'|'boolean'|'any' {
367
- const prefix = this.attributePreffix(attrName);
374
+ const prefix = this.attributePrefix(attrName);
368
375
 
369
376
  if (
370
377
  prefix === 'string' ||
@@ -375,13 +382,13 @@ export class Component {
375
382
  return prefix;
376
383
  }
377
384
 
378
- // unrecognized attribute preffix
385
+ // unrecognized attribute prefix
379
386
  return 'any';
380
387
  }
381
388
 
382
389
  // removes the data-type prefix from given attribute name
383
390
  // for example number:data-total returns data-total
384
- private attributeUnpreffixed(attrName: string): string {
391
+ private attributeUnprefixed(attrName: string): string {
385
392
  const index = attrName.indexOf(':');
386
393
  if (index < 0) {
387
394
  return attrName;
@@ -9,7 +9,7 @@ import { attributeValueToString, randomString } from '../Util.js';
9
9
  import path from 'node:path';
10
10
  import { existsSync, readFileSync } from 'node:fs';
11
11
 
12
- export class Document extends Component {
12
+ export class Document extends Component<{'componentCreated': Component}> {
13
13
 
14
14
  head: DocumentHead;
15
15
  language = 'en';
package/tsconfig.json CHANGED
@@ -10,7 +10,7 @@
10
10
  "module": "ES2020",
11
11
  "target": "ES2021",
12
12
  "alwaysStrict": true,
13
- "allowSyntheticDefaultImports": true, // albe to do import { default as varname } from 'module'
13
+ "allowSyntheticDefaultImports": true, // able to do import { default as varname } from 'module'
14
14
  "declaration": true,
15
15
  "isolatedDeclarations": true,
16
16
  "noImplicitReturns": true,