@johntalton/http-util 4.1.1 → 5.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.
Files changed (57) hide show
  1. package/README.md +232 -21
  2. package/package.json +1 -1
  3. package/src/accept-encoding.js +1 -1
  4. package/src/accept-language.js +1 -1
  5. package/src/accept-util.js +1 -1
  6. package/src/accept.js +2 -5
  7. package/src/body.js +13 -15
  8. package/src/cache-control.js +6 -1
  9. package/src/clear-site-data.js +59 -0
  10. package/src/conditional.js +37 -42
  11. package/src/content-disposition.js +12 -7
  12. package/src/content-range.js +59 -0
  13. package/src/content-type.js +17 -14
  14. package/src/forwarded.js +1 -1
  15. package/src/index.js +6 -1
  16. package/src/multipart.js +81 -33
  17. package/src/preference.js +205 -0
  18. package/src/range.js +154 -0
  19. package/src/response/accepted.js +1 -0
  20. package/src/response/bytes.js +27 -0
  21. package/src/response/conflict.js +1 -0
  22. package/src/response/content-too-large.js +16 -0
  23. package/src/response/created.js +2 -1
  24. package/src/response/defs.js +15 -1
  25. package/src/response/error.js +1 -0
  26. package/src/response/forbidden.js +17 -0
  27. package/src/response/gone.js +16 -0
  28. package/src/response/header-util.js +2 -1
  29. package/src/response/im-a-teapot.js +16 -0
  30. package/src/response/index.js +17 -0
  31. package/src/response/insufficient-storage.js +16 -0
  32. package/src/response/json.js +6 -53
  33. package/src/response/moved-permanently.js +23 -0
  34. package/src/response/multiple-choices.js +21 -0
  35. package/src/response/no-content.js +2 -1
  36. package/src/response/not-acceptable.js +2 -1
  37. package/src/response/not-allowed.js +1 -0
  38. package/src/response/not-found.js +1 -0
  39. package/src/response/not-implemented.js +1 -0
  40. package/src/response/not-modified.js +3 -2
  41. package/src/response/partial-content.js +71 -0
  42. package/src/response/permanent-redirect.js +23 -0
  43. package/src/response/precondition-failed.js +1 -0
  44. package/src/response/preflight.js +21 -5
  45. package/src/response/range-not-satisfiable.js +28 -0
  46. package/src/response/response.js +26 -0
  47. package/src/response/see-other.js +23 -0
  48. package/src/response/send-util.js +137 -6
  49. package/src/response/sse.js +4 -3
  50. package/src/response/temporary-redirect.js +23 -0
  51. package/src/response/timeout.js +1 -0
  52. package/src/response/too-many-requests.js +1 -0
  53. package/src/response/trace.js +8 -4
  54. package/src/response/unauthorized.js +1 -0
  55. package/src/response/unavailable.js +1 -0
  56. package/src/response/unprocessable.js +1 -0
  57. package/src/response/unsupported-media.js +15 -4
@@ -0,0 +1,59 @@
1
+ import { RANGE_UNITS_BYTES } from "./response/defs.js"
2
+
3
+ export const CONTENT_RANGE_UNKNOWN = '*'
4
+ export const CONTENT_RANGE_SEPARATOR = '-'
5
+ export const CONTENT_RANGE_SIZE_SEPARATOR = '/'
6
+
7
+ export const DEFAULT_CONTENT_RANGE_DIRECTIVE = {
8
+ units: RANGE_UNITS_BYTES,
9
+ range: CONTENT_RANGE_UNKNOWN,
10
+ size: CONTENT_RANGE_UNKNOWN
11
+ }
12
+ /**
13
+ * @typedef {Object} ContentRangeDirective
14
+ * @property {RANGE_UNITS_BYTES|undefined} [units]
15
+ * @property {{ start: number, end: number }|CONTENT_RANGE_UNKNOWN|undefined} [range]
16
+ * @property {number|CONTENT_RANGE_UNKNOWN|undefined} [size]
17
+ */
18
+
19
+ export class ContentRange {
20
+ /**
21
+ * @param {ContentRangeDirective|undefined} rangeDirective
22
+ * @returns {string|undefined}
23
+ */
24
+ static encode(rangeDirective) {
25
+ if(rangeDirective === undefined) { return undefined }
26
+
27
+ const units = rangeDirective.units ?? DEFAULT_CONTENT_RANGE_DIRECTIVE.units
28
+ const size = rangeDirective.size ?? DEFAULT_CONTENT_RANGE_DIRECTIVE.size
29
+ const range = rangeDirective.range ?? DEFAULT_CONTENT_RANGE_DIRECTIVE.range
30
+
31
+ if(units !== RANGE_UNITS_BYTES) { return undefined }
32
+ if(size !== CONTENT_RANGE_UNKNOWN && !Number.isInteger(size)) { return undefined }
33
+
34
+ if((typeof range === 'string')) {
35
+ if(range !== CONTENT_RANGE_UNKNOWN) { return undefined }
36
+ return `${units} ${CONTENT_RANGE_UNKNOWN}${CONTENT_RANGE_SIZE_SEPARATOR}${size}`
37
+ }
38
+
39
+ const rangeStr = `${range.start}${CONTENT_RANGE_SEPARATOR}${range.end}`
40
+ return `${units} ${rangeStr}${CONTENT_RANGE_SIZE_SEPARATOR}${size}`
41
+ }
42
+ }
43
+
44
+ // console.log(ContentRange.encode({}))
45
+ // console.log(ContentRange.encode({ size: '*' }))
46
+ // console.log(ContentRange.encode({ units: 'bytes' }))
47
+ // console.log(ContentRange.encode({ range: '*' }))
48
+ // console.log(ContentRange.encode({ range: '*', size: '*' }))
49
+
50
+ // console.log()
51
+ // console.log(ContentRange.encode({ range: { start: 0, end: 1024 } }))
52
+ // console.log(ContentRange.encode({ range: { start: 0, end: 1024 }, size: 1024 }))
53
+ // console.log(ContentRange.encode({ range: '*', size: 1024 }))
54
+ // console.log(ContentRange.encode({ size: 1024 }))
55
+
56
+ // console.log()
57
+ // console.log(ContentRange.encode({ units: 'bob' }))
58
+ // console.log(ContentRange.encode({ range: 'bob' }))
59
+ // console.log(ContentRange.encode({ size: 'bob' }))
@@ -5,9 +5,11 @@ export const MIME_TYPE_EVENT_STREAM = 'text/event-stream'
5
5
  export const MIME_TYPE_XML = 'application/xml'
6
6
  export const MIME_TYPE_URL_FORM_DATA = 'application/x-www-form-urlencoded'
7
7
  export const MIME_TYPE_MULTIPART_FORM_DATA = 'multipart/form-data'
8
+ export const MIME_TYPE_MULTIPART_RANGE = 'multipart/byteranges'
8
9
  export const MIME_TYPE_OCTET_STREAM = 'application/octet-stream'
9
10
  export const MIME_TYPE_MESSAGE_HTTP = 'message/http'
10
11
 
12
+
11
13
  export const KNOWN_CONTENT_TYPES = [
12
14
  'application', 'audio', 'image', 'message',
13
15
  'multipart','text', 'video'
@@ -27,10 +29,12 @@ export const SPECIAL_CHARS = [
27
29
  '\n', '\r', '\t'
28
30
  ]
29
31
 
32
+ export const WHITESPACE_REGEX = /\s/
33
+
30
34
  /**
31
35
  * @param {string} c
32
36
  */
33
- export function isWhitespace(c){ return /\s/.test(c) }
37
+ export function isWhitespace(c){ return WHITESPACE_REGEX.test(c) }
34
38
 
35
39
  /**
36
40
  * @param {string|undefined} value
@@ -114,22 +118,21 @@ export function parseContentType(contentTypeHeader) {
114
118
 
115
119
  const parameters = new Map()
116
120
 
117
- parameterSet
118
- .forEach(parameter => {
119
- const [ key, value ] = parameter.split(CONTENT_TYPE_SEPARATOR.KVP)
120
- if(key === undefined || key === '') { return }
121
- if(value === undefined || value === '') { return }
122
- if(hasSpecialChar(key)) { return }
121
+ for(const parameter of parameterSet) {
122
+ const [ key, value ] = parameter.split(CONTENT_TYPE_SEPARATOR.KVP)
123
+ if(key === undefined || key === '') { return }
124
+ if(value === undefined || value === '') { return }
125
+ if(hasSpecialChar(key)) { return }
123
126
 
124
- const actualKey = key?.trim().toLowerCase()
127
+ const actualKey = key?.trim().toLowerCase()
125
128
 
126
- const quoted = (value.charAt(0) === '"' && value.charAt(value.length - 1) === '"')
127
- const actualValue = quoted ? value.substring(1, value.length - 1) : value
129
+ const quoted = (value.at(0) === '"' && value.at(-1) === '"')
130
+ const actualValue = quoted ? value.substring(1, value.length - 1) : value
128
131
 
129
- if(!parameters.has(actualKey)) {
130
- parameters.set(actualKey, actualValue)
131
- }
132
- })
132
+ if(!parameters.has(actualKey)) {
133
+ parameters.set(actualKey, actualValue)
134
+ }
135
+ }
133
136
 
134
137
  const charset = parameters.get(CHARSET)
135
138
 
package/src/forwarded.js CHANGED
@@ -49,7 +49,7 @@ export class Forwarded {
49
49
  })
50
50
  .filter(item => item !== undefined))
51
51
  )
52
- .filter(m => m.size !== 0)
52
+ .filter(m => m.size > 0)
53
53
  }
54
54
 
55
55
  /**
package/src/index.js CHANGED
@@ -1,11 +1,16 @@
1
+ /** biome-ignore-all lint/performance/noBarrelFile: entry point */
2
+ /** biome-ignore-all lint/performance/noReExportAll: entry point */
3
+ export * from './accept.js'
1
4
  export * from './accept-encoding.js'
2
5
  export * from './accept-language.js'
3
6
  export * from './accept-util.js'
4
- export * from './accept.js'
7
+ export * from './cache-control.js'
5
8
  export * from './conditional.js'
6
9
  export * from './content-disposition.js'
10
+ export * from './content-range.js'
7
11
  export * from './content-type.js'
8
12
  export * from './forwarded.js'
9
13
  export * from './multipart.js'
14
+ export * from './range.js'
10
15
  export * from './rate-limit.js'
11
16
  export * from './server-timing.js'
package/src/multipart.js CHANGED
@@ -1,6 +1,18 @@
1
+ import { ReadableStream } from 'node:stream/web'
2
+
1
3
  import { parseContentDisposition } from './content-disposition.js'
4
+ import { ContentRange } from './content-range.js'
2
5
  import { parseContentType } from './content-type.js'
3
6
 
7
+ /** @import { ContentRangeDirective } from './content-range.js' */
8
+ /** @import { SendBody } from './response/send-util.js' */
9
+
10
+ /**
11
+ * @typedef {Object} MultipartBytePart
12
+ * @property {SendBody} obj
13
+ * @property {ContentRangeDirective} range
14
+ */
15
+
4
16
  export const DISPOSITION_FORM_DATA = 'form-data'
5
17
 
6
18
  export const BOUNDARY_MARK = '--'
@@ -12,7 +24,8 @@ export const EMPTY = ''
12
24
 
13
25
  export const MULTIPART_HEADER = {
14
26
  CONTENT_DISPOSITION: 'content-disposition',
15
- CONTENT_TYPE: 'content-type'
27
+ CONTENT_TYPE: 'content-type',
28
+ CONTENT_RANGE: 'content-range'
16
29
  }
17
30
 
18
31
  export const MULTIPART_STATE = {
@@ -29,6 +42,15 @@ export class Multipart {
29
42
  * @param {string} [charset='utf8']
30
43
  */
31
44
  static parse(text, boundary, charset = 'utf8') {
45
+ return Multipart.parse_FormData(text, boundary, charset)
46
+ }
47
+
48
+ /**
49
+ * @param {string} text
50
+ * @param {string} boundary
51
+ * @param {string} [_charset='utf8']
52
+ */
53
+ static parse_FormData(text, boundary, _charset = 'utf8') {
32
54
  // console.log({ boundary, text })
33
55
  const formData = new FormData()
34
56
 
@@ -69,10 +91,10 @@ export class Multipart {
69
91
  if(line === EMPTY) { state = MULTIPART_STATE.VALUE }
70
92
  else {
71
93
  const [ rawName, value ] = line.split(HEADER_SEPARATOR)
72
- const name = rawName.toLowerCase()
94
+ const name = rawName?.toLowerCase()
73
95
  // console.log('header', name, value)
74
96
  if(name === MULTIPART_HEADER.CONTENT_TYPE) {
75
- const contentType = parseContentType(value)
97
+ const _contentType = parseContentType(value)
76
98
  // console.log({ contentType })
77
99
  }
78
100
  else if(name === MULTIPART_HEADER.CONTENT_DISPOSITION) {
@@ -114,36 +136,62 @@ export class Multipart {
114
136
 
115
137
  return formData
116
138
  }
117
- }
118
139
 
140
+ /**
141
+ * @param {string} contentType
142
+ * @param {Array<MultipartBytePart>} parts
143
+ * @param {number|undefined} contentLength
144
+ * @param {string} boundary
145
+ * @returns {ReadableStream<Uint8Array>}
146
+ */
147
+ static encode_Bytes(contentType, parts, contentLength, boundary) {
148
+ const boundaryBegin = `${BOUNDARY_MARK}${boundary}`
149
+ const boundaryEnd = `${BOUNDARY_MARK}${boundary}${BOUNDARY_MARK}`
119
150
 
151
+ return new ReadableStream({
152
+ type: 'bytes',
153
+ async start(controller) {
154
+ const encoder = new TextEncoder()
155
+
156
+ for (const part of parts) {
157
+ controller.enqueue(encoder.encode(`${boundaryBegin}${MULTIPART_SEPARATOR}`))
158
+ controller.enqueue(encoder.encode(`${MULTIPART_HEADER.CONTENT_TYPE}: ${contentType}${MULTIPART_SEPARATOR}`))
159
+ controller.enqueue(encoder.encode(`${MULTIPART_HEADER.CONTENT_RANGE}: ${ContentRange.encode({ ...part.range, size: contentLength })}${MULTIPART_SEPARATOR}`))
160
+ controller.enqueue(encoder.encode(MULTIPART_SEPARATOR))
161
+ // controller.enqueue(encoder.encode(MULTIPART_SEPARATOR))
162
+
163
+ if(part.obj instanceof ReadableStream) {
164
+ for await (const chunk of part.obj) {
165
+ if(chunk instanceof ArrayBuffer || ArrayBuffer.isView(chunk)) {
166
+ controller.enqueue(chunk)
167
+ }
168
+ else if(typeof chunk === 'string'){
169
+ controller.enqueue(encoder.encode(chunk))
170
+ }
171
+ else {
172
+ // console.log('chunk type', typeof chunk)
173
+ controller.enqueue(Uint8Array.from([ chunk ]))
174
+ }
175
+ }
176
+ }
177
+ else if(part.obj instanceof ArrayBuffer || ArrayBuffer.isView(part.obj)) {
178
+ controller.enqueue(part.obj)
179
+ }
180
+ else if(typeof part.obj === 'string'){
181
+ controller.enqueue(encoder.encode(part.obj))
182
+ }
183
+ else {
184
+ // console.log('error', typeof part.obj, part.obj)
185
+ throw new Error('unknown part type')
186
+ }
120
187
 
121
- // const test = '--X-INSOMNIA-BOUNDARY\r\n' +
122
- // 'Content-Disposition: form-data; name="u"\r\n' +
123
- // '\r\n' +
124
- // 'alice\r\n' +
125
- // '--X-INSOMNIA-BOUNDARY\r\n' +
126
- // 'Content-Disposition: form-data; name="user"\r\n' +
127
- // '\r\n' +
128
- // 'jeff\r\n' +
129
- // '--X-INSOMNIA-BOUNDARY--\r\n'
130
- // const result = Multipart.parse(test, 'X-INSOMNIA-BOUNDARY')
131
- // console.log(result)
132
-
133
-
134
- // const test = [
135
- // '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k',
136
- // 'Content-Disposition: form-data; '
137
- // + 'name="upload_file_0"; filename="テスト.dat"',
138
- // 'Content-Type: application/octet-stream',
139
- // '',
140
- // 'A'.repeat(1023),
141
- // '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--'
142
- // ].join('\r\n')
143
- // const result = Multipart.parse(test, '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k')
144
- // console.log(result)
145
-
146
-
147
- // const test = '--X-INSOMNIA-BOUNDARY--\r\n'
148
- // const result = Multipart.parse(test, 'X-INSOMNIA-BOUNDARY')
149
- // console.log(result)
188
+ controller.enqueue(encoder.encode(MULTIPART_SEPARATOR))
189
+ }
190
+
191
+ controller.enqueue(encoder.encode(boundaryEnd))
192
+
193
+ controller.close()
194
+ }
195
+ })
196
+ }
197
+ }
@@ -0,0 +1,205 @@
1
+ // https://datatracker.ietf.org/doc/html/rfc7240
2
+ // https://www.rfc-editor.org/rfc/rfc7240#section-3
3
+
4
+ export const SEPARATOR = {
5
+ PREFERENCE: ',',
6
+ PARAMS: ';',
7
+ PARAM_KVP: '=',
8
+ KVP: '='
9
+ }
10
+
11
+ export const QUOTE = '"'
12
+
13
+ export const DIRECTIVE_RESPOND_ASYNC = 'respond-async'
14
+ export const DIRECTIVE_WAIT = 'wait'
15
+ export const DIRECTIVE_HANDLING = 'handling'
16
+ export const DIRECTIVE_REPRESENTATION = 'return'
17
+ export const DIRECTIVE_TIMEZONE = 'timezone'
18
+
19
+ export const DIRECTIVE_HANDLING_STRICT = 'strict'
20
+ export const DIRECTIVE_HANDLING_LENIENT = 'lenient'
21
+
22
+ export const DIRECTIVE_REPRESENTATION_MINIMAL = 'minimal'
23
+ export const DIRECTIVE_REPRESENTATION_HEADERS_ONLY = 'headers-only'
24
+ export const DIRECTIVE_REPRESENTATION_FULL = 'representation'
25
+
26
+
27
+ /**
28
+ * @param {string|undefined} value
29
+ */
30
+ export function stripQuotes(value) {
31
+ if(value === undefined) { return undefined }
32
+ return value.substring(1, value.length - 1)
33
+ }
34
+
35
+ /**
36
+ * @param {string|undefined} value
37
+ */
38
+ export function isQuoted(value) {
39
+ if(value === undefined) { return false }
40
+ if(value.length < 2) { return false }
41
+ if(!value.startsWith(QUOTE)) { return false }
42
+ if(!value.endsWith(QUOTE)) { return false }
43
+ return true
44
+ }
45
+
46
+ /**
47
+ * @typedef {Object} Preference
48
+ * @property {string|undefined} value
49
+ * @property {Map<string, string|undefined>} parameters
50
+ */
51
+
52
+ /**
53
+ * @template P
54
+ * @typedef {Object} RequestPreferencesBase
55
+ * @property {boolean|undefined} [asynchronous]
56
+ * @property {string|undefined} [representation]
57
+ * @property {string|undefined} [handling]
58
+ * @property {number|undefined} [wait]
59
+ * @property {string|undefined} [timezone]
60
+ * @property {Map<string, P>} preferences
61
+ */
62
+
63
+ /** @typedef {RequestPreferencesBase<Preference>} RequestPreferences */
64
+ /** @typedef {RequestPreferencesBase<Partial<Omit<Preference,'parameters'>>|undefined>} AppliedRequestPreferences */
65
+
66
+ export class Preferences {
67
+ /**
68
+ * @param {string|undefined} header
69
+ * @returns {RequestPreferences|undefined}
70
+ */
71
+ static parse(header) {
72
+ if(header === undefined) { return undefined }
73
+
74
+ const preferences = new Map(header.split(SEPARATOR.PREFERENCE)
75
+ .map(pref => {
76
+ const [ kvp, ...params ] = pref.trim().split(SEPARATOR.PARAMS)
77
+ const [ key, rawValue ] = kvp?.split(SEPARATOR.KVP) ?? []
78
+
79
+ if(key === undefined) { return {} }
80
+ const valueOrEmpty = isQuoted(rawValue) ? stripQuotes(rawValue) : rawValue
81
+ const value = (valueOrEmpty !== '') ? valueOrEmpty : undefined
82
+
83
+ const parameters = new Map(params
84
+ .map(param => {
85
+ const [ pKey, rawPValue ] = param.split(SEPARATOR.PARAM_KVP)
86
+ if(pKey === undefined) { return {} }
87
+ const trimmedRawPValue = rawPValue?.trim()
88
+ const pValueOrEmpty = isQuoted(trimmedRawPValue) ? stripQuotes(trimmedRawPValue) : trimmedRawPValue
89
+ const pValue = (pValueOrEmpty !== '') ? pValueOrEmpty : undefined
90
+ return { key: pKey.trim(), value: pValue }
91
+ })
92
+ .filter(item => item.key !== undefined)
93
+ .map(item => ([ item.key, item.value ]))
94
+ )
95
+
96
+ return { key, value, parameters }
97
+ })
98
+ .filter(item => item.key !== undefined)
99
+ .map(item => ([
100
+ item.key,
101
+ { value: item.value, parameters: item.parameters }
102
+ ]))
103
+ )
104
+
105
+ //
106
+ const asynchronous = preferences.get(DIRECTIVE_RESPOND_ASYNC) !== undefined
107
+ const representation = preferences.get(DIRECTIVE_REPRESENTATION)?.value
108
+ const handling = preferences.get(DIRECTIVE_HANDLING)?.value
109
+ const wait = Number.parseInt(preferences.get(DIRECTIVE_WAIT)?.value ?? '')
110
+ const timezone = preferences.get(DIRECTIVE_TIMEZONE)?.value
111
+
112
+ return {
113
+ asynchronous,
114
+ representation,
115
+ handling,
116
+ wait: Number.isFinite(wait) ? wait : undefined,
117
+ timezone,
118
+ preferences
119
+ }
120
+ }
121
+ }
122
+
123
+ export class AppliedPreferences {
124
+
125
+ /**
126
+ * @param {Map<string, string|undefined>} preferences
127
+ */
128
+ static #encode_Map(preferences) {
129
+ return [ ...preferences.entries()
130
+ .map(([ key, value ]) => {
131
+ // todo check if value should be quoted
132
+ if(value !== undefined) { return `${key}${SEPARATOR.KVP}${value}` }
133
+ return key
134
+ }) ]
135
+ .join(SEPARATOR.PREFERENCE)
136
+ }
137
+
138
+ /**
139
+ * @param {Partial<AppliedRequestPreferences>|Map<string, string|undefined>|undefined} preferences
140
+ */
141
+ static encode(preferences) {
142
+ if(preferences === undefined) { return undefined}
143
+ if(preferences instanceof Map) {
144
+ return AppliedPreferences.#encode_Map(preferences)
145
+ }
146
+
147
+ const applied = new Map()
148
+
149
+ for(const [ key, pref ] of preferences.preferences?.entries() ?? []) {
150
+ applied.set(key, pref?.value)
151
+ }
152
+
153
+ if(preferences.asynchronous === true) { applied.set(DIRECTIVE_RESPOND_ASYNC, undefined) }
154
+ if(preferences.asynchronous === false) { applied.delete(DIRECTIVE_RESPOND_ASYNC) }
155
+ if(preferences.representation !== undefined) { applied.set(DIRECTIVE_REPRESENTATION, preferences.representation) }
156
+ if(preferences.handling !== undefined) { applied.set(DIRECTIVE_HANDLING, preferences.handling) }
157
+ if(preferences.wait !== undefined) { applied.set(DIRECTIVE_WAIT, preferences.wait) }
158
+ if(preferences.timezone !== undefined) { applied.set(DIRECTIVE_TIMEZONE, preferences.timezone) }
159
+
160
+ return AppliedPreferences.#encode_Map(applied)
161
+ }
162
+ }
163
+
164
+
165
+ // console.log(AppliedPreferences.encode(undefined))
166
+ // console.log(AppliedPreferences.encode({ }))
167
+ // console.log(AppliedPreferences.encode({ wait: 10 }))
168
+ // console.log(AppliedPreferences.encode({ asynchronous: undefined }))
169
+ // console.log(AppliedPreferences.encode({ asynchronous: false }))
170
+ // console.log(AppliedPreferences.encode({ asynchronous: true }))
171
+ // console.log(AppliedPreferences.encode({ preferences: new Map([
172
+ // [ 'respond-async', { value: undefined } ]
173
+ // ]) }))
174
+ // console.log(AppliedPreferences.encode({
175
+ // asynchronous: false,
176
+ // preferences: new Map([
177
+ // [ 'respond-async', { value: 'fake' } ]
178
+ // ]) }))
179
+ // console.log(AppliedPreferences.encode({ asynchronous: true, wait: 100 }))
180
+ // console.log(AppliedPreferences.encode({
181
+ // representation: DIRECTIVE_REPRESENTATION_MINIMAL,
182
+ // preferences: new Map([
183
+ // ['foo', { value: 'bar', parameters: new Map([ [ 'biz', 'bang' ] ]) } ],
184
+ // [ 'fake', undefined ]
185
+ // ])
186
+ // }))
187
+
188
+ // console.log(Preferences.parse('handling=lenient, wait=100, respond-async'))
189
+ // console.log(Preferences.parse(' foo; bar')?.preferences)
190
+ // console.log(Preferences.parse(' foo; bar=""')?.preferences)
191
+ // console.log(Preferences.parse(' foo=""; bar')?.preferences)
192
+ // console.log(Preferences.parse(' foo =""; bar;biz; bang ')?.preferences)
193
+ // console.log(Preferences.parse('return=minimal; foo="some parameter"')?.preferences)
194
+
195
+ // console.log(Preferences.parse('timezone=America/Los_Angeles'))
196
+ // console.log(Preferences.parse('return=headers-only'))
197
+ // console.log(Preferences.parse('return=minimal'))
198
+ // console.log(Preferences.parse('return=representation'))
199
+ // console.log(Preferences.parse('respond-async, wait=10`'))
200
+ // console.log(Preferences.parse('priority=5'))
201
+ // console.log(Preferences.parse('foo; bar'))
202
+ // console.log(Preferences.parse('foo; bar=""'))
203
+ // console.log(Preferences.parse('foo=""; bar'))
204
+ // console.log(Preferences.parse('handling=lenient, wait=100, respond-async'))
205
+ // console.log(Preferences.parse('return=minimal; foo="some parameter"'))
package/src/range.js ADDED
@@ -0,0 +1,154 @@
1
+ import { RANGE_UNITS_BYTES } from "./response/defs.js"
2
+
3
+ export const RANGE_EQUAL = '='
4
+ export const RANGE_SEPARATOR = '-'
5
+ export const RANGE_LIST_SEPARATOR = ','
6
+
7
+ /** @type {''} */
8
+ export const RANGE_EMPTY = ''
9
+
10
+ /**
11
+ * @typedef {Object} RangeValueFixed
12
+ * @property {number} start
13
+ * @property {number} end
14
+ */
15
+
16
+ /**
17
+ * @typedef {Object} RangeValueOpenEnded
18
+ * @property {number} start
19
+ * @property {''} end
20
+ */
21
+
22
+ /**
23
+ * @typedef {Object} RangeValueFromEnd
24
+ * @property {''} start
25
+ * @property {number} end
26
+ */
27
+
28
+ /** @typedef {RangeValueFixed | RangeValueOpenEnded | RangeValueFromEnd} RangeValue */
29
+
30
+ /** @typedef {RangeValueFixed} NormalizedRangeValue */
31
+
32
+ /**
33
+ * @template RV
34
+ * @typedef {Object} RangeDirective
35
+ * @property {'bytes'|'none'|undefined} units
36
+ * @property {Array<RV>} ranges
37
+ */
38
+
39
+ /**
40
+ * @typedef {Object} RangeDirectiveInfo
41
+ * @property {boolean} exceeds
42
+ * @property {boolean} overlap
43
+ */
44
+
45
+
46
+ export class Range {
47
+ /**
48
+ * @param {string|undefined} rangeHeader
49
+ * @returns {RangeDirective<RangeValue>|undefined}
50
+ */
51
+ static parse(rangeHeader) {
52
+ if(rangeHeader === undefined) { return undefined }
53
+ if(!rangeHeader.startsWith(RANGE_UNITS_BYTES)) { return undefined }
54
+ if(!(rangeHeader.substring(RANGE_UNITS_BYTES.length, RANGE_UNITS_BYTES.length + 1) === RANGE_EQUAL)) { return undefined }
55
+ const rangeStr = rangeHeader.substring(RANGE_UNITS_BYTES.length + RANGE_EQUAL.length).trim()
56
+ if(rangeStr === '') { return undefined }
57
+
58
+ const ranges = rangeStr.split(RANGE_LIST_SEPARATOR)
59
+ .map(range => range.trim())
60
+ .map(range => {
61
+ const [ startStr, endStr ] = range.split(RANGE_SEPARATOR)
62
+ if(startStr === undefined) { return undefined }
63
+ if(endStr === undefined) { return undefined }
64
+ if(startStr === RANGE_EMPTY && endStr === RANGE_EMPTY) { return undefined }
65
+
66
+ const start = Number.parseInt(startStr, 10)
67
+ const end = Number.parseInt(endStr, 10)
68
+
69
+ if(startStr === RANGE_EMPTY) {
70
+ if(!Number.isInteger(end)) { return undefined }
71
+ if(end === 0) { return undefined }
72
+ return { start: RANGE_EMPTY, end }
73
+ }
74
+
75
+ if(endStr === RANGE_EMPTY) {
76
+ if(!Number.isInteger(start)) { return undefined }
77
+ return { start, end: RANGE_EMPTY }
78
+ }
79
+
80
+ if(!(Number.isInteger(start) && Number.isInteger(end))) { return undefined }
81
+ return { start, end }
82
+ })
83
+ .filter(range => range !== undefined)
84
+
85
+ if(ranges.length === 0) { return undefined }
86
+
87
+ return {
88
+ units: RANGE_UNITS_BYTES,
89
+ ranges
90
+ }
91
+ }
92
+
93
+ /**
94
+ * @param {RangeDirective<RangeValue>|undefined} directive
95
+ * @param {number} contentLength
96
+ * @returns {RangeDirectiveInfo & RangeDirective<NormalizedRangeValue>|undefined}
97
+ */
98
+ static normalize(directive, contentLength) {
99
+ if(directive === undefined) { return undefined }
100
+
101
+ /** @type {Array<NormalizedRangeValue>} */
102
+ const normalizedRanges = directive.ranges.map(({ start, end }) => {
103
+ if(end === RANGE_EMPTY) { return { start, end: contentLength - 1 } }
104
+ if(start === RANGE_EMPTY) { return { start: contentLength - end, end: contentLength - 1 } }
105
+ return { start, end }
106
+ })
107
+
108
+ const exceeds = normalizedRanges.reduce((acc, value) => (acc || (value.start >= contentLength) || (value.end >= contentLength)), false)
109
+
110
+ const { overlap } = normalizedRanges
111
+ .toSorted((a, b) => a.start - b.start)
112
+ .reduce((acc, item) => ({
113
+ overlap: acc.overlap || acc.end > item.start,
114
+ end: item.end
115
+ }), { overlap: false, end: 0 })
116
+
117
+ return {
118
+ units: directive.units,
119
+ overlap,
120
+ exceeds,
121
+ ranges: normalizedRanges
122
+ }
123
+ }
124
+ }
125
+
126
+ // console.log(Range.parse(''))
127
+ // console.log(Range.parse('='))
128
+ // console.log(Range.parse('foo'))
129
+ // console.log(Range.parse('bytes'))
130
+ // console.log(Range.parse('bytes='))
131
+ // console.log(Range.parse('bytes=-'))
132
+ // console.log(Range.parse('bytes=foo'))
133
+ // console.log(Range.parse('bytes=0-foo'))
134
+ // console.log(Range.parse('bytes=0-0xff'))
135
+ // console.log()
136
+ // console.log(Range.parse('bytes=1024-'))
137
+ // console.log(Range.parse('bytes=-1024'))
138
+ // console.log(Range.parse('bytes=0-1024'))
139
+ // console.log()
140
+ // console.log(Range.parse('bytes=0-0,-1'))
141
+ // console.log(Range.parse('bytes=0-1024, -1024'))
142
+ // console.log(Range.parse('bytes= 0-999, 4500-5499, -1000'))
143
+ // console.log(Range.parse('bytes=500-600,601-999'))
144
+
145
+ // console.log('------')
146
+ // console.log(Range.normalize(Range.parse('bytes=1024-'), 5000))
147
+ // console.log(Range.normalize(Range.parse('bytes=-1024'), 5000))
148
+ // console.log(Range.normalize(Range.parse('bytes=0-1024'), 5000))
149
+ // console.log(Range.normalize(Range.parse('bytes=0-0,-1'), 10000)) // 0 and 9999
150
+ // console.log(Range.normalize(Range.parse('bytes=0-1024, -1024'), 5000))
151
+ // console.log(Range.normalize(Range.parse('bytes= 0-999, 4500-5499, -1000'), 5000))
152
+ // console.log(Range.normalize(Range.parse('bytes=500-600,601-999'), 5000))
153
+
154
+ // console.log(Range.normalize(Range.parse('bytes=-500'), 10000)) // 9500-9999
@@ -1,4 +1,5 @@
1
1
  import http2 from 'node:http2'
2
+
2
3
  import { send } from './send-util.js'
3
4
 
4
5
  /** @import { ServerHttp2Stream } from 'node:http2' */