@jnode/server 1.0.6 → 2.0.1

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.
@@ -0,0 +1,246 @@
1
+ /*
2
+ @jnode/server/handlers.js
3
+ v2
4
+
5
+ Simple web server package for Node.js.
6
+
7
+ by JustApple
8
+ */
9
+
10
+ // dependencies
11
+ const stream = require('stream');
12
+ const fs = require('fs');
13
+ const mime = require('./mime.json');
14
+ const path = require('path');
15
+
16
+ // data handler: string, buffer, or stream
17
+ class DataHandler {
18
+ constructor(data, options = {}) {
19
+ this.data = data;
20
+ this.options = options;
21
+ }
22
+
23
+ async handle(ctx, env) {
24
+ if (typeof this.data === 'string') { // string
25
+ ctx.res.writeHead(this.options.statusCode ?? 200, {
26
+ 'Content-Type': 'text/plain; charset=utf-8',
27
+ 'Content-Length': Buffer.byteLength(this.data, 'utf8'),
28
+ ...this.options.headers
29
+ });
30
+ ctx.res.end(this.data, 'utf8');
31
+ } else if (this.data instanceof Uint8Array) { // buffer
32
+ ctx.res.writeHead(this.options.statusCode ?? 200, {
33
+ 'Content-Type': 'application/octet-stream',
34
+ 'Content-Length': this.data.length,
35
+ ...this.options.headers
36
+ });
37
+ ctx.res.end(this.data);
38
+ } else if (stream.isReadable(this.data)) { // stream
39
+ ctx.res.writeHead(this.options.statusCode ?? 200, {
40
+ 'Content-Type': 'application/octet-stream',
41
+ ...this.options.headers
42
+ });
43
+
44
+ try {
45
+ await stream.promises.pipeline(this.data, ctx.res);
46
+ } catch (e) {
47
+ if (e.code === 'ERR_STREAM_PREMATURE_CLOSE') return;
48
+ throw e;
49
+ }
50
+ }
51
+ }
52
+ }
53
+
54
+ // file handler: local file
55
+ class FileHandler {
56
+ constructor(file, options = {}) {
57
+ this.file = path.resolve(this.file);
58
+ this.options = options;
59
+
60
+ // range may be disabled by `options.disableRange` or when `statusCode` is set to non-200 value
61
+ this.options.disableRange = this.options.disableRange || (this.options.statusCode && this.options.statusCode !== 200);
62
+ }
63
+
64
+ async handle(ctx, env) {
65
+ let stats;
66
+ try {
67
+ stats = await fs.promises.stat(this.file);
68
+ } catch { throw 404; }
69
+
70
+ // folders are not allowed (if you need, try to make your own handler :D)
71
+ if (!stats.isFile()) throw 404;
72
+
73
+ // handle caching
74
+ if (this.options.cache) {
75
+ // set etag
76
+ if (!this._etag) this._etag = this.options.headers?.['ETag'] ?? `"${stats.size}-${stats.mtime.getTime()}"`;
77
+
78
+ // by etag
79
+ if (ctx.req.headers['if-none-match'] === this._etag) {
80
+ ctx.res.writeHead(304, {
81
+ 'Last-Modified': stats.mtime.toUTCString(),
82
+ 'ETag': this._etag,
83
+ ...this.options.headers
84
+ });
85
+ ctx.res.end();
86
+ return;
87
+ }
88
+
89
+ // by mtime
90
+ const since = new Date(ctx.req.headers['if-modified-since'] ?? 0).getTime();
91
+ if (!isNaN(since) && stats.mtime.getTime() <= since) {
92
+ ctx.res.writeHead(304, {
93
+ 'Last-Modified': stats.mtime.toUTCString(),
94
+ 'ETag': this._etag,
95
+ ...this.options.headers
96
+ });
97
+ ctx.res.end();
98
+ return;
99
+ }
100
+ }
101
+
102
+ // range
103
+ let start = 0;
104
+ let end = stats.size - 1;
105
+
106
+ // parse range header
107
+ let range = /^bytes=(\d*)-(\d*)$/.exec(ctx.req.headers.range || '');
108
+ if (this.options.disableRange || !(ctx.method === 'GET' || ctx.method === 'HEAD')) range = null;
109
+ if (range) {
110
+ // cache check
111
+ if (this.options.cache && ctx.req.headers['if-range']) {
112
+ if (ctx.req.headers['if-range'].startsWith('"') && ctx.req.headers['if-range'].endsWith('"')) { // etag
113
+ if (ctx.req.headers['if-range'] !== this._etag) range = null;
114
+ } else { // mtime
115
+ const since = new Date(ctx.req.headers['if-range']).getTime();
116
+ if (isNaN(since) || stats.mtime.getTime() > since) range = null;
117
+ }
118
+ }
119
+
120
+ if (range[1] === '' && range[2] === '') throw 416; // invalid range
121
+
122
+ if (range[1] === '' && range[2] !== '') {
123
+ start = Math.max(stats.size - parseInt(range[2], 10), 0); // last n bytes
124
+ } else {
125
+ if (range[1] !== '') start = parseInt(range[1], 10);
126
+ if (range[2] !== '') end = Math.min(parseInt(range[2], 10), stats.size - 1);
127
+ }
128
+
129
+ if (start > end) throw 416;
130
+ }
131
+
132
+ // headers
133
+ ctx.res.writeHead(this.options.statusCode ?? (range ? 206 : 200), {
134
+ 'Content-Type': mime[path.extname(this.file)] || 'application/octet-stream',
135
+ 'Content-Length': range ? (end - start) + 1 : stats.size,
136
+ 'Last-Modified': stats.mtime.toUTCString(),
137
+ ...(this._etag && { 'ETag': this._etag }),
138
+ ...(this.options.cache && { 'Cache-Control': 'max-age=' + (this.options.cache === true ? 'no-cache' : this.options.cache) }), // cache
139
+ ...(!this.options.disableRange && { 'Accept-Ranges': 'bytes' }),
140
+ ...(range && { 'Content-Range': `bytes ${start}-${end}/${stats.size}` }),
141
+ ...this.options.headers
142
+ });
143
+
144
+ // pipe stream or send with only headers
145
+ if (ctx.method === 'HEAD' && !this.options.disableHead) {
146
+ ctx.res.end();
147
+ } else {
148
+ // send with stream
149
+ try {
150
+ await stream.promises.pipeline(
151
+ fs.createReadStream(this.file, {
152
+ start, end,
153
+ highWaterMark: this.options.highWaterMark || 64 * 1024
154
+ }),
155
+ ctx.res
156
+ );
157
+ } catch (e) {
158
+ if (e.code === 'ERR_STREAM_PREMATURE_CLOSE') return;
159
+ throw e;
160
+ }
161
+ }
162
+ }
163
+ }
164
+
165
+ // folder handler: local folder, continues from env.path
166
+ class FolderHandler {
167
+ constructor(folder, options = {}) {
168
+ this.folder = path.resolve(folder);
169
+ this.options = options;
170
+ }
171
+
172
+ handle(ctx, env) {
173
+ const file = path.resolve(this.folder, ...env.path.slice(env.pathPointer));
174
+
175
+ // safety check
176
+ const rel = path.relative(this.folder, file);
177
+ if (rel.startsWith('..') || path.isAbsolute(rel)) throw 404;
178
+
179
+ // use a FileHandler
180
+ return (new FileHandler(file, this.options)).handle(ctx, env);
181
+ }
182
+ }
183
+
184
+ // JSON handler: JSON object
185
+ class JSONHandler {
186
+ constructor(obj, options = {}) {
187
+ this.obj = obj;
188
+ this.options = options;
189
+ }
190
+
191
+ handle(ctx, env) {
192
+ const data = JSON.stringify(this.obj);
193
+
194
+ ctx.res.writeHead(this.options.statusCode ?? 200, {
195
+ 'Content-Type': 'application/json; charset=utf-8',
196
+ 'Content-Length': Buffer.byteLength(data, 'utf8'),
197
+ ...this.options.headers
198
+ });
199
+ ctx.res.end(data, 'utf8');
200
+ }
201
+ }
202
+
203
+ // redirect handler: 307 redirect
204
+ class RedirectHandler {
205
+ constructor(location, options = {}) {
206
+ this.location = location;
207
+ this.options = options;
208
+ }
209
+
210
+ handle(ctx, env) {
211
+ ctx.res.writeHead(this.options.statusCode ?? 307, {
212
+ 'Location': this.options.base ?
213
+ this.options.base +
214
+ (this.options.base.endsWith('/') ? '' : '/') +
215
+ env.path.slice(env.pathPointer).map(encodeURIComponent).join('/') :
216
+ this.location,
217
+ ...this.options.headers
218
+ });
219
+ ctx.res.end();
220
+ }
221
+ }
222
+
223
+ // function handler: custom function
224
+ class FunctionHandler {
225
+ constructor(func) {
226
+ this.func = func;
227
+ }
228
+
229
+ handle(ctx, env) {
230
+ return this.func(ctx, env);
231
+ }
232
+ }
233
+
234
+ // export
235
+ module.exports = {
236
+ DataHandler, TextHandler: DataHandler, FileHandler, FolderHandler, JSONHandler, RedirectHandler, FunctionHandler,
237
+ handlerConstructors: {
238
+ Data: (...args) => new DataHandler(...args),
239
+ Text: (...args) => new DataHandler(...args),
240
+ File: (...args) => new FileHandler(...args),
241
+ Folder: (...args) => new FolderHandler(...args),
242
+ JSON: (...args) => new JSONHandler(...args),
243
+ Redirect: (...args) => new RedirectHandler(...args),
244
+ Function: (...args) => new FunctionHandler(...args)
245
+ }
246
+ };
package/src/index.js CHANGED
@@ -1,17 +1,37 @@
1
1
  /*
2
- JustServer
2
+ @jnode/server
3
+ v2
3
4
 
4
5
  Simple web server package for Node.js.
5
6
 
6
7
  by JustNode Dev Team / JustApple
7
8
  */
8
9
 
9
- //export
10
+ // router
11
+ class Router {
12
+ constructor() {
13
+ console.warn('Hey! There is no need to use class `Router` directly, just use any object with a `.route(env, ctx)` method as a router.');
14
+ console.warn('Learn more at the documentation (README.md).');
15
+ }
16
+
17
+ route(env, ctx) { }
18
+ }
19
+
20
+ // handler
21
+ class Handler {
22
+ constructor() {
23
+ console.warn('Hey! There is no need to use class `Handler` directly, just use any object with a `.handle(ctx, env)` method as a handler.');
24
+ console.warn('Learn more at the documentation (README.md).');
25
+ }
26
+
27
+ handle(ctx, env) { }
28
+ }
29
+
30
+ // export
10
31
  module.exports = {
11
- Server: require('./server.js'),
12
- processMap: require('./map.js'),
13
- processHandle: require('./handle.js'),
14
- processFinal: require('./final.js'),
15
- processError: require('./error.js'),
16
- mimeType: require('./mime.json')
32
+ mimeTypes: require('./mime.json'),
33
+ Router, Handler,
34
+ ...require('./routers.js'),
35
+ ...require('./handlers.js'),
36
+ ...require('./server.js')
17
37
  };
package/src/routers.js ADDED
@@ -0,0 +1,224 @@
1
+ /*
2
+ @jnode/server/routers.js
3
+ v2
4
+
5
+ Simple web server package for Node.js.
6
+
7
+ by JustApple
8
+ */
9
+
10
+ // path router: the most important router, routes the request by path
11
+ class PathRouter {
12
+ constructor(end = 404, map = {}) {
13
+ this.end = end;
14
+
15
+ // parse map
16
+ this.map = {};
17
+ for (let [key, value] of Object.entries(map)) {
18
+ // any path segment
19
+ if (key === '*') {
20
+ this.map['*'] = value;
21
+ continue;
22
+ }
23
+
24
+ key = key.trimStart();
25
+
26
+ const firstSlashIndex = key.indexOf('/');
27
+ if (firstSlashIndex === -1) continue;
28
+
29
+ // key format: '[@ (path end check)][METHOD (method check)]/path/segments'
30
+ // example: '@ GET /cat/names', 'POST /cats', '/api'
31
+ const routeEnd = key.startsWith('@');
32
+ const routeMethod = key.substring(routeEnd ? 1 : 0, firstSlashIndex).trim().toUpperCase();
33
+ const routePath = key.substring(firstSlashIndex).split('/').slice(1).map(decodeURIComponent);
34
+
35
+ // expand map
36
+ let current = this.map;
37
+ for (const segment of routePath) {
38
+ if (!current['/' + segment]) current['/' + segment] = {};
39
+ current = current['/' + segment];
40
+ }
41
+
42
+ // '*' for non-end check, '@' for end check
43
+ // '*' for any method, 'METHOD' for specific method
44
+ if (!current[routeEnd ? '@' : '*']) current[routeEnd ? '@' : '*'] = {};
45
+ current[routeEnd ? '@' : '*'][routeMethod || '*'] = value;
46
+ }
47
+ }
48
+
49
+ route(env, ctx) {
50
+ if (env.pathPointer >= env.path.length) return this.end;
51
+
52
+ let result = this.map['*'];
53
+ let resultPointer = env.pathPointer;
54
+ let current = this.map;
55
+ while (env.pathPointer < env.path.length) {
56
+ const segment = '/' + env.path[env.pathPointer];
57
+ if (!current[segment]) break;
58
+
59
+ // prepare fallback
60
+ if (current[segment]['*']?.['*'] || current[segment]['*']?.[ctx.method]) {
61
+ result = current[segment]['*'][ctx.method] ?? current[segment]['*']['*'];
62
+ resultPointer = env.pathPointer + 1;
63
+ }
64
+
65
+ current = current[segment];
66
+ env.pathPointer++;
67
+
68
+ // ends
69
+ if (env.pathPointer >= env.path.length && (current['@']?.['*'] || current['@']?.[ctx.method])) {
70
+ result = current['@'][ctx.method] ?? current['@']['*'];
71
+ resultPointer = env.pathPointer;
72
+ }
73
+ }
74
+
75
+ env.pathPointer = resultPointer;
76
+ return result;
77
+ }
78
+ }
79
+
80
+ // host router: routes the request by host
81
+ class HostRouter {
82
+ constructor(end = 404, map = {}) {
83
+ this.end = end;
84
+
85
+ // parse map
86
+ this.map = {};
87
+ for (let [key, value] of Object.entries(map)) {
88
+ // any path segment
89
+ if (key === '*') {
90
+ this.map['*'] = value;
91
+ continue;
92
+ }
93
+
94
+ key = key.trimStart();
95
+
96
+ const firstDotIndex = key.indexOf('.');
97
+ if (firstDotIndex === -1) continue;
98
+
99
+ // key format: '[@ (domain end check)].domain.segments'
100
+ // example: '@ .com.example', '.com.example', '.localhost', '.1.0.0.127' (yes, that's how it works)
101
+ const routeEnd = key.startsWith('@');
102
+ const routeDomain = key.substring(firstDotIndex).split('.').slice(1);
103
+
104
+ // expand map
105
+ let current = this.map;
106
+ for (const segment of routeDomain) {
107
+ if (!current['.' + segment]) current['.' + segment] = {};
108
+ current = current['.' + segment];
109
+ }
110
+
111
+ // '*' for non-end check, '@' for end check
112
+ current[routeEnd ? '@' : '*'] = value;
113
+ }
114
+ }
115
+
116
+ route(env, ctx) {
117
+ if (env.hostPointer >= env.host.length) return this.end;
118
+
119
+ let result = this.map['*'];
120
+ let resultPointer = env.hostPointer;
121
+ let current = this.map;
122
+ while (env.hostPointer < env.host.length) {
123
+ const segment = '.' + env.host[env.hostPointer];
124
+ if (!current[segment]) break;
125
+
126
+ // prepare fallback
127
+ if (current[segment]['*']) {
128
+ result = current[segment]['*'];
129
+ resultPointer = env.hostPointer + 1;
130
+ }
131
+
132
+ current = current[segment];
133
+ env.hostPointer++;
134
+
135
+ // ends
136
+ if (env.hostPointer >= env.host.length && current['@']) {
137
+ result = current['@'];
138
+ resultPointer = env.hostPointer;
139
+ }
140
+ }
141
+
142
+ env.hostPointer = resultPointer;
143
+ return result;
144
+ }
145
+ }
146
+
147
+ // method router: routes the request by method
148
+ class MethodRouter {
149
+ constructor(methodMap = {}) {
150
+ this.methodMap = methodMap;
151
+ }
152
+
153
+ route(env, ctx) {
154
+ return this.methodMap[ctx.method] || this.methodMap['*'] || 405;
155
+ }
156
+ }
157
+
158
+ // function router: a simple router that allows you to make custom routing logic
159
+ class FunctionRouter {
160
+ constructor(fn) {
161
+ this.fn = fn;
162
+ }
163
+
164
+ route(env, ctx) {
165
+ return this.fn(env, ctx);
166
+ }
167
+ }
168
+
169
+ // path argument router: collects a path segment and save to `ctx.params`
170
+ class PathArgRouter {
171
+ constructor(paramName, next) {
172
+ this.paramName = paramName;
173
+ this.next = next;
174
+ }
175
+
176
+ route(env, ctx) {
177
+ ctx.params[this.paramName] = env.path[env.pathPointer];
178
+ env.pathPointer++;
179
+
180
+ return this.next;
181
+ }
182
+ }
183
+
184
+ // host argument router: collects a host segment and save to `ctx.params`
185
+ class HostArgRouter {
186
+ constructor(paramName, next) {
187
+ this.paramName = paramName;
188
+ this.next = next;
189
+ }
190
+
191
+ route(env, ctx) {
192
+ ctx.params[this.paramName] = env.host[env.hostPointer];
193
+ env.hostPointer++;
194
+
195
+ return this.next;
196
+ }
197
+ }
198
+
199
+ // set code router: set the code handler for specific status code
200
+ class SetCodeRouter {
201
+ constructor(codeHandlers, next) {
202
+ this.codeHandlers = codeHandlers;
203
+ this.next = next;
204
+ }
205
+
206
+ route(env, ctx) {
207
+ env.codeHandlers = Object.assign({}, env.codeHandlers, this.codeHandlers);
208
+ return this.next;
209
+ }
210
+ }
211
+
212
+ // export
213
+ module.exports = {
214
+ PathRouter, HostRouter, MethodRouter, FunctionRouter, PathArgRouter, HostArgRouter, SetCodeRouter,
215
+ routerConstructors: {
216
+ Path: (...args) => new PathRouter(...args),
217
+ Host: (...args) => new HostRouter(...args),
218
+ Method: (...args) => new MethodRouter(...args),
219
+ Function: (...args) => new FunctionRouter(...args),
220
+ PathArg: (...args) => new PathArgRouter(...args),
221
+ HostArg: (...args) => new HostArgRouter(...args),
222
+ SetCode: (...args) => new SetCodeRouter(...args)
223
+ }
224
+ };