structured-fw 0.8.0 → 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Config.ts +1 -2
- package/README.md +181 -8
- package/build/Config.js +1 -2
- package/build/app/routes/Test.js +1 -1
- package/build/system/Types.d.ts +5 -6
- package/build/system/client/ClientComponent.d.ts +1 -7
- package/build/system/server/Application.d.ts +1 -1
- package/build/system/server/Application.js +10 -8
- package/build/system/server/Component.js +5 -1
- package/build/system/server/Request.d.ts +3 -3
- package/build/system/server/Request.js +1 -0
- package/build/system/server/Session.js +1 -1
- package/index.ts +12 -1
- package/jsr.json +35 -0
- package/package.json +2 -3
- package/system/Types.ts +6 -7
- package/system/client/ClientComponent.ts +1 -11
- package/system/global.d.ts +12 -0
- package/system/server/Application.ts +10 -8
- package/system/server/Component.ts +6 -1
- package/system/server/Request.ts +7 -6
- package/system/server/Session.ts +1 -1
package/Config.ts
CHANGED
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
|
-
* [
|
|
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
|
|
@@ -98,7 +103,7 @@ new Application(config);
|
|
|
98
103
|
|
|
99
104
|
### Properties
|
|
100
105
|
- `cookies` - Instance of Cookies, allows you to set a cookie
|
|
101
|
-
- `session` - Instance of Session, utilities to manage sessions and data
|
|
106
|
+
- [`session`](#session) - Instance of Session, utilities to manage sessions and data
|
|
102
107
|
- `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
|
|
103
108
|
- `handlebars` - Instance of Handlebars (wrapper around Handlebars templating engine)
|
|
104
109
|
- `components` - Instance of Components, this is the components registry, you should never need to use this directly
|
|
@@ -106,12 +111,12 @@ new Application(config);
|
|
|
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
113
|
- `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
|
|
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
|
-
- `
|
|
114
|
-
- `
|
|
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
|
|
@@ -167,6 +172,39 @@ app.exportContextFields('user');
|
|
|
167
172
|
|
|
168
173
|
```
|
|
169
174
|
|
|
175
|
+
### 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 instace `Application.session`.
|
|
177
|
+
|
|
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.
|
|
179
|
+
|
|
180
|
+
**Configuration**\
|
|
181
|
+
`StructuredConfig`.`session`:
|
|
182
|
+
```
|
|
183
|
+
{
|
|
184
|
+
// cookie name for the session cookie
|
|
185
|
+
readonly cookieName: string,
|
|
186
|
+
|
|
187
|
+
// cookie stores the session key (a random string), keyLength determines it's length (longer key = more secure)
|
|
188
|
+
readonly keyLength: number,
|
|
189
|
+
|
|
190
|
+
// sessions expire after durationSeconds of no activity
|
|
191
|
+
readonly durationSeconds: number,
|
|
192
|
+
|
|
193
|
+
// session garbage collector runs every garbageCollectIntervalSeconds
|
|
194
|
+
// removing expired sessions from the memory
|
|
195
|
+
readonly garbageCollectIntervalSeconds: number
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
**Methods**
|
|
200
|
+
- `setValue(sessionId: string, key: string, value: any): void` - set a session value for given sessionId
|
|
201
|
+
- `getValue<T>(sessionId: string, key: string): T | null` - return a value for given `key` from session, if `key` is not set, returns `null`. It is a generic method so you can specify the expected return type
|
|
202
|
+
- `removeValue(sessionId: string, key: string): void` - remove value for given `key`
|
|
203
|
+
- `getClear<T>(sessionId: string, key: string): T | null` - return and clear value for given `key`
|
|
204
|
+
- `clear(sessionId: string): void` - clear all data for given `sessionId`
|
|
205
|
+
- `extract(sessionId: string, keys: Array<string|{ [keyInSession: string] : string }>): LooseObject` - extract given keys from session and return them as an object. Key in `keys` can be a string in which case the key will remain the same in returned object or it can be an object { keyInSession : keyInReturnedData } in which case key in returned data will be keyInReturnedData
|
|
206
|
+
|
|
207
|
+
|
|
170
208
|
## Route
|
|
171
209
|
Routes are the first thing that gets executed when your application receives a request. They are a mean for the developer to dictate what code gets executed depending on the URL. In addition to that, they allow capturing parts of the URL for use within the route.
|
|
172
210
|
|
|
@@ -199,7 +237,7 @@ Route file name has no effect on how the route (request handler) behaves, the on
|
|
|
199
237
|
### RequestContext
|
|
200
238
|
All request handlers receive a `RequestContext` as the first argument.
|
|
201
239
|
```
|
|
202
|
-
type RequestContext = {
|
|
240
|
+
type RequestContext<Body extends LooseObject | undefined = LooseObject> = {
|
|
203
241
|
request: IncomingMessage,
|
|
204
242
|
response: ServerResponse,
|
|
205
243
|
args: URIArguments,
|
|
@@ -208,7 +246,7 @@ type RequestContext = {
|
|
|
208
246
|
cookies: Record<string, string>,
|
|
209
247
|
|
|
210
248
|
// POSTed data, parsed to object
|
|
211
|
-
body?:
|
|
249
|
+
body?: LooseObject,
|
|
212
250
|
|
|
213
251
|
bodyRaw?: Buffer,
|
|
214
252
|
|
|
@@ -276,6 +314,21 @@ POST '/hello/(name)'
|
|
|
276
314
|
**RegExp as URLPatter**\
|
|
277
315
|
In some edge cases you may need more control of when a route is executed, in which case you can use a regular expression as URLPattern. If you use a RegExp, ctx.args will be `RegExpExecArray` so you can still capture data from the URL. This is very rarely needed because Structured router is versatile and covers almost all use cases.
|
|
278
316
|
|
|
317
|
+
> [!TIP]
|
|
318
|
+
> Since version 0.8.1 `Request`.`on` is a generic, accepting 0-2 generic arguments. First argument defines the request handler return type (response type) and defaults to any, second argument allows you to specify the expected (parsed) request body type, defaults to LooseObject.
|
|
319
|
+
> ```
|
|
320
|
+
> app.request.on<Document, {
|
|
321
|
+
> email: string,
|
|
322
|
+
> password: string,
|
|
323
|
+
> age: number
|
|
324
|
+
>}>('POST', '/users/create', asyc (ctx) => {
|
|
325
|
+
> ctx.body.email // string
|
|
326
|
+
> ctx.body.age // number
|
|
327
|
+
> const doc = new Document(app, 'User', ctx);
|
|
328
|
+
> return doc; // error if we return anything but Document
|
|
329
|
+
> });
|
|
330
|
+
> ```
|
|
331
|
+
|
|
279
332
|
## Document
|
|
280
333
|
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.
|
|
281
334
|
|
|
@@ -337,7 +390,7 @@ You can now run the app and if you open /hello/world in the browser you will see
|
|
|
337
390
|
`Hello, World!` - which came from your HelloWorld component.
|
|
338
391
|
|
|
339
392
|
That was the simplest possible example, let's make it more interesting.
|
|
340
|
-
Create a new file `/app/views/HelloWorld/HelloWorld.ts
|
|
393
|
+
Create a new file `/app/views/HelloWorld/HelloWorld.ts` (server side component code):
|
|
341
394
|
```
|
|
342
395
|
import { ComponentScaffold } from 'system/Types.js';
|
|
343
396
|
export default class HelloWorld implements ComponentScaffold {
|
|
@@ -450,6 +503,15 @@ which is now avaialble in `AnotherComponent` HTML, we assigned the received numb
|
|
|
450
503
|
|
|
451
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).
|
|
452
505
|
|
|
506
|
+
> [!NOTE]
|
|
507
|
+
> 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.
|
|
508
|
+
|
|
509
|
+
> [!IMPORTANT]
|
|
510
|
+
> Server side `getData` will receive the following arguments:
|
|
511
|
+
> - `data: LooseObject` any data passed in (either by attributes, ClientComponent.add or ClientComponent.redraw)
|
|
512
|
+
> - `ctx: RequestContext` - current `RequestContext`, you will often use this to access for example ctx.data (`RequestContextData`) or ctx.sessionId to interact with session
|
|
513
|
+
> - `app: Application` - your Application instance. You can use it to, for example, access the session in combination with ctx.sessionId
|
|
514
|
+
|
|
453
515
|
Let's create a client side code for `AnotherComponent` and export the `betterNumber` to it, create `/app/views/AnotherComponent/AnotherComponent.client.ts`:
|
|
454
516
|
```
|
|
455
517
|
import { InitializerFunction } from 'system/Types.js';
|
|
@@ -548,6 +610,117 @@ Methods:
|
|
|
548
610
|
- `arrayRef<T>(refName: string): Array<T>` - get an array of HTMLElement or ClientComponent that have attribute `array:ref="[refName]"`
|
|
549
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
|
|
550
612
|
|
|
613
|
+
## Good to know
|
|
614
|
+
- [Uing CSS frameworks](#css-frameworks)
|
|
615
|
+
- [Using JS runtimes other than Node.js](#runtimes)
|
|
616
|
+
- [Why not JSR](#jsr)
|
|
617
|
+
|
|
618
|
+
### CSS frameworks
|
|
619
|
+
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.
|
|
620
|
+
|
|
621
|
+
Your Tailwind configuration may look something like:
|
|
622
|
+
```
|
|
623
|
+
/** @type {import('tailwindcss').Config} */
|
|
624
|
+
module.exports = {
|
|
625
|
+
content: ["./app/views/**/*.html", "./app/views/**/*.hbs"],
|
|
626
|
+
...
|
|
627
|
+
}
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
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:\
|
|
631
|
+
`npx tailwindcss -i ./assets/css/src/style.css -o ./assets/css/dist.css`
|
|
632
|
+
|
|
633
|
+
**Including the output CSS**\
|
|
634
|
+
To include the output CSS in all pages, you can add the following to `index.ts`:
|
|
635
|
+
```
|
|
636
|
+
const app = new Application(config);
|
|
637
|
+
|
|
638
|
+
app.on('documentCreated', (doc) => {
|
|
639
|
+
doc.head.addCSS('/assets/css/dist.css');
|
|
640
|
+
});
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
### Runtimes
|
|
644
|
+
Structured is tested with Node.js and Deno. Other runtimes would likely work as well.
|
|
645
|
+
|
|
646
|
+
To use Structured with Deno, you can:
|
|
647
|
+
```
|
|
648
|
+
cd /path/to/project
|
|
649
|
+
deno init
|
|
650
|
+
deno add npm:structured-fw
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
With Deno, we can't use the cli to create the boilerplate, so you will need to create it yourself.
|
|
654
|
+
```
|
|
655
|
+
mkdir app
|
|
656
|
+
mkdir app/views
|
|
657
|
+
mkdir app/routes
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
Create `Config.ts`:
|
|
661
|
+
```
|
|
662
|
+
import { StructuredConfig } from "structured-fw/Types";
|
|
663
|
+
|
|
664
|
+
export const config: StructuredConfig = {
|
|
665
|
+
// Application.importEnv will load all env variables starting with [envPrefix]_
|
|
666
|
+
envPrefix: 'STRUCTURED',
|
|
667
|
+
|
|
668
|
+
// whether to call Application.init when an instance of Application is created
|
|
669
|
+
autoInit: true,
|
|
670
|
+
|
|
671
|
+
url: {
|
|
672
|
+
removeTrailingSlash: true,
|
|
673
|
+
|
|
674
|
+
// if you want to enable individual component rendering set this to URI (string)
|
|
675
|
+
// to disable component rendering set it to false
|
|
676
|
+
// setting this to false disallows the use of ClientComponent.redraw and ClientComponent.add
|
|
677
|
+
componentRender: '/componentRender',
|
|
678
|
+
|
|
679
|
+
// function that receives the requested URL and returns boolean, if true, treat as static asset
|
|
680
|
+
// if there is a registered request handler that matches this same URL, it takes precedence over this
|
|
681
|
+
isAsset: function(uri: string) {
|
|
682
|
+
return uri.indexOf('/assets/') === 0;
|
|
683
|
+
}
|
|
684
|
+
},
|
|
685
|
+
routes: {
|
|
686
|
+
path: '/app/routes'
|
|
687
|
+
},
|
|
688
|
+
components : {
|
|
689
|
+
// relative to index.ts
|
|
690
|
+
path: '/app/views',
|
|
691
|
+
|
|
692
|
+
componentNameAttribute: 'structured-component'
|
|
693
|
+
},
|
|
694
|
+
session: {
|
|
695
|
+
cookieName: 'session',
|
|
696
|
+
keyLength: 24,
|
|
697
|
+
durationSeconds: 60 * 60,
|
|
698
|
+
garbageCollectIntervalSeconds: 60
|
|
699
|
+
},
|
|
700
|
+
http: {
|
|
701
|
+
port: 9191,
|
|
702
|
+
host: '0.0.0.0',
|
|
703
|
+
// used by Document.push, can be preload or preconnect
|
|
704
|
+
linkHeaderRel : 'preload'
|
|
705
|
+
},
|
|
706
|
+
runtime: 'Deno'
|
|
707
|
+
}
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
Import `Config.ts` in `main.ts` and create the Application instance:
|
|
711
|
+
```
|
|
712
|
+
import { Application } from 'structured-fw/Application';
|
|
713
|
+
import { config } from './Config.ts';
|
|
714
|
+
|
|
715
|
+
new Application(config);
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
Run application using `deno main.ts`
|
|
719
|
+
|
|
720
|
+
### JSR
|
|
721
|
+
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).\
|
|
722
|
+
This does not stop the framework from working with Deno, but for the time being, we have to stick with good old npm.
|
|
723
|
+
|
|
551
724
|
## Why Structured
|
|
552
725
|
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.
|
|
553
726
|
\
|
package/build/Config.js
CHANGED
package/build/app/routes/Test.js
CHANGED
|
@@ -4,7 +4,7 @@ export default function (app) {
|
|
|
4
4
|
app.request.on('GET', '/test/form', async (ctx) => {
|
|
5
5
|
const doc = new Document(app, 'Form test', ctx);
|
|
6
6
|
await doc.loadComponent('FormTestNested', { test: 3 });
|
|
7
|
-
|
|
7
|
+
return doc;
|
|
8
8
|
});
|
|
9
9
|
app.request.on('POST', '/test/form', async (ctx) => {
|
|
10
10
|
console.log(JSON.stringify(ctx.body, undefined, 4));
|
package/build/system/Types.d.ts
CHANGED
|
@@ -24,7 +24,6 @@ export type StructuredConfig = {
|
|
|
24
24
|
readonly keyLength: number;
|
|
25
25
|
readonly durationSeconds: number;
|
|
26
26
|
readonly garbageCollectIntervalSeconds: number;
|
|
27
|
-
readonly garbageCollectAfterSeconds: number;
|
|
28
27
|
};
|
|
29
28
|
http: {
|
|
30
29
|
host?: string;
|
|
@@ -38,21 +37,21 @@ export type StructuredClientConfig = {
|
|
|
38
37
|
componentNameAttribute: string;
|
|
39
38
|
};
|
|
40
39
|
export type RequestMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
41
|
-
export type RequestCallback = (ctx: RequestContext) => Promise<
|
|
40
|
+
export type RequestCallback<R extends any, Body extends LooseObject | undefined> = (ctx: RequestContext<Body>) => Promise<R>;
|
|
42
41
|
export type RequestHandler = {
|
|
43
42
|
match: Array<URISegmentPattern> | RegExp;
|
|
44
43
|
methods: Array<RequestMethod>;
|
|
45
|
-
callback: RequestCallback
|
|
44
|
+
callback: RequestCallback<any, LooseObject | undefined>;
|
|
46
45
|
scope: any;
|
|
47
46
|
staticAsset: boolean;
|
|
48
47
|
};
|
|
49
|
-
export type RequestContext = {
|
|
48
|
+
export type RequestContext<Body extends LooseObject | undefined = LooseObject> = {
|
|
50
49
|
request: IncomingMessage;
|
|
51
50
|
response: ServerResponse;
|
|
52
51
|
args: URIArguments;
|
|
53
52
|
handler: null | RequestHandler;
|
|
54
53
|
cookies: Record<string, string>;
|
|
55
|
-
body
|
|
54
|
+
body: Body;
|
|
56
55
|
bodyRaw?: Buffer;
|
|
57
56
|
files?: Record<string, RequestBodyRecordValue>;
|
|
58
57
|
data: RequestContextData;
|
|
@@ -128,7 +127,7 @@ export interface ComponentScaffold {
|
|
|
128
127
|
[key: string]: any;
|
|
129
128
|
}
|
|
130
129
|
export type LooseObject = Record<string, any>;
|
|
131
|
-
export type ApplicationEvents = 'serverStarted' | 'beforeRequestHandler' | 'afterRequestHandler' | 'beforeRoutes' | 'afterRoutes' | '
|
|
130
|
+
export type ApplicationEvents = 'serverStarted' | 'beforeRequestHandler' | 'afterRequestHandler' | 'beforeRoutes' | 'afterRoutes' | 'beforeComponentsLoad' | 'afterComponentsLoaded' | 'componentCreated' | 'documentCreated' | 'beforeAssetAccess' | 'afterAssetAccess' | 'pageNotFound';
|
|
132
131
|
export type SessionEntry = {
|
|
133
132
|
sessionId: string;
|
|
134
133
|
lastRequest: number;
|
|
@@ -1,14 +1,8 @@
|
|
|
1
|
-
import {
|
|
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
5
|
import { EventEmitter } from './EventEmitter.js';
|
|
6
|
-
declare global {
|
|
7
|
-
interface Window {
|
|
8
|
-
initializers: Record<string, InitializerFunction | string>;
|
|
9
|
-
structuredClientConfig: StructuredClientConfig;
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
6
|
export declare class ClientComponent extends EventEmitter {
|
|
13
7
|
readonly name: string;
|
|
14
8
|
children: Array<ClientComponent>;
|
|
@@ -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('
|
|
38
|
+
await this.emit('beforeComponentsLoad');
|
|
39
39
|
this.components.loadComponents();
|
|
40
|
-
await this.emit('
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|
|
@@ -8,6 +8,7 @@ export class Component {
|
|
|
8
8
|
this.attributesRaw = {};
|
|
9
9
|
this.attributes = {};
|
|
10
10
|
this.data = {};
|
|
11
|
+
const isDocument = this instanceof Document;
|
|
11
12
|
this.name = name;
|
|
12
13
|
if (name === 'root') {
|
|
13
14
|
this.dom = new DOMFragment();
|
|
@@ -21,7 +22,7 @@ export class Component {
|
|
|
21
22
|
}
|
|
22
23
|
this.isRoot = false;
|
|
23
24
|
}
|
|
24
|
-
if (
|
|
25
|
+
if (isDocument) {
|
|
25
26
|
this.document = this;
|
|
26
27
|
}
|
|
27
28
|
else {
|
|
@@ -42,6 +43,9 @@ export class Component {
|
|
|
42
43
|
else {
|
|
43
44
|
this.entry = null;
|
|
44
45
|
}
|
|
46
|
+
if (!isDocument) {
|
|
47
|
+
this.document.application.emit('componentCreated', this);
|
|
48
|
+
}
|
|
45
49
|
}
|
|
46
50
|
async init(html, data) {
|
|
47
51
|
this.initAttributesData();
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
-
import { PostedDataDecoded, RequestBodyFile, RequestCallback, RequestMethod } from "../Types.js";
|
|
2
|
+
import { LooseObject, PostedDataDecoded, RequestBodyFile, RequestCallback, RequestMethod } from "../Types.js";
|
|
3
3
|
import { Application } from "./Application.js";
|
|
4
4
|
export declare class Request {
|
|
5
5
|
private app;
|
|
6
6
|
constructor(app: Application);
|
|
7
|
-
pageNotFoundCallback: RequestCallback
|
|
7
|
+
pageNotFoundCallback: RequestCallback<void, PostedDataDecoded | undefined>;
|
|
8
8
|
private readonly handlers;
|
|
9
|
-
on(methods: RequestMethod | Array<RequestMethod>, pattern: string | RegExp | Array<string | RegExp>, callback: RequestCallback, scope?: any, isStaticAsset?: boolean): void;
|
|
9
|
+
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;
|
|
10
10
|
private getHandler;
|
|
11
11
|
handle(request: IncomingMessage, response: ServerResponse): Promise<void>;
|
|
12
12
|
private extractURIArguments;
|
|
@@ -43,7 +43,7 @@ export class Session {
|
|
|
43
43
|
}
|
|
44
44
|
garbageCollect() {
|
|
45
45
|
const time = new Date().getTime();
|
|
46
|
-
const sessDurationMilliseconds = this.application.config.session.
|
|
46
|
+
const sessDurationMilliseconds = this.application.config.session.durationSeconds * 1000;
|
|
47
47
|
for (const sessionId in this.sessions) {
|
|
48
48
|
const sess = this.sessions[sessionId];
|
|
49
49
|
if (time - sess.lastRequest > sessDurationMilliseconds) {
|
package/index.ts
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
import { Application } from "structured-fw/Application";
|
|
2
2
|
import { config } from './Config.js';
|
|
3
3
|
|
|
4
|
-
new Application(config);
|
|
4
|
+
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
|
+
// })
|
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,13 +14,12 @@
|
|
|
14
14
|
"license": "MIT",
|
|
15
15
|
"type": "module",
|
|
16
16
|
"main": "build/index",
|
|
17
|
-
"version": "0.8.
|
|
17
|
+
"version": "0.8.3",
|
|
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"
|
|
23
|
-
"postinstall": "echo \"Run npx structured init\" to create application boilerplate"
|
|
22
|
+
"prepublish": "tsc"
|
|
24
23
|
},
|
|
25
24
|
"bin": {
|
|
26
25
|
"structured": "./build/system/bin/structured.js"
|
package/system/Types.ts
CHANGED
|
@@ -24,8 +24,7 @@ export type StructuredConfig = {
|
|
|
24
24
|
readonly cookieName: string,
|
|
25
25
|
readonly keyLength: number,
|
|
26
26
|
readonly durationSeconds: number,
|
|
27
|
-
readonly garbageCollectIntervalSeconds: number
|
|
28
|
-
readonly garbageCollectAfterSeconds: number
|
|
27
|
+
readonly garbageCollectIntervalSeconds: number
|
|
29
28
|
},
|
|
30
29
|
http: {
|
|
31
30
|
host?: string,
|
|
@@ -42,19 +41,19 @@ export type StructuredClientConfig = {
|
|
|
42
41
|
|
|
43
42
|
export type RequestMethod = 'GET'|'POST'|'PUT'|'PATCH'|'DELETE';
|
|
44
43
|
|
|
45
|
-
export type RequestCallback = (ctx: RequestContext) => Promise<
|
|
44
|
+
export type RequestCallback<R extends any, Body extends LooseObject | undefined> = (ctx: RequestContext<Body>) => Promise<R>
|
|
46
45
|
|
|
47
46
|
export type RequestHandler = {
|
|
48
47
|
match: Array<URISegmentPattern>|RegExp,
|
|
49
48
|
methods: Array<RequestMethod>,
|
|
50
|
-
callback: RequestCallback,
|
|
49
|
+
callback: RequestCallback<any, LooseObject | undefined>,
|
|
51
50
|
scope: any,
|
|
52
51
|
|
|
53
52
|
// if true, no (before/after)RequestHandler event is emitted, body and GET args not parsed
|
|
54
53
|
staticAsset: boolean
|
|
55
54
|
}
|
|
56
55
|
|
|
57
|
-
export type RequestContext = {
|
|
56
|
+
export type RequestContext<Body extends LooseObject | undefined = LooseObject> = {
|
|
58
57
|
request: IncomingMessage,
|
|
59
58
|
response: ServerResponse,
|
|
60
59
|
args: URIArguments,
|
|
@@ -63,7 +62,7 @@ export type RequestContext = {
|
|
|
63
62
|
cookies: Record<string, string>,
|
|
64
63
|
|
|
65
64
|
// POSTed data, parsed to object
|
|
66
|
-
body
|
|
65
|
+
body: Body,
|
|
67
66
|
|
|
68
67
|
bodyRaw?: Buffer,
|
|
69
68
|
|
|
@@ -178,7 +177,7 @@ export interface ComponentScaffold {
|
|
|
178
177
|
|
|
179
178
|
export type LooseObject = Record<string, any>
|
|
180
179
|
|
|
181
|
-
export type ApplicationEvents = 'serverStarted'|'beforeRequestHandler'|'afterRequestHandler'|'beforeRoutes'|'afterRoutes'|'
|
|
180
|
+
export type ApplicationEvents = 'serverStarted'|'beforeRequestHandler'|'afterRequestHandler'|'beforeRoutes'|'afterRoutes'|'beforeComponentsLoad'|'afterComponentsLoaded'|'componentCreated'|'documentCreated'|'beforeAssetAccess'|'afterAssetAccess'|'pageNotFound';
|
|
182
181
|
|
|
183
182
|
export type SessionEntry = {
|
|
184
183
|
sessionId : string,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ClientComponentTransition, ClientComponentTransitions, InitializerFunction, LooseObject, StoreChangeCallback
|
|
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';
|
|
@@ -6,16 +6,6 @@ import { Net } from './Net.js';
|
|
|
6
6
|
import { NetRequest } from './NetRequest.js';
|
|
7
7
|
import { EventEmitter } from './EventEmitter.js';
|
|
8
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
|
-
}
|
|
18
|
-
|
|
19
9
|
export class ClientComponent extends EventEmitter {
|
|
20
10
|
readonly name: string;
|
|
21
11
|
children: Array<ClientComponent> = [];
|
|
@@ -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('
|
|
62
|
+
await this.emit('beforeComponentsLoad');
|
|
63
63
|
this.components.loadComponents();
|
|
64
|
-
await this.emit('
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
@@ -29,6 +29,7 @@ export class Component {
|
|
|
29
29
|
isRoot: boolean;
|
|
30
30
|
|
|
31
31
|
constructor(name: string, node?: DOMNode, parent?: Document|Component, autoInit: boolean = true) {
|
|
32
|
+
const isDocument = this instanceof Document;
|
|
32
33
|
this.name = name;
|
|
33
34
|
|
|
34
35
|
if (name === 'root') {
|
|
@@ -43,7 +44,7 @@ export class Component {
|
|
|
43
44
|
this.isRoot = false;
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
if (
|
|
47
|
+
if (isDocument) {
|
|
47
48
|
// this will only happen if an instance of Document, as it extends component
|
|
48
49
|
this.document = this;
|
|
49
50
|
} else {
|
|
@@ -71,6 +72,10 @@ export class Component {
|
|
|
71
72
|
} else {
|
|
72
73
|
this.entry = null;
|
|
73
74
|
}
|
|
75
|
+
|
|
76
|
+
if (! isDocument) {
|
|
77
|
+
this.document.application.emit('componentCreated', this);
|
|
78
|
+
}
|
|
74
79
|
}
|
|
75
80
|
|
|
76
81
|
// load component's data and fill it
|
package/system/server/Request.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
-
import { PostedDataDecoded, RequestBodyFile, RequestCallback, RequestContext, RequestHandler, RequestMethod, URIArguments, URISegmentPattern } from "../Types.js";
|
|
2
|
+
import { LooseObject, PostedDataDecoded, RequestBodyFile, RequestCallback, RequestContext, RequestHandler, RequestMethod, URIArguments, URISegmentPattern } from "../Types.js";
|
|
3
3
|
import { mergeDeep, queryStringDecode, queryStringDecodedSetValue } from "../Util.js";
|
|
4
4
|
import { Application } from "./Application.js";
|
|
5
5
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
@@ -15,7 +15,7 @@ export class Request {
|
|
|
15
15
|
this.app = app;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
pageNotFoundCallback: RequestCallback = async ({ response }) => {
|
|
18
|
+
pageNotFoundCallback: RequestCallback<void, PostedDataDecoded | undefined> = async ({ response }) => {
|
|
19
19
|
response.statusCode = 404;
|
|
20
20
|
response.write('Page not found');
|
|
21
21
|
response.end();
|
|
@@ -30,10 +30,10 @@ export class Request {
|
|
|
30
30
|
// callback.this will be the scope if scope is provided, otherwise scope is the Application instance
|
|
31
31
|
// if pattern is given as array, one request handler will be created for each element of the array
|
|
32
32
|
// if isStaticAsset = true, (before/after)RequestHandler event not emitted, body and GET args not parsed
|
|
33
|
-
public on(
|
|
33
|
+
public on<R extends any, Body extends LooseObject | undefined = LooseObject>(
|
|
34
34
|
methods: RequestMethod|Array<RequestMethod>,
|
|
35
35
|
pattern: string|RegExp|Array<string|RegExp>,
|
|
36
|
-
callback: RequestCallback,
|
|
36
|
+
callback: RequestCallback<R, Body>,
|
|
37
37
|
scope?: any,
|
|
38
38
|
isStaticAsset: boolean = false
|
|
39
39
|
): void {
|
|
@@ -163,7 +163,7 @@ export class Request {
|
|
|
163
163
|
// get the best matching request handler
|
|
164
164
|
const handler = this.getHandler(uri, requestMethod);
|
|
165
165
|
|
|
166
|
-
const context: RequestContext = {
|
|
166
|
+
const context: RequestContext<LooseObject | undefined> = {
|
|
167
167
|
request,
|
|
168
168
|
response,
|
|
169
169
|
handler,
|
|
@@ -173,6 +173,7 @@ export class Request {
|
|
|
173
173
|
// potentially falsely declared as RequestContextData
|
|
174
174
|
// user will fill this out, usually on beforeRequestHandler
|
|
175
175
|
data: {} as RequestContextData,
|
|
176
|
+
body: undefined,
|
|
176
177
|
getArgs,
|
|
177
178
|
cookies: this.app.cookies.parse(request),
|
|
178
179
|
isAjax : request.headers['x-requested-with'] == 'xmlhttprequest',
|
|
@@ -347,7 +348,7 @@ export class Request {
|
|
|
347
348
|
// parse raw request body
|
|
348
349
|
// if there is a parser for received Content-Type
|
|
349
350
|
// then ctx.body is populated with data: URIArgs
|
|
350
|
-
private async parseBody(ctx:
|
|
351
|
+
private async parseBody(ctx: RequestContext<LooseObject | undefined>): Promise<void> {
|
|
351
352
|
if (ctx.request.headers['content-type']) {
|
|
352
353
|
|
|
353
354
|
ctx.bodyRaw = await this.dataRaw(ctx.request);
|
package/system/server/Session.ts
CHANGED
|
@@ -73,7 +73,7 @@ export class Session {
|
|
|
73
73
|
// remove expired session entries
|
|
74
74
|
private garbageCollect(): void {
|
|
75
75
|
const time = new Date().getTime();
|
|
76
|
-
const sessDurationMilliseconds = this.application.config.session.
|
|
76
|
+
const sessDurationMilliseconds = this.application.config.session.durationSeconds * 1000;
|
|
77
77
|
|
|
78
78
|
for (const sessionId in this.sessions) {
|
|
79
79
|
const sess = this.sessions[sessionId];
|