@flancer32/teq-web 0.1.0 → 0.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.
@@ -30,7 +30,7 @@ jobs:
30
30
  needs: build
31
31
  runs-on: ubuntu-latest
32
32
  steps:
33
- # Checkout repository code again for separate job
33
+ # Checkout repository code again for a separate job
34
34
  - uses: actions/checkout@v4
35
35
 
36
36
  # Setup Node.js and configure npm registry
package/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ ## [0.2.0] - 2025-06-21
4
+
5
+ ### Added
6
+ - Integration test covering NPM handler with the built-in server.
7
+ - Built-in NPM handler for serving files from `node_modules`.
8
+ - JSDoc examples for NPM handler initialization.
9
+
10
+ ## [0.1.0] - 2025-06-11
11
+
12
+ ### Added
13
+ - Initial release of `@flancer32/teq-web`, a TeqFW plugin for centralized HTTP(S) request handling.
14
+ - Dispatcher with three-stage lifecycle: `pre`, `process`, and `post`, each with isolated execution logic.
15
+ - Middleware registration with support for execution order via `before`/`after` dependencies.
16
+ - Support for custom adapters to integrate with various web servers (e.g., Express, Fastify, Node.js `http`).
17
+ - Basic Node.js HTTP server implementation for standalone use cases.
18
+ - Unified interfaces for registering request handlers from other teq-plugins.
19
+ - Modular architecture compatible with Tequila Framework philosophy.
20
+
package/README.md CHANGED
@@ -81,12 +81,20 @@ resolver.addNamespaceRoot('Fl32_Web_', './node_modules/@flancer32/teq-web/src');
81
81
 
82
82
  // Get and configure built-in handlers
83
83
  const logHandler = await container.get('Fl32_Web_Back_Handler_Pre_Log$');
84
+ const npmHandler = await container.get('Fl32_Web_Back_Handler_Npm$');
84
85
  const staticHandler = await container.get('Fl32_Web_Back_Handler_Static$');
86
+ await npmHandler.init({
87
+ allow: {
88
+ vue: ['dist/vue.global.prod.js'],
89
+ '@teqfw/di': ['src/Container.js'],
90
+ }
91
+ });
85
92
  await staticHandler.init({rootPath: webRoot});
86
93
 
87
94
  // Register handlers
88
95
  const dispatcher = await container.get('Fl32_Web_Back_Dispatcher$');
89
96
  dispatcher.addHandler(logHandler);
97
+ dispatcher.addHandler(npmHandler);
90
98
  dispatcher.addHandler(staticHandler);
91
99
 
92
100
  // Create and start the server
@@ -105,10 +113,35 @@ await server.start({
105
113
  This will start an HTTPS server on port `3443` with:
106
114
 
107
115
  * `Fl32_Web_Back_Handler_Pre_Log` logging each request method and URL;
116
+ * `Fl32_Web_Back_Handler_Npm` serving allowed files from `node_modules`;
108
117
  * `Fl32_Web_Back_Handler_Static` serving files from the `/web` folder.
109
118
 
110
119
  ---
111
120
 
121
+ ### Using with Express or Fastify
122
+
123
+ The dispatcher can be connected to external web frameworks instead of the built-in server.
124
+
125
+ ```js
126
+ // Express
127
+ const app = Express();
128
+ app.use(async (req, res) => {
129
+ await dispatcher.onEventRequest(req, res);
130
+ });
131
+ app.listen(3000);
132
+
133
+ // Fastify
134
+ const fastify = Fastify();
135
+ fastify.all('*', async (request, reply) => {
136
+ const req = request.raw;
137
+ const res = reply.raw;
138
+ await dispatcher.onEventRequest(req, res);
139
+ });
140
+ await fastify.listen({port: 3000});
141
+ ```
142
+
143
+ ---
144
+
112
145
  ## Installation
113
146
 
114
147
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flancer32/teq-web",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Node.js web plugin supporting Express and Fastify integration, with built-in server for standalone operation",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -34,7 +34,9 @@
34
34
  "devDependencies": {
35
35
  "@eslint/js": "^9.25.0",
36
36
  "eslint": "^9.25.0",
37
- "eslint-plugin-jsdoc": "^50.6.8"
37
+ "eslint-plugin-jsdoc": "^50.6.8",
38
+ "express": "^4.18.2",
39
+ "fastify": "^4.27.2"
38
40
  },
39
41
  "engines": {
40
42
  "node": ">=20"
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Serves whitelisted files from ./node_modules directory.
3
+ * @implements Fl32_Web_Back_Api_Handler
4
+ */
5
+ export default class Fl32_Web_Back_Handler_Npm {
6
+ /* eslint-disable jsdoc/require-param-description,jsdoc/check-param-names */
7
+ /**
8
+ * @param {typeof import('node:fs')} fs
9
+ * @param {typeof import('node:http2')} http2
10
+ * @param {typeof import('node:path')} path
11
+ * @param {Fl32_Web_Back_Logger} logger
12
+ * @param {Fl32_Web_Back_Helper_Mime} helpMime
13
+ * @param {Fl32_Web_Back_Helper_Respond} respond
14
+ * @param {Fl32_Web_Back_Dto_Handler_Info} dtoInfo
15
+ * @param {typeof Fl32_Web_Back_Enum_Stage} STAGE
16
+ */
17
+ constructor(
18
+ {
19
+ 'node:fs': fs,
20
+ 'node:http2': http2,
21
+ 'node:path': path,
22
+ Fl32_Web_Back_Logger$: logger,
23
+ Fl32_Web_Back_Helper_Mime$: helpMime,
24
+ Fl32_Web_Back_Helper_Respond$: respond,
25
+ Fl32_Web_Back_Dto_Handler_Info$: dtoInfo,
26
+ Fl32_Web_Back_Enum_Stage$: STAGE,
27
+ }
28
+ ) {
29
+ /* eslint-enable jsdoc/check-param-names */
30
+ // VARS
31
+ const {promises: fsp} = fs;
32
+ const {constants: H2} = http2;
33
+ const {
34
+ HTTP2_HEADER_CONTENT_LENGTH,
35
+ HTTP2_HEADER_CONTENT_TYPE,
36
+ HTTP2_HEADER_LAST_MODIFIED,
37
+ HTTP_STATUS_OK,
38
+ } = H2;
39
+ const _root = path.resolve('node_modules');
40
+
41
+ /** @type {Fl32_Web_Back_Dto_Handler_Info.Dto} */
42
+ const _info = dtoInfo.create();
43
+ _info.name = this.constructor.name;
44
+ _info.stage = STAGE.PROCESS;
45
+ Object.freeze(_info);
46
+
47
+ /** @type {{[key: string]: string[]}} */
48
+ let _allow = {};
49
+
50
+ // MAIN
51
+ /**
52
+ * Handles request to serve allowed files from node_modules.
53
+ *
54
+ * @param {module:http.IncomingMessage|module:http2.Http2ServerRequest} req
55
+ * @param {module:http.ServerResponse|module:http2.Http2ServerResponse} res
56
+ * @returns {Promise<boolean>}
57
+ */
58
+ this.handle = async function (req, res) {
59
+ if (!respond.isWritable(res)) return false;
60
+
61
+ const urlPath = decodeURIComponent(req.url.split('?')[0]);
62
+ const prefix = '/node_modules/';
63
+ if (!urlPath.startsWith(prefix)) return false;
64
+
65
+ const rel = urlPath.slice(prefix.length);
66
+ if (rel.includes('..') || path.isAbsolute(rel)) {
67
+ logger.warn(`NPM static access denied: ${rel}`);
68
+ return false;
69
+ }
70
+
71
+ let pkg; let subPath;
72
+ for (const key of Object.keys(_allow)) {
73
+ if (rel === key || rel.startsWith(`${key}/`)) {
74
+ pkg = key;
75
+ subPath = rel.slice(key.length);
76
+ if (subPath.startsWith('/')) subPath = subPath.slice(1);
77
+ break;
78
+ }
79
+ }
80
+ if (!pkg) return false;
81
+
82
+ const rules = _allow[pkg] || [];
83
+ let allowed = false;
84
+ if (rules.includes('.')) {
85
+ allowed = true;
86
+ } else {
87
+ for (const p of rules) {
88
+ if (subPath === p || subPath.startsWith(`${p}/`)) {
89
+ allowed = true;
90
+ break;
91
+ }
92
+ }
93
+ }
94
+
95
+ if (!allowed) {
96
+ logger.warn(`NPM static access denied: ${rel}`);
97
+ return false;
98
+ }
99
+
100
+ const fsPath = path.resolve(_root, rel);
101
+ if (!fsPath.startsWith(_root)) {
102
+ logger.warn(`NPM static access denied: ${rel}`);
103
+ return false;
104
+ }
105
+
106
+ let stat;
107
+ try {
108
+ stat = await fsp.stat(fsPath);
109
+ } catch {
110
+ logger.warn(`NPM static file not found: ${rel}`);
111
+ return false;
112
+ }
113
+ if (!stat.isFile()) {
114
+ logger.warn(`NPM static file not found: ${rel}`);
115
+ return false;
116
+ }
117
+
118
+ const stream = fs.createReadStream(fsPath);
119
+ const ext = path.extname(fsPath).toLowerCase();
120
+ const headers = {
121
+ [HTTP2_HEADER_CONTENT_LENGTH]: stat.size,
122
+ [HTTP2_HEADER_CONTENT_TYPE]: helpMime.getByExt(ext),
123
+ [HTTP2_HEADER_LAST_MODIFIED]: stat.mtime.toUTCString(),
124
+ };
125
+ res.writeHead(HTTP_STATUS_OK, headers);
126
+ stream.pipe(res);
127
+ return true;
128
+ };
129
+
130
+ /**
131
+ * Initialize handler with allow list.
132
+ *
133
+ * @param {object} params
134
+ * @param {{[key: string]: string[]}} params.allow - Map of packages to paths
135
+ * that can be served from `node_modules`.
136
+ * @returns {Promise<void>}
137
+ *
138
+ * @example
139
+ * // Allow a single file from a package
140
+ * await npmHandler.init({
141
+ * allow: {
142
+ * vue: ['dist/vue.global.prod.js'],
143
+ * },
144
+ * });
145
+ *
146
+ * @example
147
+ * // Allow all files from a subfolder
148
+ * await npmHandler.init({
149
+ * allow: {
150
+ * '@teqfw/di/src': ['.'],
151
+ * },
152
+ * });
153
+ */
154
+ this.init = async function ({allow}) {
155
+ _allow = allow || {};
156
+ };
157
+
158
+ /** @returns {Fl32_Web_Back_Dto_Handler_Info.Dto} */
159
+ this.getRegistrationInfo = () => _info;
160
+ }
161
+ }
@@ -0,0 +1,66 @@
1
+ import {describe, it} from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import http from 'node:http';
4
+ import {buildTestContainer} from '../unit/common.js';
5
+ import Express from 'express';
6
+ import Fastify from 'fastify';
7
+
8
+ async function startExpress(port, dispatcher) {
9
+ const app = Express();
10
+ app.use(async (req, res) => {
11
+ await dispatcher.onEventRequest(req, res);
12
+ });
13
+ const server = await new Promise((resolve, reject) => {
14
+ const srv = app.listen(port, err => err ? reject(err) : resolve(srv));
15
+ });
16
+ return server;
17
+ }
18
+
19
+ async function startFastify(port, dispatcher) {
20
+ const fastify = Fastify();
21
+ fastify.all('*', async (request, reply) => {
22
+ const req = request.raw;
23
+ const res = reply.raw;
24
+ await dispatcher.onEventRequest(req, res);
25
+ });
26
+ await fastify.listen({port});
27
+ return fastify;
28
+ }
29
+
30
+ describe('Dispatcher integration with external servers', () => {
31
+ it('should respond via express', async () => {
32
+ const container = buildTestContainer();
33
+ const dispatcher = await container.get('Fl32_Web_Back_Dispatcher$');
34
+ const port = 3054;
35
+ const server = await startExpress(port, dispatcher);
36
+
37
+ const status = await new Promise((resolve, reject) => {
38
+ http.get(`http://localhost:${port}`, res => {
39
+ const {statusCode} = res;
40
+ res.resume();
41
+ res.on('end', () => resolve(statusCode));
42
+ }).on('error', reject);
43
+ });
44
+
45
+ assert.strictEqual(status, 404);
46
+ await new Promise(resolve => server.close(resolve));
47
+ });
48
+
49
+ it('should respond via fastify', async () => {
50
+ const container = buildTestContainer();
51
+ const dispatcher = await container.get('Fl32_Web_Back_Dispatcher$');
52
+ const port = 3055;
53
+ const fastify = await startFastify(port, dispatcher);
54
+
55
+ const status = await new Promise((resolve, reject) => {
56
+ http.get(`http://localhost:${port}`, res => {
57
+ const {statusCode} = res;
58
+ res.resume();
59
+ res.on('end', () => resolve(statusCode));
60
+ }).on('error', reject);
61
+ });
62
+
63
+ assert.strictEqual(status, 404);
64
+ await fastify.close();
65
+ });
66
+ });
@@ -11,7 +11,7 @@ async function waitListening(server) {
11
11
  }
12
12
  }
13
13
 
14
- describe('Fl32_Web_Back_Server (real)', () => {
14
+ describe('Fl32_Web_Back_Server', () => {
15
15
  it('should start and respond in HTTP/1 mode', async () => {
16
16
  const container = buildTestContainer();
17
17
  const server = await container.get('Fl32_Web_Back_Server$');
@@ -103,3 +103,48 @@ describe('Fl32_Web_Back_Server (real)', () => {
103
103
  assert.strictEqual(server.getInstance(), undefined);
104
104
  });
105
105
  });
106
+
107
+ describe('Fl32_Web_Back_Api_Handler', () => {
108
+ it('should serve allowed NPM file', async () => {
109
+ const container = buildTestContainer();
110
+ const dispatcher = await container.get('Fl32_Web_Back_Dispatcher$');
111
+ const handler = await container.get('Fl32_Web_Back_Handler_Npm$');
112
+ await handler.init({
113
+ allow: {
114
+ '@teqfw/di/src': ['.'],
115
+ },
116
+ });
117
+ dispatcher.addHandler(handler);
118
+ dispatcher.orderHandlers();
119
+
120
+ const server = await container.get('Fl32_Web_Back_Server$');
121
+ const SERVER_TYPE = await container.get('Fl32_Web_Back_Enum_Server_Type$');
122
+ const Config = await container.get('Fl32_Web_Back_Server_Config$');
123
+ const http = await container.get('node:http');
124
+
125
+ const cfg = Config.create({ port: 3056, type: SERVER_TYPE.HTTP });
126
+ await server.start(cfg);
127
+ await waitListening(server);
128
+
129
+ const result = await new Promise((resolve, reject) => {
130
+ const req = http.get({
131
+ hostname: 'localhost',
132
+ port: cfg.port,
133
+ path: '/node_modules/@teqfw/di/src/Api/Container/Parser/Chunk.js',
134
+ }, res => {
135
+ const chunks = [];
136
+ res.on('data', ch => chunks.push(ch));
137
+ res.on('end', () => {
138
+ resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString() });
139
+ });
140
+ });
141
+ req.on('error', reject);
142
+ });
143
+
144
+ assert.strictEqual(result.status, 200);
145
+ assert.match(result.body, /class TeqFw_Di_Api_Container_Parser_Chunk/);
146
+
147
+ await server.stop();
148
+ assert.strictEqual(server.getInstance(), undefined);
149
+ });
150
+ });
@@ -4,6 +4,7 @@ export default class App_Plugin_Start {
4
4
  * @param {typeof import('node:url')} url
5
5
  * @param {Fl32_Web_Back_Dispatcher} dispatcher
6
6
  * @param {Fl32_Web_Back_Handler_Pre_Log} hndlRequestLog
7
+ * @param {Fl32_Web_Back_Handler_Npm} hndlNpm
7
8
  * @param {Fl32_Web_Back_Handler_Static} hndlStatic
8
9
  */
9
10
  constructor(
@@ -12,6 +13,7 @@ export default class App_Plugin_Start {
12
13
  'node:url': url,
13
14
  Fl32_Web_Back_Dispatcher$: dispatcher,
14
15
  Fl32_Web_Back_Handler_Pre_Log$: hndlRequestLog,
16
+ Fl32_Web_Back_Handler_Npm$: hndlNpm,
15
17
  Fl32_Web_Back_Handler_Static$: hndlStatic,
16
18
  }
17
19
  ) {
@@ -29,9 +31,11 @@ export default class App_Plugin_Start {
29
31
 
30
32
  return async function () {
31
33
  // Set up handlers
34
+ await hndlNpm.init({allow: {'@teqfw/di': ['src/Container.js']}});
32
35
  await hndlStatic.init({rootPath: webRoot});
33
36
  // Register handlers
34
37
  dispatcher.addHandler(hndlRequestLog);
38
+ dispatcher.addHandler(hndlNpm);
35
39
  dispatcher.addHandler(hndlStatic);
36
40
  };
37
41
  }
@@ -0,0 +1,69 @@
1
+ import {describe, it, beforeEach} from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {Writable} from 'node:stream';
4
+ import {buildTestContainer} from '../../common.js';
5
+
6
+ class MockRes extends Writable {
7
+ constructor() {
8
+ super();
9
+ this.data = Buffer.alloc(0);
10
+ this.statusCode = undefined;
11
+ this.headers = undefined;
12
+ this._headersSent = false;
13
+ this._ended = false;
14
+ }
15
+ get headersSent() { return this._headersSent; }
16
+ get writableEnded() { return this._ended; }
17
+ writeHead(status, headers) {
18
+ this.statusCode = status;
19
+ this.headers = headers;
20
+ this._headersSent = true;
21
+ }
22
+ _write(chunk, enc, cb) {
23
+ this.data = Buffer.concat([this.data, chunk]);
24
+ cb();
25
+ }
26
+ end(chunk) {
27
+ if (chunk) this.data = Buffer.concat([this.data, Buffer.from(chunk)]);
28
+ this._ended = true;
29
+ super.end();
30
+ }
31
+ }
32
+
33
+ describe('Fl32_Web_Back_Handler_Npm', () => {
34
+ let container;
35
+ const log = [];
36
+
37
+ beforeEach(() => {
38
+ container = buildTestContainer();
39
+ container.register('Fl32_Web_Back_Logger$', {
40
+ warn: (...args) => log.push(['warn', ...args]),
41
+ exception: (...args) => log.push(['exception', ...args]),
42
+ });
43
+ log.length = 0;
44
+ });
45
+
46
+ it('should serve allowed file', async () => {
47
+ const handler = await container.get('Fl32_Web_Back_Handler_Npm$');
48
+ await handler.init({allow: {'@teqfw/di': ['package.json']}});
49
+ const req = {url: '/node_modules/@teqfw/di/package.json'};
50
+ const res = new MockRes();
51
+ const ok = await handler.handle(req, res);
52
+ await new Promise(resolve => res.on('finish', resolve));
53
+ assert.strictEqual(ok, true);
54
+ assert.strictEqual(res.statusCode, 200);
55
+ assert.match(res.data.toString(), /@teqfw\/di/);
56
+ assert.strictEqual(log.length, 0);
57
+ });
58
+
59
+ it('should deny disallowed path', async () => {
60
+ const handler = await container.get('Fl32_Web_Back_Handler_Npm$');
61
+ await handler.init({allow: {'@teqfw/di': ['package.json']}});
62
+ const req = {url: '/node_modules/@teqfw/di/secret.js'};
63
+ const res = new MockRes();
64
+ const ok = await handler.handle(req, res);
65
+ assert.strictEqual(ok, false);
66
+ assert.strictEqual(res.headersSent, false);
67
+ assert.ok(log[0][0] === 'warn');
68
+ });
69
+ });