@johntalton/http-core 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +2 -0
- package/package.json +21 -0
- package/src/epilogue.js +125 -0
- package/src/index.js +507 -0
- package/src/preamble.js +295 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 John
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@johntalton/http-core",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src/*.js"
|
|
11
|
+
],
|
|
12
|
+
"repository": {
|
|
13
|
+
"url": "https://github.com/johntalton/http-core"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@johntalton/http-util": "^5.0.0",
|
|
19
|
+
"@johntalton/sse-util": "^1.0.0"
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/epilogue.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { Response } from '@johntalton/http-util/response/object'
|
|
2
|
+
import { MIME_TYPE_JSON } from '@johntalton/http-util/headers'
|
|
3
|
+
import { ServerSentEvents } from '@johntalton/sse-util'
|
|
4
|
+
|
|
5
|
+
/** @import { ServerHttp2Stream } from 'node:http2' */
|
|
6
|
+
/** @import { RouteAction, StreamID } from './index.js' */
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {ServerHttp2Stream} stream
|
|
10
|
+
* @param {MessagePort} port
|
|
11
|
+
* @param {StreamID} streamId
|
|
12
|
+
* @param {AbortSignal} shutdownSignal
|
|
13
|
+
*/
|
|
14
|
+
function addSSEPortHandler(stream, port, streamId, shutdownSignal) {
|
|
15
|
+
const signalHandler = () => {
|
|
16
|
+
console.log('shutdown of SSE requested (shutdown signal)', streamId, shutdownSignal.reason)
|
|
17
|
+
port.close()
|
|
18
|
+
stream.end()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
stream.once('close', (() => {
|
|
22
|
+
console.log('stream close in sse handler', streamId)
|
|
23
|
+
shutdownSignal.removeEventListener('abort', signalHandler)
|
|
24
|
+
port.close()
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
shutdownSignal.addEventListener('abort', signalHandler)
|
|
28
|
+
|
|
29
|
+
// ServerSentEvents.messageToEventStreamLines({
|
|
30
|
+
// comment: 'Welcome',
|
|
31
|
+
// retryMs: 1000 * 60,
|
|
32
|
+
// }).forEach(line => stream.write(line))
|
|
33
|
+
|
|
34
|
+
port.onmessage = message => {
|
|
35
|
+
const { data } = message
|
|
36
|
+
console.log('sending sse data', streamId, data)
|
|
37
|
+
// ServerSentEvents.messageToEventStreamLines(data)
|
|
38
|
+
ServerSentEvents.lineGen(data)
|
|
39
|
+
.forEach(line => stream.write(line))
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {RouteAction} state
|
|
45
|
+
*/
|
|
46
|
+
export function epilogue(state) {
|
|
47
|
+
const { type, stream, meta, streamId } = state
|
|
48
|
+
|
|
49
|
+
meta.customHeaders.push([ 'X-Request-Id', streamId ])
|
|
50
|
+
|
|
51
|
+
switch(type) {
|
|
52
|
+
//
|
|
53
|
+
case 'trace': { Response.trace(stream, state.method, state.url, state.headers, meta) } break
|
|
54
|
+
//
|
|
55
|
+
case 'preflight': { Response.preflight(stream, state.methods, state.supportedQueryTypes, undefined, meta) } break
|
|
56
|
+
// case 'no-content': { Response.noContent(stream, state.etag, meta)} break
|
|
57
|
+
// case 'accepted': { Response.accepted(stream, meta) } break
|
|
58
|
+
case 'created': { Response.created(stream, new URL(state.location, meta.origin), state.etag, meta) } break
|
|
59
|
+
case 'not-modified': { Response.notModified(stream, state.etag, state.age, { priv: true, maxAge: 60 }, meta) } break
|
|
60
|
+
|
|
61
|
+
//
|
|
62
|
+
// case 'multiple-choices': { Response.multipleChoices(stream, meta) } break
|
|
63
|
+
// case 'gone': { Response.gone(stream, meta) } break
|
|
64
|
+
// case 'moved-permanently': { Response.movedPermanently(stream, state.location, meta) } break
|
|
65
|
+
// case 'see-other': { Response.seeOther(stream, state.location, meta) } break
|
|
66
|
+
// case 'temporary-redirect': { Response.temporaryRedirect(stream, state.location, meta) } break
|
|
67
|
+
|
|
68
|
+
//
|
|
69
|
+
case '404': { Response.notFound(stream, state.message, meta) } break
|
|
70
|
+
case 'conflict': { Response.conflict(stream, meta) } break
|
|
71
|
+
case 'not-allowed': { Response.notAllowed(stream, state.methods, meta) } break
|
|
72
|
+
case 'not-acceptable': { Response.notAcceptable(stream, state.acceptableMediaTypes ?? [], meta)} break
|
|
73
|
+
case 'unsupported-media': { Response.unsupportedMediaType(stream, state.acceptableMediaTypes, state.supportedQueryTypes, meta) } break
|
|
74
|
+
case 'unprocessable': { Response.unprocessable(stream, meta) } break
|
|
75
|
+
case 'precondition-failed': { Response.preconditionFailed(stream, meta) } break
|
|
76
|
+
case 'not-satisfiable': { Response.rangeNotSatisfiable(stream, { size: state.contentLength }, meta) } break
|
|
77
|
+
// case 'content-too-large': { Response.contentTooLarge(stream, meta) } break
|
|
78
|
+
// case 'insufficient-storage': { Response.insufficientStorage(stream, meta) } break
|
|
79
|
+
// case 'too-many-requests': { Response.tooManyRequests(stream, state.limit, state.policies, meta) } break
|
|
80
|
+
// case 'unauthorized': { Response.unauthorized(stream, meta) } break
|
|
81
|
+
case 'unavailable': { Response.unavailable(stream, state.message, state.retryAfter, meta)} break
|
|
82
|
+
case 'not-implemented': { Response.notImplemented(stream, state.message, meta)} break
|
|
83
|
+
// case 'timeout': { Response.timeout(stream, meta) } break
|
|
84
|
+
|
|
85
|
+
//
|
|
86
|
+
case 'sse': {
|
|
87
|
+
const { active, bom, port } = state
|
|
88
|
+
|
|
89
|
+
Response.sse(stream, { ...meta, active, bom })
|
|
90
|
+
if(active) { addSSEPortHandler(stream, port, state.streamId, state.shutdownSignal) }
|
|
91
|
+
}
|
|
92
|
+
break
|
|
93
|
+
case 'json': {
|
|
94
|
+
const { obj, accept, etag } = state
|
|
95
|
+
|
|
96
|
+
if(accept.type === MIME_TYPE_JSON) {
|
|
97
|
+
Response.json(stream, obj, accept.encoding, etag, state.age, { priv: true, maxAge: 60 }, state.supportedQueryTypes, meta)
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
// todo: but we did process the request - is that ok?
|
|
101
|
+
Response.notAcceptable(stream, [ MIME_TYPE_JSON ], meta)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
break
|
|
105
|
+
case 'partial-bytes': { Response.partialContent(stream, state.contentType, state.objs, state.contentLength, undefined, state.etag, state.age, { maxAge: state.maxAge }, meta) } break
|
|
106
|
+
case 'bytes': { Response.bytes(stream, state.contentType, state.obj, state.contentLength, 'identity', state.etag, state.age, { maxAge: state.maxAge }, state.acceptRanges, meta) } break
|
|
107
|
+
|
|
108
|
+
//
|
|
109
|
+
case 'error': {
|
|
110
|
+
const { cause, error } = state
|
|
111
|
+
console.log('send error', state.streamId, cause)
|
|
112
|
+
if(error !== undefined) { console.log(error) }
|
|
113
|
+
Response.error(stream, cause, meta)
|
|
114
|
+
} break
|
|
115
|
+
|
|
116
|
+
//
|
|
117
|
+
// case 'void': {} break
|
|
118
|
+
// case 'request' : { throw new Error('unhandled request') } break
|
|
119
|
+
default: {
|
|
120
|
+
/** @type {never} */
|
|
121
|
+
const neverType = type
|
|
122
|
+
Response.error(stream, `unknown type ${neverType}`, meta)
|
|
123
|
+
} break
|
|
124
|
+
}
|
|
125
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
import http2 from 'node:http2'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import crypto from 'node:crypto'
|
|
4
|
+
|
|
5
|
+
import { HTTP_METHOD_QUERY } from '@johntalton/http-util/response'
|
|
6
|
+
|
|
7
|
+
import { preamble } from './preamble.js'
|
|
8
|
+
import { epilogue } from './epilogue.js'
|
|
9
|
+
|
|
10
|
+
const {
|
|
11
|
+
HTTP2_METHOD_GET,
|
|
12
|
+
HTTP2_METHOD_HEAD,
|
|
13
|
+
HTTP2_METHOD_POST,
|
|
14
|
+
HTTP2_METHOD_PUT,
|
|
15
|
+
HTTP2_METHOD_PATCH,
|
|
16
|
+
HTTP2_METHOD_OPTIONS,
|
|
17
|
+
HTTP2_METHOD_DELETE,
|
|
18
|
+
HTTP2_METHOD_TRACE
|
|
19
|
+
} = http2.constants
|
|
20
|
+
|
|
21
|
+
export const KNOWN_METHODS = [
|
|
22
|
+
HTTP2_METHOD_GET,
|
|
23
|
+
HTTP2_METHOD_HEAD,
|
|
24
|
+
HTTP2_METHOD_POST,
|
|
25
|
+
HTTP2_METHOD_PUT,
|
|
26
|
+
HTTP2_METHOD_PATCH,
|
|
27
|
+
HTTP2_METHOD_OPTIONS,
|
|
28
|
+
HTTP2_METHOD_DELETE,
|
|
29
|
+
HTTP2_METHOD_TRACE,
|
|
30
|
+
HTTP_METHOD_QUERY
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
/** @import { Http2Stream, ServerHttp2Stream, IncomingHttpHeaders } from 'node:http2' */
|
|
34
|
+
/** @import { SecureServerOptions } from 'node:http2' */
|
|
35
|
+
|
|
36
|
+
/** @import { Metadata } from '@johntalton/http-util/response' */
|
|
37
|
+
/** @import { BodyFuture } from '@johntalton/http-util/body' */
|
|
38
|
+
/** @import { EtagItem, IMFFixDate, ContentRangeDirective } from '@johntalton/http-util/headers' */
|
|
39
|
+
/** @import { SendBody } from '@johntalton/http-util/response' */
|
|
40
|
+
|
|
41
|
+
/** @typedef {(state: RouteRequest|RouteAction) => Promise<RouteAction>} Router */
|
|
42
|
+
|
|
43
|
+
/** @typedef {'request'} RouteTypeRequest */
|
|
44
|
+
/** @typedef {'partial-bytes'|'bytes'|'json'|'404'|'sse'|'error'|'preflight'|'not-allowed'|'trace'|'created'|'unsupported-media'|'not-modified'|'precondition-failed'|'unprocessable'|'not-acceptable'|'conflict'|'not-implemented'|'unavailable'|'not-satisfiable'} RouteType */
|
|
45
|
+
/** @typedef {'GET'|'HEAD'|'POST'|'PUT'|'OPTIONS'|'DELETE'|'TRACE'} RouteMethod */
|
|
46
|
+
|
|
47
|
+
/** @typedef {string & { readonly _brand: 'sid' }} StreamID */
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @typedef {Object} Config
|
|
51
|
+
* @property {boolean|undefined} [maintenance_mode]
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @typedef {Object} RouteBase
|
|
56
|
+
* @property {RouteTypeRequest|RouteType} type
|
|
57
|
+
* @property {Config} config
|
|
58
|
+
* @property {StreamID} streamId
|
|
59
|
+
* @property {ServerHttp2Stream} stream
|
|
60
|
+
* @property {Metadata} meta
|
|
61
|
+
* @property {AbortSignal} shutdownSignal
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @typedef {Object} RouteRequestBase
|
|
66
|
+
* @property {'request'} type
|
|
67
|
+
* @property {RouteMethod} method
|
|
68
|
+
* @property {URL} url
|
|
69
|
+
* @property {IncomingHttpHeaders} headers
|
|
70
|
+
* @property {BodyFuture} body
|
|
71
|
+
* @property {RouteRequestAccept} accept
|
|
72
|
+
* @property {RouteRemoteClient} client
|
|
73
|
+
* @property {RouteConditions} conditions
|
|
74
|
+
* @property {string} SNI
|
|
75
|
+
*/
|
|
76
|
+
/** @typedef {RouteBase & RouteRequestBase} RouteRequest */
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @typedef {Object} RouteErrorBase
|
|
80
|
+
* @property {'error'} type
|
|
81
|
+
* @property {string} cause
|
|
82
|
+
* @property {Error|undefined} [error]
|
|
83
|
+
*/
|
|
84
|
+
/** @typedef {RouteBase & RouteErrorBase } RouteError */
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @typedef {Object} RouteNotAllowedBase
|
|
88
|
+
* @property {'not-allowed'} type
|
|
89
|
+
* @property {RouteMethod} method
|
|
90
|
+
* @property {URL} url
|
|
91
|
+
* @property {Array<RouteMethod>} methods
|
|
92
|
+
*/
|
|
93
|
+
/** @typedef {RouteBase & RouteNotAllowedBase} RouteNotAllowed */
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @typedef {Object} RouteTraceBase
|
|
97
|
+
* @property {'trace'} type
|
|
98
|
+
* @property {RouteMethod} method
|
|
99
|
+
* @property {URL} url
|
|
100
|
+
* @property {IncomingHttpHeaders} headers
|
|
101
|
+
* @property {number} maxForwards
|
|
102
|
+
* @property {RouteRequestAccept} accept
|
|
103
|
+
*/
|
|
104
|
+
/** @typedef {RouteBase & RouteTraceBase} RouteTrace */
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @typedef {Object} RouteRequestAccept
|
|
108
|
+
* @property {string|undefined} type
|
|
109
|
+
* @property {string|undefined} encoding
|
|
110
|
+
* @property {string|undefined} language
|
|
111
|
+
*/
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @typedef {Object} RouteRemoteClient
|
|
115
|
+
* @property {string|undefined} family
|
|
116
|
+
* @property {string|undefined} ip
|
|
117
|
+
* @property {number|undefined} port
|
|
118
|
+
*/
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @typedef {Object} RouteConditions
|
|
122
|
+
* @property {Array<EtagItem>} match
|
|
123
|
+
* @property {Array<EtagItem>} noneMatch
|
|
124
|
+
* @property {IMFFixDate|undefined} modifiedSince
|
|
125
|
+
* @property {IMFFixDate|undefined} unmodifiedSince
|
|
126
|
+
* @property {IMFFixDate|EtagItem|undefined} [range]
|
|
127
|
+
*/
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @typedef {Object} RoutePreflightBase
|
|
131
|
+
* @property {'preflight'} type
|
|
132
|
+
* @property {RouteMethod} method
|
|
133
|
+
* @property {URL} url
|
|
134
|
+
* @property {Array<RouteMethod>} methods
|
|
135
|
+
* @property {Array<string>|undefined} [supportedQueryTypes]
|
|
136
|
+
*/
|
|
137
|
+
/** @typedef {RouteBase & RoutePreflightBase} RoutePreflight */
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @typedef {Object} RouteJSONBase
|
|
141
|
+
* @property {'json'} type
|
|
142
|
+
* @property {RouteRequestAccept} accept
|
|
143
|
+
* @property {Record<any, any>} obj
|
|
144
|
+
* @property {IMFFixDate|string|undefined} [lastModified]
|
|
145
|
+
* @property {EtagItem|undefined} [etag]
|
|
146
|
+
* @property {number|undefined} [age]
|
|
147
|
+
* @property {Array<string>|undefined} [supportedQueryTypes]
|
|
148
|
+
*/
|
|
149
|
+
/** @typedef {RouteBase & RouteJSONBase} RouteJSON */
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @typedef {Object} Route404Base
|
|
153
|
+
* @property {'404'} type
|
|
154
|
+
* @property {string} method
|
|
155
|
+
* @property {URL} url
|
|
156
|
+
* @property {string} message
|
|
157
|
+
*/
|
|
158
|
+
/** @typedef {RouteBase & Route404Base} Route404 */
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* @typedef {Object} RouteCreatedBase
|
|
162
|
+
* @property {'created'} type
|
|
163
|
+
* @property {URL|string} location
|
|
164
|
+
* @property {EtagItem|undefined} [etag]
|
|
165
|
+
*/
|
|
166
|
+
/** @typedef {RouteBase & RouteCreatedBase} RouteCreated */
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* @typedef {Object} RouteUnsupportedMediaTypeBase
|
|
170
|
+
* @property {'unsupported-media'} type
|
|
171
|
+
* @property {Array<string>|string} acceptableMediaTypes
|
|
172
|
+
* @property {Array<string>|undefined} [supportedQueryTypes]
|
|
173
|
+
*/
|
|
174
|
+
/** @typedef {RouteBase & RouteUnsupportedMediaTypeBase} RouteUnsupportedMediaType */
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* @typedef {Object} RouteNotModifiedBase
|
|
178
|
+
* @property {'not-modified'} type
|
|
179
|
+
* @property {number} age
|
|
180
|
+
* @property {EtagItem|undefined} [etag]
|
|
181
|
+
* @property {number|undefined} [age]
|
|
182
|
+
*/
|
|
183
|
+
/** @typedef {RouteBase & RouteNotModifiedBase} RouteNotModified */
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* @typedef {Object} RoutePreconditionFailedBase
|
|
187
|
+
* @property {'precondition-failed'} type
|
|
188
|
+
* @property {EtagItem|undefined} [etag]
|
|
189
|
+
*/
|
|
190
|
+
/** @typedef {RouteBase & RoutePreconditionFailedBase} RoutePreconditionFailed */
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* @typedef {Object} RouteNotAcceptableBase
|
|
194
|
+
* @property {'not-acceptable'} type
|
|
195
|
+
* @property {Array<string>|undefined} [acceptableMediaTypes]
|
|
196
|
+
* @property {Array<string>|undefined} [acceptableEncodings]
|
|
197
|
+
* @property {Array<string>|undefined} [acceptableLanguages]
|
|
198
|
+
*/
|
|
199
|
+
/** @typedef {RouteBase & RouteNotAcceptableBase} RouteNotAcceptable */
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @typedef {Object} RouteUnprocessableBase
|
|
203
|
+
* @property {'unprocessable'} type
|
|
204
|
+
* @property {string} message
|
|
205
|
+
*/
|
|
206
|
+
/** @typedef {RouteBase & RouteUnprocessableBase} RouteUnprocessable */
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* @typedef {Object} RouteConflictBase
|
|
210
|
+
* @property {'conflict'} type
|
|
211
|
+
* @property {string|undefined} [message]
|
|
212
|
+
*/
|
|
213
|
+
/** @typedef {RouteBase & RouteConflictBase} RouteConflict */
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* @typedef {Object} RouteNotImplementedBase
|
|
217
|
+
* @property {'not-implemented'} type
|
|
218
|
+
* @property {string|undefined} [message]
|
|
219
|
+
*/
|
|
220
|
+
/** @typedef {RouteBase & RouteNotImplementedBase} RouteNotImplemented */
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* @typedef {Object} RouteUnavailableBase
|
|
224
|
+
* @property {'unavailable'} type
|
|
225
|
+
* @property {string|undefined} [message]
|
|
226
|
+
* @property {number|undefined} [retryAfter]
|
|
227
|
+
*/
|
|
228
|
+
/** @typedef {RouteBase & RouteUnavailableBase} RouteUnavailable */
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* @typedef {Object} RouteBytesBase
|
|
232
|
+
* @property {'bytes'} type
|
|
233
|
+
* @property {string} contentType
|
|
234
|
+
* @property {number|undefined} [contentLength]
|
|
235
|
+
* @property {SendBody|undefined} obj
|
|
236
|
+
* @property {IMFFixDate|string|undefined} [lastModified]
|
|
237
|
+
* @property {EtagItem|undefined} [etag]
|
|
238
|
+
* @property {number|undefined} [age]
|
|
239
|
+
* @property {number|undefined} [maxAge]
|
|
240
|
+
* @property {'bytes'|'none'|undefined} [acceptRanges]
|
|
241
|
+
*/
|
|
242
|
+
/** @typedef {RouteBase & RouteBytesBase} RouteBytes */
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* @typedef {Object} PartialBytes
|
|
246
|
+
* @property {SendBody} obj
|
|
247
|
+
* @property {ContentRangeDirective} range
|
|
248
|
+
*/
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* @template T
|
|
252
|
+
* @typedef {[ T, ...T[] ]} NonEmptyArray
|
|
253
|
+
*/
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* @typedef {Object} RoutePartialBytesBase
|
|
257
|
+
* @property {'partial-bytes'} type
|
|
258
|
+
* @property {NonEmptyArray<PartialBytes>} objs
|
|
259
|
+
* @property {string} contentType
|
|
260
|
+
* @property {number|undefined} [contentLength]
|
|
261
|
+
* @property {EtagItem|undefined} [etag]
|
|
262
|
+
* @property {number|undefined} [age]
|
|
263
|
+
* @property {number|undefined} [maxAge]
|
|
264
|
+
*/
|
|
265
|
+
/** @typedef {RouteBase & RoutePartialBytesBase} RoutePartialBytes */
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* @typedef {Object} RouteNotSatisfiableBase
|
|
269
|
+
* @property {'not-satisfiable'} type
|
|
270
|
+
* @property {number} contentLength
|
|
271
|
+
*/
|
|
272
|
+
/** @typedef {RouteBase & RouteNotSatisfiableBase} RouteNotSatisfiable */
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* @typedef {Object} RouteSSEBase
|
|
277
|
+
* @property {'sse'} type
|
|
278
|
+
* @property {boolean} active
|
|
279
|
+
* @property {boolean} bom
|
|
280
|
+
* @property {MessagePort} port
|
|
281
|
+
* @property {RouteRequestAccept} accept
|
|
282
|
+
*/
|
|
283
|
+
/** @typedef {RouteBase & RouteSSEBase} RouteSSE */
|
|
284
|
+
|
|
285
|
+
/** @typedef {
|
|
286
|
+
RouteError |
|
|
287
|
+
RouteNotAllowed |
|
|
288
|
+
RoutePreflight |
|
|
289
|
+
RouteBytes |
|
|
290
|
+
RouteJSON |
|
|
291
|
+
Route404 |
|
|
292
|
+
RouteSSE |
|
|
293
|
+
RouteTrace |
|
|
294
|
+
RouteCreated |
|
|
295
|
+
RouteUnsupportedMediaType |
|
|
296
|
+
RouteNotModified |
|
|
297
|
+
RoutePreconditionFailed |
|
|
298
|
+
RouteUnprocessable |
|
|
299
|
+
RouteNotAcceptable |
|
|
300
|
+
RouteConflict |
|
|
301
|
+
RouteNotImplemented |
|
|
302
|
+
RouteUnavailable |
|
|
303
|
+
RoutePartialBytes |
|
|
304
|
+
RouteNotSatisfiable
|
|
305
|
+
} RouteAction */
|
|
306
|
+
|
|
307
|
+
/** @typedef {Record<string, string|undefined>} RouteMatches */
|
|
308
|
+
/** @typedef {(matches: RouteMatches, state: RouteRequest) => Promise<RouteAction>} RouteFunction */
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* @param {Http2Stream} stream
|
|
312
|
+
* @returns {stream is ServerHttp2Stream}
|
|
313
|
+
*/
|
|
314
|
+
function isServerStream(stream) {
|
|
315
|
+
if(stream === null) { return false }
|
|
316
|
+
return true
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* @param {string|undefined|Array<string>} header
|
|
321
|
+
* @returns {header is string}
|
|
322
|
+
*/
|
|
323
|
+
export function isValidHeader(header) {
|
|
324
|
+
return header !== undefined && isValidLikeHeader(header)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* @param {string|undefined|Array<string>} header
|
|
329
|
+
* @returns {header is string|undefined}
|
|
330
|
+
*/
|
|
331
|
+
export function isValidLikeHeader(header) {
|
|
332
|
+
return !Array.isArray(header)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* @param {string|undefined|Array<string>} method
|
|
337
|
+
* @returns {method is RouteMethod}
|
|
338
|
+
*/
|
|
339
|
+
export function isValidMethod(method) {
|
|
340
|
+
if(!isValidHeader(method)) { return false }
|
|
341
|
+
|
|
342
|
+
return KNOWN_METHODS.includes(method)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* @param {number} rstCode
|
|
347
|
+
*/
|
|
348
|
+
export function closeCodeToString(rstCode) {
|
|
349
|
+
if(rstCode === http2.constants.NGHTTP2_NO_ERROR) { return '(No Error)' }
|
|
350
|
+
else if(rstCode === http2.constants.NGHTTP2_PROTOCOL_ERROR) { return '(Protocol Error)' }
|
|
351
|
+
else if(rstCode === http2.constants.NGHTTP2_INTERNAL_ERROR) { return '(Internal Error)' }
|
|
352
|
+
else if(rstCode === http2.constants.NGHTTP2_FLOW_CONTROL_ERROR) { return '(Flow Control Error)' }
|
|
353
|
+
else if(rstCode === http2.constants.NGHTTP2_SETTINGS_TIMEOUT) { return '(Settings Timeout)' }
|
|
354
|
+
else if(rstCode === http2.constants.NGHTTP2_STREAM_CLOSED) { return '(Closed)' }
|
|
355
|
+
else if(rstCode === http2.constants.NGHTTP2_FRAME_SIZE_ERROR) { return '(Frame Size Error)' }
|
|
356
|
+
else if(rstCode === http2.constants.NGHTTP2_REFUSED_STREAM) { return '(Refused)' }
|
|
357
|
+
else if(rstCode === http2.constants.NGHTTP2_CANCEL) { return '(Cancel)' }
|
|
358
|
+
else if(rstCode === http2.constants.NGHTTP2_COMPRESSION_ERROR) { return '(Compression Error)' }
|
|
359
|
+
else if(rstCode === http2.constants.NGHTTP2_CONNECT_ERROR) { return '(Connect Error)' }
|
|
360
|
+
else if(rstCode === http2.constants.NGHTTP2_ENHANCE_YOUR_CALM) { return '(Chill)' }
|
|
361
|
+
else if(rstCode === http2.constants.NGHTTP2_INADEQUATE_SECURITY) { return '(Inadequate Security)' }
|
|
362
|
+
else if(rstCode === http2.constants.NGHTTP2_HTTP_1_1_REQUIRED) { return '(HTTP 1.1 Requested)' }
|
|
363
|
+
|
|
364
|
+
return `(${rstCode})`
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export const REQUEST_ID_SIZE = 5
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* @returns {StreamID}
|
|
371
|
+
*/
|
|
372
|
+
export function requestId() {
|
|
373
|
+
const buffer = new Uint8Array(REQUEST_ID_SIZE)
|
|
374
|
+
crypto.getRandomValues(buffer)
|
|
375
|
+
// @ts-ignore
|
|
376
|
+
return buffer.toHex()
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const {
|
|
380
|
+
SSL_OP_NO_TLSv1,
|
|
381
|
+
SSL_OP_NO_TLSv1_1,
|
|
382
|
+
SSL_OP_NO_TLSv1_2,
|
|
383
|
+
} = crypto.constants
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* @typedef {Object} H2CoreOptions
|
|
387
|
+
* @property {Config} config
|
|
388
|
+
* @property {boolean} ipv6Only
|
|
389
|
+
* @property {string} host
|
|
390
|
+
* @property {number} port
|
|
391
|
+
* @property {Array<string>} credentials
|
|
392
|
+
* @property {string|undefined} serverName
|
|
393
|
+
*/
|
|
394
|
+
|
|
395
|
+
export class H2CoreServer {
|
|
396
|
+
#server
|
|
397
|
+
#controller
|
|
398
|
+
|
|
399
|
+
/** @type {H2CoreOptions} */
|
|
400
|
+
#h2Options
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* @param {Router} router
|
|
404
|
+
* @param {Partial<H2CoreOptions>|undefined} [h2Options]
|
|
405
|
+
*/
|
|
406
|
+
constructor(router, h2Options) {
|
|
407
|
+
this.#h2Options = {
|
|
408
|
+
config: h2Options?.config ?? {},
|
|
409
|
+
ipv6Only: h2Options?.ipv6Only ?? true,
|
|
410
|
+
host: h2Options?.host ?? '',
|
|
411
|
+
port: h2Options?.port ?? 0,
|
|
412
|
+
credentials: h2Options?.credentials ?? [],
|
|
413
|
+
serverName: h2Options?.serverName
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** @type {SecureServerOptions} */
|
|
417
|
+
const options = {
|
|
418
|
+
allowHTTP1: false,
|
|
419
|
+
secureOptions: SSL_OP_NO_TLSv1 | SSL_OP_NO_TLSv1_1 | SSL_OP_NO_TLSv1_2,
|
|
420
|
+
minVersion: 'TLSv1.3',
|
|
421
|
+
settings: {
|
|
422
|
+
enablePush: false
|
|
423
|
+
},
|
|
424
|
+
ALPNProtocols: [ 'h2' ]
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const server = http2.createSecureServer(options)
|
|
428
|
+
this.#server = server
|
|
429
|
+
|
|
430
|
+
this.#controller = new AbortController()
|
|
431
|
+
|
|
432
|
+
for(const credentialHost of this.#h2Options.credentials) {
|
|
433
|
+
server.addContext(credentialHost, {
|
|
434
|
+
key: fs.readFileSync(`./certificates/${credentialHost}-privkey.pem`, 'utf-8'),
|
|
435
|
+
cert: fs.readFileSync(`./certificates/${credentialHost}-cert.pem`, 'utf-8')
|
|
436
|
+
})
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// server.setTimeout(5 * 1000)
|
|
440
|
+
|
|
441
|
+
// server.on('request', (req, res) => res.end('hello'))
|
|
442
|
+
server.on('drop', data => console.log('Drop', data))
|
|
443
|
+
server.on('connection', socket => console.log('new connection', socket.remoteAddress))
|
|
444
|
+
// server.on('secureConnection', socket => console.log('new secure connection'))
|
|
445
|
+
// server.on('keylog', (data) => console.log('key log', data))
|
|
446
|
+
server.on('unknownProtocol', socket => { console.log('Unknown Protocol', socket.getProtocol()) ; socket.end() })
|
|
447
|
+
server.on('tlsClientError', (error, _socket) => {
|
|
448
|
+
if('code' in error) {
|
|
449
|
+
if(error.code === 'ERR_SSL_SSL/TLS_ALERT_CERTIFICATE_UNKNOWN') { return }
|
|
450
|
+
if(error.code === 'ERR_SSL_NO_SUITABLE_SIGNATURE_ALGORITHM') { return }
|
|
451
|
+
// ERR_SSL_SSL/TLS_ALERT_BAD_CERTIFICATE
|
|
452
|
+
}
|
|
453
|
+
console.log('TLS Error', error)
|
|
454
|
+
})
|
|
455
|
+
server.on('error', error => console.log('Server Error', error))
|
|
456
|
+
server.on('sessionError', error => { console.log('session error', error) })
|
|
457
|
+
server.on('listening', () => console.log('Server Up', this.#h2Options.serverName, server.address()))
|
|
458
|
+
server.on('close', () => console.log('End of Line'))
|
|
459
|
+
server.on('session', session => {
|
|
460
|
+
console.log('new session')
|
|
461
|
+
session.on('close', () => console.log('session close'))
|
|
462
|
+
session.on('error', () => console.log('session error'))
|
|
463
|
+
session.on('frameError', () => console.log('session frameError'))
|
|
464
|
+
session.on('goaway', () => console.log('session goAway'))
|
|
465
|
+
})
|
|
466
|
+
server.on('stream', (stream, headers) => {
|
|
467
|
+
const streamId = requestId()
|
|
468
|
+
|
|
469
|
+
console.log('new stream', streamId, stream.id)
|
|
470
|
+
stream.on('aborted', () => console.log('stream aborted', streamId))
|
|
471
|
+
stream.on('close', () => {
|
|
472
|
+
// if(stream.rstCode !== http2.constants.NGHTTP2_NO_ERROR) {
|
|
473
|
+
console.log('stream close', streamId, closeCodeToString(stream.rstCode))
|
|
474
|
+
// }
|
|
475
|
+
})
|
|
476
|
+
stream.on('error', error => console.log('stream error', streamId, error.message))
|
|
477
|
+
stream.on('frameError', (type, code, id) => console.log('stream frameError', streamId, type, code, id))
|
|
478
|
+
|
|
479
|
+
// tickle the type
|
|
480
|
+
if(!isServerStream(stream)) { return }
|
|
481
|
+
|
|
482
|
+
// const start = performance.now()
|
|
483
|
+
const state = preamble(this.#h2Options.config, streamId, stream, headers, this.#h2Options.serverName, this.#controller.signal)
|
|
484
|
+
router(state)
|
|
485
|
+
.then(epilogue)
|
|
486
|
+
.catch(e => epilogue({ ...state, type: 'error', cause: e.message, error: e }))
|
|
487
|
+
.catch(e => console.error('Top Level Error:', streamId, e))
|
|
488
|
+
// .finally(() => console.log('perf', streamId, performance.now() - start))
|
|
489
|
+
})
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
listen() {
|
|
493
|
+
this.#server.listen({
|
|
494
|
+
ipv6Only: this.#h2Options.ipv6Only,
|
|
495
|
+
port: this.#h2Options.port,
|
|
496
|
+
host: this.#h2Options.host,
|
|
497
|
+
signal: this.#controller.signal
|
|
498
|
+
})
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
get closed() { return this.#controller.signal.aborted }
|
|
502
|
+
|
|
503
|
+
close() {
|
|
504
|
+
this.#controller.abort('close')
|
|
505
|
+
this.#server.close()
|
|
506
|
+
}
|
|
507
|
+
}
|
package/src/preamble.js
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import http2 from 'node:http2'
|
|
2
|
+
import { TLSSocket } from 'node:tls'
|
|
3
|
+
|
|
4
|
+
import { requestBody } from '@johntalton/http-util/body'
|
|
5
|
+
import {
|
|
6
|
+
MIME_TYPE_JSON,
|
|
7
|
+
MIME_TYPE_TEXT,
|
|
8
|
+
MIME_TYPE_XML,
|
|
9
|
+
MIME_TYPE_EVENT_STREAM,
|
|
10
|
+
MIME_TYPE_MESSAGE_HTTP,
|
|
11
|
+
parseContentType,
|
|
12
|
+
|
|
13
|
+
Accept,
|
|
14
|
+
AcceptEncoding,
|
|
15
|
+
AcceptLanguage,
|
|
16
|
+
|
|
17
|
+
Forwarded,
|
|
18
|
+
FORWARDED_KEY_FOR,
|
|
19
|
+
KNOWN_FORWARDED_KEYS,
|
|
20
|
+
Conditional,
|
|
21
|
+
ETag
|
|
22
|
+
} from '@johntalton/http-util/headers'
|
|
23
|
+
import { ENCODER_MAP, HTTP_HEADER_FORWARDED, HTTP_HEADER_ORIGIN } from '@johntalton/http-util/response'
|
|
24
|
+
import { isValidHeader, isValidLikeHeader, isValidMethod } from './index.js'
|
|
25
|
+
|
|
26
|
+
/** @import { ServerHttp2Stream, IncomingHttpHeaders } from 'node:http2' */
|
|
27
|
+
/** @import { Config, RouteRequest, RouteAction, StreamID, RouteConditions } from './index.js' */
|
|
28
|
+
|
|
29
|
+
const { HTTP2_METHOD_OPTIONS, HTTP2_METHOD_TRACE } = http2.constants
|
|
30
|
+
|
|
31
|
+
const {
|
|
32
|
+
HTTP2_HEADER_METHOD,
|
|
33
|
+
HTTP2_HEADER_AUTHORITY,
|
|
34
|
+
HTTP2_HEADER_SCHEME,
|
|
35
|
+
HTTP2_HEADER_PATH,
|
|
36
|
+
HTTP2_HEADER_AUTHORIZATION,
|
|
37
|
+
HTTP2_HEADER_CONTENT_TYPE,
|
|
38
|
+
HTTP2_HEADER_CONTENT_LENGTH,
|
|
39
|
+
HTTP2_HEADER_ACCEPT,
|
|
40
|
+
HTTP2_HEADER_ACCEPT_ENCODING,
|
|
41
|
+
HTTP2_HEADER_ACCEPT_LANGUAGE,
|
|
42
|
+
// HTTP2_HEADER_REFERER,
|
|
43
|
+
// HTTP2_HEADER_HOST,
|
|
44
|
+
// HTTP2_HEADER_VIA,
|
|
45
|
+
// HTTP2_HEADER_CACHE_CONTROL,
|
|
46
|
+
HTTP2_HEADER_IF_MATCH,
|
|
47
|
+
HTTP2_HEADER_IF_MODIFIED_SINCE,
|
|
48
|
+
HTTP2_HEADER_IF_NONE_MATCH,
|
|
49
|
+
HTTP2_HEADER_IF_RANGE,
|
|
50
|
+
HTTP2_HEADER_IF_UNMODIFIED_SINCE,
|
|
51
|
+
// HTTP2_HEADER_LAST_MODIFIED,
|
|
52
|
+
HTTP2_HEADER_MAX_FORWARDS,
|
|
53
|
+
// HTTP2_HEADER_FROM
|
|
54
|
+
} = http2.constants
|
|
55
|
+
|
|
56
|
+
const DEFAULT_SUPPORTED_LANGUAGES = [ 'en-US', 'en' ]
|
|
57
|
+
const DEFAULT_SUPPORTED_MIME_TYPES = [
|
|
58
|
+
MIME_TYPE_JSON,
|
|
59
|
+
MIME_TYPE_XML,
|
|
60
|
+
MIME_TYPE_TEXT,
|
|
61
|
+
MIME_TYPE_EVENT_STREAM,
|
|
62
|
+
MIME_TYPE_MESSAGE_HTTP
|
|
63
|
+
]
|
|
64
|
+
const DEFAULT_SUPPORTED_ENCODINGS = [ ...ENCODER_MAP.keys() ]
|
|
65
|
+
|
|
66
|
+
const FORWARDED_KEY_SECRET = 'secret'
|
|
67
|
+
const FORWARDED_ACCEPTABLE_KEYS = [ ...KNOWN_FORWARDED_KEYS, FORWARDED_KEY_SECRET ]
|
|
68
|
+
const FORWARDED_REQUIRED = process.env['FORWARDED_REQUIRED'] === 'true'
|
|
69
|
+
const FORWARDED_DROP_RIGHTMOST = (process.env['FORWARDED_SKIP_LIST'] ?? '').split(',').map(s => s.trim()).filter(s => s.length > 0)
|
|
70
|
+
const FORWARDED_SECRET = process.env['FORWARDED_SECRET']
|
|
71
|
+
|
|
72
|
+
const ALLOWED_ORIGINS = (process.env['ALLOWED_ORIGINS'] ?? '').split(',').map(s => s.trim()).filter(s => s.length > 0)
|
|
73
|
+
|
|
74
|
+
const ALLOW_TRACE = process.env['ALLOW_TRACE'] === 'true'
|
|
75
|
+
|
|
76
|
+
const BODY_TIMEOUT_SEC = 2 * 1000
|
|
77
|
+
const BODY_BYTE_LENGTH = 1000 * 1000
|
|
78
|
+
|
|
79
|
+
// const ipRateLimitStore = new Map()
|
|
80
|
+
// const ipRateLimitPolicy = {
|
|
81
|
+
// name: 'ip',
|
|
82
|
+
// quota: 25,
|
|
83
|
+
// windowSeconds: 15,
|
|
84
|
+
// size: 50,
|
|
85
|
+
// quotaUnits: 1
|
|
86
|
+
// }
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {Config} config
|
|
90
|
+
* @param {StreamID} streamId
|
|
91
|
+
* @param {ServerHttp2Stream} stream
|
|
92
|
+
* @param {IncomingHttpHeaders} headers
|
|
93
|
+
* @param {string|undefined} servername
|
|
94
|
+
* @param {AbortSignal} shutdownSignal
|
|
95
|
+
* @returns {RouteRequest|RouteAction}
|
|
96
|
+
*/
|
|
97
|
+
export function preamble(config, streamId, stream, headers, servername, shutdownSignal) {
|
|
98
|
+
const preambleStart = performance.now()
|
|
99
|
+
|
|
100
|
+
//
|
|
101
|
+
const method = headers[HTTP2_HEADER_METHOD]
|
|
102
|
+
const fullPathAndQuery = headers[HTTP2_HEADER_PATH]
|
|
103
|
+
const authority = headers[HTTP2_HEADER_AUTHORITY]
|
|
104
|
+
const scheme = headers[HTTP2_HEADER_SCHEME]
|
|
105
|
+
//
|
|
106
|
+
const authorization = headers[HTTP2_HEADER_AUTHORIZATION]
|
|
107
|
+
//
|
|
108
|
+
const fullForwarded = headers[HTTP_HEADER_FORWARDED]
|
|
109
|
+
//
|
|
110
|
+
const maxForwards = headers[HTTP2_HEADER_MAX_FORWARDS]
|
|
111
|
+
//
|
|
112
|
+
const fullContentType = headers[HTTP2_HEADER_CONTENT_TYPE]
|
|
113
|
+
const fullContentLength = headers[HTTP2_HEADER_CONTENT_LENGTH]
|
|
114
|
+
const fullAccept = headers[HTTP2_HEADER_ACCEPT]
|
|
115
|
+
const fullAcceptEncoding = headers[HTTP2_HEADER_ACCEPT_ENCODING]
|
|
116
|
+
const fullAcceptLanguage = headers[HTTP2_HEADER_ACCEPT_LANGUAGE]
|
|
117
|
+
//
|
|
118
|
+
const origin = headers[HTTP_HEADER_ORIGIN]
|
|
119
|
+
// const host = header[HTTP2_HEADER_HOST]
|
|
120
|
+
// const referer = header[HTTP2_HEADER_REFERER]
|
|
121
|
+
// const UA = header[HTTP_HEADER_USER_AGENT]
|
|
122
|
+
|
|
123
|
+
//
|
|
124
|
+
// const from = headers[HTTP2_HEADER_FROM]
|
|
125
|
+
|
|
126
|
+
// Conditions
|
|
127
|
+
const conditionIfMatch = headers[HTTP2_HEADER_IF_MATCH]
|
|
128
|
+
const conditionIfNoneMatch = headers[HTTP2_HEADER_IF_NONE_MATCH]
|
|
129
|
+
const conditionIfModifiedSince = headers[HTTP2_HEADER_IF_MODIFIED_SINCE]
|
|
130
|
+
const conditionIfUnmodifiedSince = headers[HTTP2_HEADER_IF_UNMODIFIED_SINCE]
|
|
131
|
+
const conditionIfRange = headers[HTTP2_HEADER_IF_RANGE]
|
|
132
|
+
|
|
133
|
+
// // SEC Client Hints
|
|
134
|
+
// const secUA = header[HTTP_HEADER_SEC_CH_UA]
|
|
135
|
+
// const secPlatform = header[HTTP_HEADER_SEC_CH_PLATFORM]
|
|
136
|
+
// const secMobile = header[HTTP_HEADER_SEC_CH_MOBILE]
|
|
137
|
+
// const secFetchSite = header[HTTP_HEADER_SEC_FETCH_SITE]
|
|
138
|
+
// const secFetchMode = header[HTTP_HEADER_SEC_FETCH_MODE]
|
|
139
|
+
// const secFetchDest = header[HTTP_HEADER_SEC_FETCH_DEST]
|
|
140
|
+
|
|
141
|
+
//
|
|
142
|
+
const allowedOrigin = (origin !== undefined) ?
|
|
143
|
+
((ALLOWED_ORIGINS.includes(origin) || ALLOWED_ORIGINS.includes('*')) ?
|
|
144
|
+
(URL.canParse(origin) ?
|
|
145
|
+
origin : undefined) : undefined) : undefined
|
|
146
|
+
|
|
147
|
+
/** @type {RouteRequest|RouteAction} */
|
|
148
|
+
const state = {
|
|
149
|
+
type: 'error',
|
|
150
|
+
cause: 'initialize',
|
|
151
|
+
config,
|
|
152
|
+
streamId,
|
|
153
|
+
stream,
|
|
154
|
+
meta: {
|
|
155
|
+
servername,
|
|
156
|
+
performance: [],
|
|
157
|
+
origin: allowedOrigin,
|
|
158
|
+
customHeaders: []
|
|
159
|
+
},
|
|
160
|
+
shutdownSignal
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if(shutdownSignal.aborted) {
|
|
164
|
+
return { ...state, type: 'unavailable', retryAfter: 60 }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if(stream.session === undefined) { return { ...state, type: 'error', cause: 'undefined session' } }
|
|
168
|
+
if(!(stream.session.socket instanceof TLSSocket)) { return { ...state, type: 'error', cause: 'not a TLSSocket' }}
|
|
169
|
+
|
|
170
|
+
const family = stream.session.socket.remoteFamily
|
|
171
|
+
const ip = stream.session.socket.remoteAddress
|
|
172
|
+
const port = stream.session.socket.remotePort
|
|
173
|
+
|
|
174
|
+
const SNI = stream.session.socket.servername // TLS SNI
|
|
175
|
+
if(SNI === null || SNI === false) { return { ...state, type: 'error', cause: 'invalid or unknown SNI' }}
|
|
176
|
+
|
|
177
|
+
//
|
|
178
|
+
if(!isValidHeader(fullPathAndQuery)) { return { ...state, type: 'error', cause: 'improper path' }}
|
|
179
|
+
if(!isValidMethod(method)) { return { ...state, type: 'not-implemented', message: 'unknown or invalid method' }}
|
|
180
|
+
|
|
181
|
+
if(!isValidLikeHeader(fullContentType)) { return { ...state, type: 'error', cause: 'improper header (content type)' }}
|
|
182
|
+
if(!isValidLikeHeader(fullContentLength)) { return { ...state, type: 'error', cause: 'improper header (content length)' }}
|
|
183
|
+
if(!isValidLikeHeader(fullAccept)) { return { ...state, type: 'error', cause: 'improper header (accept)' }}
|
|
184
|
+
if(!isValidLikeHeader(fullAcceptEncoding)) { return { ...state, type: 'error', cause: 'improper header (accept encoding)' }}
|
|
185
|
+
if(!isValidLikeHeader(fullAcceptLanguage)) { return { ...state, type: 'error', cause: 'improper header (accept language)' }}
|
|
186
|
+
if(!isValidLikeHeader(authorization)) { return { ...state, type: 'error', cause: 'improper header (authorization)' }}
|
|
187
|
+
if(!isValidLikeHeader(maxForwards)) { return { ...state, type: 'error', cause: 'improper header (max forwards)' } }
|
|
188
|
+
if(!isValidLikeHeader(conditionIfMatch)) { return { ...state, type: 'error', cause: 'improper header (if match)' } }
|
|
189
|
+
if(!isValidLikeHeader(conditionIfNoneMatch)) { return { ...state, type: 'error', cause: 'improper header (if none match)' } }
|
|
190
|
+
if(!isValidLikeHeader(conditionIfModifiedSince)) { return { ...state, type: 'error', cause: 'improper header (if modified since)' } }
|
|
191
|
+
if(!isValidLikeHeader(conditionIfUnmodifiedSince)) { return { ...state, type: 'error', cause: 'improper header (if unmodified since)' } }
|
|
192
|
+
if(!isValidLikeHeader(conditionIfRange)) { return { ...state, type: 'error', cause: 'improper header (if range)' } }
|
|
193
|
+
|
|
194
|
+
//
|
|
195
|
+
const requestUrl = new URL(fullPathAndQuery, `${scheme}://${authority}`)
|
|
196
|
+
|
|
197
|
+
//
|
|
198
|
+
/** @type {RouteConditions} */
|
|
199
|
+
const conditions = {
|
|
200
|
+
match: Conditional.parseEtagList(conditionIfMatch),
|
|
201
|
+
noneMatch: Conditional.parseEtagList(conditionIfNoneMatch),
|
|
202
|
+
modifiedSince: Conditional.parseFixDate(conditionIfModifiedSince),
|
|
203
|
+
unmodifiedSince: Conditional.parseFixDate(conditionIfUnmodifiedSince),
|
|
204
|
+
range: Conditional.parseFixDate(conditionIfRange) ?? ETag.parse(conditionIfRange)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
//
|
|
208
|
+
// Forwarded
|
|
209
|
+
//
|
|
210
|
+
const forwardedList = Forwarded.parse(fullForwarded, FORWARDED_ACCEPTABLE_KEYS)
|
|
211
|
+
const forwarded = Forwarded.selectRightMost(forwardedList, FORWARDED_DROP_RIGHTMOST)
|
|
212
|
+
const forwardedFor = forwarded?.get(FORWARDED_KEY_FOR)
|
|
213
|
+
const forwardedSecret = forwarded?.get(FORWARDED_KEY_SECRET)
|
|
214
|
+
|
|
215
|
+
if(FORWARDED_REQUIRED && forwarded === undefined) { return { ...state, type: 'error', cause: 'forwarded required' } }
|
|
216
|
+
if(FORWARDED_REQUIRED && forwardedFor === undefined) { return { ...state, type: 'error', cause: 'forwarded for required' } }
|
|
217
|
+
if(FORWARDED_REQUIRED && forwardedSecret !== FORWARDED_SECRET) { return { ...state, type: 'error', cause: 'forwarded invalid' } }
|
|
218
|
+
|
|
219
|
+
//
|
|
220
|
+
// Options
|
|
221
|
+
//
|
|
222
|
+
if(method === HTTP2_METHOD_OPTIONS) {
|
|
223
|
+
const preambleEnd = performance.now()
|
|
224
|
+
state.meta.performance.push({ name: 'preamble-preflight', duration: preambleEnd - preambleStart })
|
|
225
|
+
return { ...state, type: 'preflight', method, methods: [], url: requestUrl }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
//
|
|
229
|
+
// rate limit
|
|
230
|
+
//
|
|
231
|
+
// const ipRateLimitKey = `${ip}`
|
|
232
|
+
// if(!RateLimiter.test(ipRateLimitStore, ipRateLimitKey, ipRateLimitPolicy)) { return { type: 'limit', url: requestUrl, policy: ipRateLimitPolicy, ...defaultReturn } }
|
|
233
|
+
|
|
234
|
+
//
|
|
235
|
+
// content negotiation
|
|
236
|
+
//
|
|
237
|
+
const contentType = parseContentType(fullContentType)
|
|
238
|
+
const acceptedEncoding = AcceptEncoding.select(fullAcceptEncoding, DEFAULT_SUPPORTED_ENCODINGS)
|
|
239
|
+
const accept = Accept.select(fullAccept, DEFAULT_SUPPORTED_MIME_TYPES)
|
|
240
|
+
const acceptedLanguage = AcceptLanguage.select(fullAcceptLanguage, DEFAULT_SUPPORTED_LANGUAGES)
|
|
241
|
+
const acceptObject = {
|
|
242
|
+
type: accept,
|
|
243
|
+
encoding: acceptedEncoding,
|
|
244
|
+
language: acceptedLanguage
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
//
|
|
248
|
+
// Trace
|
|
249
|
+
//
|
|
250
|
+
if(method === HTTP2_METHOD_TRACE) {
|
|
251
|
+
if(!ALLOW_TRACE) { return { ...state, type: 'not-allowed', method, methods: [], url: requestUrl }}
|
|
252
|
+
const maxForwardsValue = maxForwards !== undefined ? parseInt(maxForwards) : 0
|
|
253
|
+
const preambleEnd = performance.now()
|
|
254
|
+
state.meta.performance.push({ name: 'preamble-trace', duration: preambleEnd - preambleStart })
|
|
255
|
+
if(acceptObject.type !== MIME_TYPE_MESSAGE_HTTP) { return { ...state, type: 'not-acceptable', acceptableMediaTypes: [ MIME_TYPE_MESSAGE_HTTP ] } }
|
|
256
|
+
return { ...state, type: 'trace', method, headers, url: requestUrl, maxForwards: maxForwardsValue, accept: acceptObject }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
//
|
|
260
|
+
// setup future body
|
|
261
|
+
//
|
|
262
|
+
const contentLength = fullContentLength === undefined ? undefined : parseInt(fullContentLength, 10)
|
|
263
|
+
const body = requestBody(stream, {
|
|
264
|
+
byteLimit: BODY_BYTE_LENGTH,
|
|
265
|
+
contentLength,
|
|
266
|
+
contentType,
|
|
267
|
+
signal: AbortSignal.any([
|
|
268
|
+
shutdownSignal,
|
|
269
|
+
AbortSignal.timeout(BODY_TIMEOUT_SEC)
|
|
270
|
+
])
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
//
|
|
274
|
+
// token
|
|
275
|
+
//
|
|
276
|
+
// const tokens = getTokens(authorization, requestUrl.searchParams)
|
|
277
|
+
|
|
278
|
+
//
|
|
279
|
+
const preambleEnd = performance.now()
|
|
280
|
+
state.meta.performance.push({ name: 'preamble', duration: preambleEnd - preambleStart })
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
...state,
|
|
284
|
+
type: 'request',
|
|
285
|
+
method,
|
|
286
|
+
url: requestUrl,
|
|
287
|
+
headers,
|
|
288
|
+
body,
|
|
289
|
+
// tokens,
|
|
290
|
+
conditions,
|
|
291
|
+
accept: acceptObject,
|
|
292
|
+
client: { family, ip, port },
|
|
293
|
+
SNI
|
|
294
|
+
}
|
|
295
|
+
}
|