fast-uri 4.1.2 → 4.1.4
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/.github/workflows/ci.yml +1 -1
- package/.github/workflows/lock-threads.yml +1 -1
- package/SECURITY.md +6 -1
- package/benchmark/benchmark.mjs +71 -0
- package/index.js +223 -43
- package/lib/schemes.js +384 -7
- package/lib/utils.js +310 -89
- package/package.json +1 -1
- package/test/component-safe-serialization.test.js +163 -0
- package/test/equal.test.js +43 -3
- package/test/fixtures/uri-js-parse.json +24 -1
- package/test/fixtures/uri-js-serialize.json +23 -1
- package/test/ipv6-canonical.test.js +34 -0
- package/test/ipv6-validation.test.js +154 -0
- package/test/malformed-percent.test.js +77 -0
- package/test/malformed-urn.test.js +61 -0
- package/test/parse.test.js +145 -0
- package/test/reserved-path-normalization.test.js +109 -0
- package/test/scheme-validation.test.js +122 -0
- package/test/security-normalization.test.js +101 -0
- package/test/security-utf8-encoding.test.js +0 -0
- package/test/security.test.js +75 -3
- package/test/serialize.test.js +144 -0
- package/test/uri-js-compatibility.test.js +0 -3
- package/test/uri-js.test.js +3 -3
- package/test/urn-full-input.test.js +29 -0
- package/test/websocket-query-preservation.test.js +24 -0
- package/types/index.d.ts +4 -0
- package/types/index.tst.ts +16 -0
package/lib/schemes.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
const { isUUID } = require('./utils')
|
|
4
|
-
const URN_REG =
|
|
3
|
+
const { isUUID, BYTE_HEX, percentEncodeNonAscii, nonSimpleMailtoDomain } = require('./utils')
|
|
4
|
+
const URN_REG = /^([\da-z][\d\-a-z]{0,31}):((?:[\w!$'()*+,\-./:;=@]|%[\da-f]{2})+)$/iu
|
|
5
5
|
|
|
6
6
|
const supportedSchemeNames = /** @type {const} */ (['http', 'https', 'ws',
|
|
7
|
-
'wss', 'urn', 'urn:uuid'])
|
|
7
|
+
'wss', 'urn', 'urn:uuid', 'mailto'])
|
|
8
8
|
|
|
9
9
|
/** @typedef {supportedSchemeNames[number]} SchemeName */
|
|
10
10
|
|
|
@@ -113,9 +113,14 @@ function wsSerialize (wsComponent) {
|
|
|
113
113
|
|
|
114
114
|
// reconstruct path from resource name
|
|
115
115
|
if (wsComponent.resourceName) {
|
|
116
|
-
const
|
|
116
|
+
const queryIndex = wsComponent.resourceName.indexOf('?')
|
|
117
|
+
const path = queryIndex === -1
|
|
118
|
+
? wsComponent.resourceName
|
|
119
|
+
: wsComponent.resourceName.slice(0, queryIndex)
|
|
117
120
|
wsComponent.path = (path && path !== '/' ? path : undefined)
|
|
118
|
-
wsComponent.query =
|
|
121
|
+
wsComponent.query = queryIndex === -1
|
|
122
|
+
? undefined
|
|
123
|
+
: wsComponent.resourceName.slice(queryIndex + 1)
|
|
119
124
|
wsComponent.resourceName = undefined
|
|
120
125
|
}
|
|
121
126
|
|
|
@@ -132,7 +137,7 @@ function urnParse (urnComponent, options) {
|
|
|
132
137
|
return urnComponent
|
|
133
138
|
}
|
|
134
139
|
const matches = urnComponent.path.match(URN_REG)
|
|
135
|
-
if (matches) {
|
|
140
|
+
if (matches && matches[0] === urnComponent.path) {
|
|
136
141
|
const scheme = options.scheme || urnComponent.scheme || 'urn'
|
|
137
142
|
urnComponent.nid = matches[1].toLowerCase()
|
|
138
143
|
urnComponent.nss = matches[2]
|
|
@@ -235,13 +240,385 @@ const urnuuid = /** @type {SchemeHandler} */ ({
|
|
|
235
240
|
skipNormalize: true
|
|
236
241
|
})
|
|
237
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Allow-sets for `encodeWithAllow`, each a `{ all, table, allowNonAscii }`
|
|
245
|
+
* descriptor built by `allowSet`:
|
|
246
|
+
*
|
|
247
|
+
* - `LOCAL_PART` is the intersection of upstream uri-js's VCHAR and
|
|
248
|
+
* NOT_PATH_NOSCHEME, minus ",": the serializer joins recipients with "," and
|
|
249
|
+
* `mailtoParse` splits the path on it, so a comma left literal inside a local
|
|
250
|
+
* part would silently become a recipient delimiter.
|
|
251
|
+
* - `HFNAME` is the qchar allow-set used for header names/values.
|
|
252
|
+
* - `DTEXT` is RFC 6068 dtext-no-obs restricted to characters that are also
|
|
253
|
+
* safe in a URI path; `DTEXT_IRI` additionally permits non-ASCII for
|
|
254
|
+
* `unicodeSupport`.
|
|
255
|
+
*/
|
|
256
|
+
const LOCAL_PART_ALLOWED = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~!$'()*+="
|
|
257
|
+
const HFNAME_ALLOWED = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~!$'()*+,;:@"
|
|
258
|
+
const DTEXT_ALLOWED = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~!$'()*+;:"
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* @typedef {Object} AllowSet
|
|
262
|
+
* @property {RegExp} all - matches a whole string needing no encoding at all.
|
|
263
|
+
* @property {Uint8Array} table - per-ASCII-code allow lookup for the slow path.
|
|
264
|
+
* @property {boolean} allowNonAscii - pass non-ASCII code points through (IRI).
|
|
265
|
+
*/
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* `all` deliberately excludes "%" and (for the IRI variant) surrogates, so any
|
|
269
|
+
* input needing escape normalization or surrogate repair falls to the slow path.
|
|
270
|
+
*
|
|
271
|
+
* @param {string} chars
|
|
272
|
+
* @param {boolean} [allowNonAscii=false]
|
|
273
|
+
* @returns {AllowSet}
|
|
274
|
+
*/
|
|
275
|
+
function allowSet (chars, allowNonAscii = false) {
|
|
276
|
+
const table = new Uint8Array(128)
|
|
277
|
+
for (let i = 0; i < chars.length; i++) {
|
|
278
|
+
table[chars.charCodeAt(i)] = 1
|
|
279
|
+
}
|
|
280
|
+
const escaped = chars.replace(/[\\\]^-]/gu, '\\$&')
|
|
281
|
+
const cls = allowNonAscii ? `[${escaped}]|[^\\0-\\x7F\\uD800-\\uDFFF]` : `[${escaped}]`
|
|
282
|
+
return { all: new RegExp(`^(?:${cls})*$`, 'u'), table, allowNonAscii }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const LOCAL_PART = allowSet(LOCAL_PART_ALLOWED)
|
|
286
|
+
const HFNAME = allowSet(HFNAME_ALLOWED)
|
|
287
|
+
const DTEXT = allowSet(DTEXT_ALLOWED)
|
|
288
|
+
const DTEXT_IRI = allowSet(DTEXT_ALLOWED, true)
|
|
289
|
+
|
|
290
|
+
const HEX_PAIR = /^[\da-f]{2}$/iu
|
|
291
|
+
const MAILTO_DOMAIN_LITERAL = /^\[[\x21-\x5A\x5E-\x7E]*\]$/u
|
|
292
|
+
const MAILTO_DOMAIN_ERROR = 'URI mailto has an invalid recipient domain.'
|
|
293
|
+
const HAS_SURROGATE = /[\uD800-\uDFFF]/u
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* @param {string} str
|
|
297
|
+
* @returns {string}
|
|
298
|
+
*/
|
|
299
|
+
function decodeHex (str) {
|
|
300
|
+
if (typeof str !== 'string' || str.indexOf('%') === -1) {
|
|
301
|
+
return str
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
return decodeURIComponent(str)
|
|
305
|
+
} catch {
|
|
306
|
+
return str
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Replaces lone surrogates with U+FFFD, leaving valid pairs intact. Only called
|
|
312
|
+
* when `HAS_SURROGATE` matched, so the scan cost is not on the common path.
|
|
313
|
+
* (`String.prototype.toWellFormed` would do this natively but is Node 20+.)
|
|
314
|
+
*
|
|
315
|
+
* @param {string} input
|
|
316
|
+
* @returns {string}
|
|
317
|
+
*/
|
|
318
|
+
function replaceLoneSurrogates (input) {
|
|
319
|
+
let result = ''
|
|
320
|
+
for (let i = 0; i < input.length; i++) {
|
|
321
|
+
const code = input.charCodeAt(i)
|
|
322
|
+
if (code >= 0xD800 && code <= 0xDBFF && i + 1 < input.length) {
|
|
323
|
+
const low = input.charCodeAt(i + 1)
|
|
324
|
+
if (low >= 0xDC00 && low <= 0xDFFF) {
|
|
325
|
+
result += input[i] + input[i + 1]
|
|
326
|
+
i++
|
|
327
|
+
continue
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
result += (code >= 0xD800 && code <= 0xDFFF) ? '\uFFFD' : input[i]
|
|
331
|
+
}
|
|
332
|
+
return result
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Percent-encodes everything outside `set`, preserving existing valid escapes
|
|
337
|
+
* (uppercased) and repairing lone surrogates.
|
|
338
|
+
*
|
|
339
|
+
* ASCII uses `BYTE_HEX` rather than `encodeURIComponent`. That is equivalent
|
|
340
|
+
* only because every character `encodeURIComponent` leaves unescaped
|
|
341
|
+
* (`A-Za-z0-9-_.!~*'()`) is present in all of the allow-sets above, so no such
|
|
342
|
+
* character ever reaches the encode branch. Re-check if a set is narrowed.
|
|
343
|
+
*
|
|
344
|
+
* @param {string} input
|
|
345
|
+
* @param {AllowSet} set
|
|
346
|
+
* @returns {string}
|
|
347
|
+
*/
|
|
348
|
+
function encodeWithAllow (input, set) {
|
|
349
|
+
if (set.all.test(input)) {
|
|
350
|
+
return input
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const table = set.table
|
|
354
|
+
let result = ''
|
|
355
|
+
for (let i = 0; i < input.length; i++) {
|
|
356
|
+
const code = input.charCodeAt(i)
|
|
357
|
+
|
|
358
|
+
if (code < 0x80) {
|
|
359
|
+
if (table[code] === 1) {
|
|
360
|
+
result += input[i]
|
|
361
|
+
} else if (code === 0x25 && i + 2 < input.length && HEX_PAIR.test(input.slice(i + 1, i + 3))) {
|
|
362
|
+
result += '%' + input.slice(i + 1, i + 3).toUpperCase()
|
|
363
|
+
i += 2
|
|
364
|
+
} else {
|
|
365
|
+
result += BYTE_HEX[code]
|
|
366
|
+
}
|
|
367
|
+
continue
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (code < 0xD800 || code > 0xDFFF) {
|
|
371
|
+
result += set.allowNonAscii ? input[i] : percentEncodeNonAscii(code)
|
|
372
|
+
continue
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (code <= 0xDBFF && i + 1 < input.length) {
|
|
376
|
+
const low = input.charCodeAt(i + 1)
|
|
377
|
+
if (low >= 0xDC00 && low <= 0xDFFF) {
|
|
378
|
+
result += set.allowNonAscii
|
|
379
|
+
? input[i] + input[i + 1]
|
|
380
|
+
: percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
|
|
381
|
+
i++
|
|
382
|
+
continue
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
result += set.allowNonAscii ? '\uFFFD' : percentEncodeNonAscii(0xFFFD)
|
|
387
|
+
}
|
|
388
|
+
return result
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* @param {string} domain
|
|
393
|
+
* @param {import('../types/index').Options} [options]
|
|
394
|
+
* @param {{error?:string}} [component]
|
|
395
|
+
* @returns {string}
|
|
396
|
+
*/
|
|
397
|
+
function mailtoNormalizeDomain (domain, options, component) {
|
|
398
|
+
const normalizedDomain = String(domain).toLowerCase()
|
|
399
|
+
|
|
400
|
+
if (MAILTO_DOMAIN_LITERAL.test(normalizedDomain)) {
|
|
401
|
+
return normalizedDomain
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// `hostname` is the identity for these, so skip constructing a WHATWG URL.
|
|
405
|
+
if (normalizedDomain !== '' && !nonSimpleMailtoDomain(normalizedDomain)) {
|
|
406
|
+
return normalizedDomain
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
try {
|
|
410
|
+
const parsedDomain = new URL('http://' + normalizedDomain)
|
|
411
|
+
if (
|
|
412
|
+
parsedDomain.username ||
|
|
413
|
+
parsedDomain.password ||
|
|
414
|
+
parsedDomain.port ||
|
|
415
|
+
parsedDomain.pathname !== '/' ||
|
|
416
|
+
parsedDomain.search ||
|
|
417
|
+
parsedDomain.hash ||
|
|
418
|
+
!parsedDomain.hostname
|
|
419
|
+
) {
|
|
420
|
+
throw new Error(MAILTO_DOMAIN_ERROR)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return options && options.unicodeSupport
|
|
424
|
+
? normalizedDomain
|
|
425
|
+
: parsedDomain.hostname
|
|
426
|
+
} catch {
|
|
427
|
+
if (component) {
|
|
428
|
+
component.error = component.error || MAILTO_DOMAIN_ERROR
|
|
429
|
+
}
|
|
430
|
+
return normalizedDomain
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Percent-encodes everything in a recipient domain that is not `dtext-no-obs`
|
|
436
|
+
* (RFC 6068). Serialize-only: `mailtoNormalizeDomain` returns the domain
|
|
437
|
+
* verbatim when validation fails, and `MAILTO_DOMAIN_LITERAL` accepts the full
|
|
438
|
+
* RFC 5321 dcontent range (which includes "?", "#", "&" and "/"), so without
|
|
439
|
+
* this an attacker-supplied recipient could inject header fields or a fragment
|
|
440
|
+
* into the serialized URI. `mailtoParse` deliberately reports the raw domain
|
|
441
|
+
* instead, so it must not use this.
|
|
442
|
+
* @param {string} domain
|
|
443
|
+
* @param {import('../types/index').Options} [options]
|
|
444
|
+
* @returns {string}
|
|
445
|
+
*/
|
|
446
|
+
function mailtoEncodeDomain (domain, options) {
|
|
447
|
+
const set = options && options.unicodeSupport ? DTEXT_IRI : DTEXT
|
|
448
|
+
|
|
449
|
+
// Keep the delimiters of a domain literal, encode its contents.
|
|
450
|
+
if (domain.length > 1 && domain[0] === '[' && domain[domain.length - 1] === ']') {
|
|
451
|
+
return '[' + encodeWithAllow(domain.slice(1, -1), set) + ']'
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return encodeWithAllow(domain, set)
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* @param {import('../types/index').URIComponent} component
|
|
459
|
+
* @param {import('../types/index').Options} options
|
|
460
|
+
* @returns {import('../types/index').URIComponent}
|
|
461
|
+
*/
|
|
462
|
+
function mailtoParse (component, options) {
|
|
463
|
+
const mailtoComponent = component
|
|
464
|
+
|
|
465
|
+
// The handler sets `skipNormalize`, so path and query arrive raw. Everything
|
|
466
|
+
// here goes through `decodeHex`, which subsumes the generic normalizers --
|
|
467
|
+
// except that they also fold lone surrogates to U+FFFD, so do that here.
|
|
468
|
+
let rawPath = mailtoComponent.path
|
|
469
|
+
let rawQuery = mailtoComponent.query
|
|
470
|
+
if (rawPath && HAS_SURROGATE.test(rawPath)) rawPath = replaceLoneSurrogates(rawPath)
|
|
471
|
+
if (rawQuery && HAS_SURROGATE.test(rawQuery)) rawQuery = replaceLoneSurrogates(rawQuery)
|
|
472
|
+
|
|
473
|
+
const to = rawPath
|
|
474
|
+
? (rawPath.indexOf(',') === -1 ? [rawPath] : rawPath.split(','))
|
|
475
|
+
: []
|
|
476
|
+
mailtoComponent.path = undefined
|
|
477
|
+
|
|
478
|
+
if (rawQuery) {
|
|
479
|
+
// Null prototype: header names come from untrusted input, so `headers[name]`
|
|
480
|
+
// must not resolve to inherited members ("constructor", "toString", ...),
|
|
481
|
+
// and "__proto__" has to land as a plain own property instead of hitting
|
|
482
|
+
// Object.prototype's setter and being silently dropped. Allocated lazily so
|
|
483
|
+
// the common subject/body-only URI does not pay for it.
|
|
484
|
+
/** @type {Record<string,string>|null} */
|
|
485
|
+
let headers = null
|
|
486
|
+
let start = 0
|
|
487
|
+
while (start <= rawQuery.length) {
|
|
488
|
+
let end = rawQuery.indexOf('&', start)
|
|
489
|
+
if (end === -1) end = rawQuery.length
|
|
490
|
+
const token = rawQuery.slice(start, end)
|
|
491
|
+
start = end + 1
|
|
492
|
+
|
|
493
|
+
const eqIdx = token.indexOf('=')
|
|
494
|
+
if (eqIdx !== token.lastIndexOf('=')) {
|
|
495
|
+
mailtoComponent.error = mailtoComponent.error || 'URI mailto has malformed header fields.'
|
|
496
|
+
continue
|
|
497
|
+
}
|
|
498
|
+
const name = eqIdx === -1 ? token : token.slice(0, eqIdx)
|
|
499
|
+
const value = eqIdx === -1 ? '' : token.slice(eqIdx + 1)
|
|
500
|
+
if (name === 'to') {
|
|
501
|
+
const addrs = value.split(',')
|
|
502
|
+
for (let j = 0; j < addrs.length; j++) to.push(addrs[j])
|
|
503
|
+
continue
|
|
504
|
+
}
|
|
505
|
+
if (name === 'subject') {
|
|
506
|
+
mailtoComponent.subject = decodeHex(value)
|
|
507
|
+
continue
|
|
508
|
+
}
|
|
509
|
+
if (name === 'body') {
|
|
510
|
+
mailtoComponent.body = decodeHex(value)
|
|
511
|
+
continue
|
|
512
|
+
}
|
|
513
|
+
if (headers === null) headers = /** @type {Record<string,string>} */ (Object.create(null))
|
|
514
|
+
headers[decodeHex(name)] = decodeHex(value)
|
|
515
|
+
}
|
|
516
|
+
if (headers !== null) mailtoComponent.headers = headers
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
mailtoComponent.query = undefined
|
|
520
|
+
|
|
521
|
+
for (let i = 0; i < to.length; i++) {
|
|
522
|
+
const rawAddr = to[i]
|
|
523
|
+
const atIdx = rawAddr.lastIndexOf('@')
|
|
524
|
+
if (atIdx < 0) {
|
|
525
|
+
mailtoComponent.error = mailtoComponent.error || MAILTO_DOMAIN_ERROR
|
|
526
|
+
to[i] = decodeHex(rawAddr)
|
|
527
|
+
continue
|
|
528
|
+
}
|
|
529
|
+
const local = decodeHex(rawAddr.slice(0, atIdx))
|
|
530
|
+
const domain = mailtoNormalizeDomain(decodeHex(rawAddr.slice(atIdx + 1)), options, mailtoComponent)
|
|
531
|
+
to[i] = local + '@' + domain
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (to.length) mailtoComponent.to = to
|
|
535
|
+
|
|
536
|
+
return mailtoComponent
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* @param {import('../types/index').URIComponent} component
|
|
541
|
+
* @param {import('../types/index').Options} options
|
|
542
|
+
* @returns {import('../types/index').URIComponent}
|
|
543
|
+
*/
|
|
544
|
+
function mailtoSerialize (component, options) {
|
|
545
|
+
const mailtoComponent = component
|
|
546
|
+
const to = Array.isArray(mailtoComponent.to) ? mailtoComponent.to.slice() : []
|
|
547
|
+
if (to.length) {
|
|
548
|
+
for (let i = 0; i < to.length; i++) {
|
|
549
|
+
const addr = String(to[i])
|
|
550
|
+
const atIdx = addr.lastIndexOf('@')
|
|
551
|
+
const rawLocal = atIdx >= 0 ? addr.slice(0, atIdx) : addr
|
|
552
|
+
const rawDomain = atIdx >= 0 ? addr.slice(atIdx + 1) : ''
|
|
553
|
+
const local = encodeWithAllow(rawLocal, LOCAL_PART)
|
|
554
|
+
const decodedDomain = decodeHex(rawDomain)
|
|
555
|
+
// `component` is intentionally not passed: index.js's `serialize` writes
|
|
556
|
+
// the error onto a private copy and returns only a string, so an error
|
|
557
|
+
// recorded here would be unreachable. Safety comes from encoding instead.
|
|
558
|
+
const normalizedDomain = mailtoNormalizeDomain(decodedDomain, options)
|
|
559
|
+
to[i] = local + '@' + mailtoEncodeDomain(normalizedDomain, options)
|
|
560
|
+
}
|
|
561
|
+
// Every recipient is now fully encoded and only the joining commas are
|
|
562
|
+
// literal, so bypass index.js's generic encoder, whose allow-set is
|
|
563
|
+
// narrower than upstream's and would over-encode.
|
|
564
|
+
mailtoComponent.path = to.join(',')
|
|
565
|
+
options.skipEscape = true
|
|
566
|
+
} else {
|
|
567
|
+
// No recipients: drop any caller-supplied path, matching upstream uri-js.
|
|
568
|
+
// `to` is the only recipient source, and `mailtoParse` always moves the
|
|
569
|
+
// path into it, so a mailto component never legitimately carries one.
|
|
570
|
+
mailtoComponent.path = undefined
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const headers = mailtoComponent.headers && typeof mailtoComponent.headers === 'object'
|
|
574
|
+
? Object.assign(Object.create(null), mailtoComponent.headers)
|
|
575
|
+
: Object.create(null)
|
|
576
|
+
|
|
577
|
+
if (mailtoComponent.subject) headers.subject = mailtoComponent.subject
|
|
578
|
+
if (mailtoComponent.body) headers.body = mailtoComponent.body
|
|
579
|
+
mailtoComponent.headers = headers
|
|
580
|
+
|
|
581
|
+
// `headers` has a null prototype and was filled via Object.assign, so every
|
|
582
|
+
// enumerable key is an own property and no hasOwnProperty guard is needed.
|
|
583
|
+
let query = ''
|
|
584
|
+
let count = 0
|
|
585
|
+
for (const name in headers) {
|
|
586
|
+
if (count++ !== 0) query += '&'
|
|
587
|
+
query += encodeWithAllow(name, HFNAME) + '=' + encodeWithAllow(String(headers[name]), HFNAME)
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (count !== 0) {
|
|
591
|
+
mailtoComponent.query = query
|
|
592
|
+
} else {
|
|
593
|
+
mailtoComponent.headers = undefined
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return mailtoComponent
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const mailto = /** @type {SchemeHandler} */ ({
|
|
600
|
+
scheme: 'mailto',
|
|
601
|
+
parse: mailtoParse,
|
|
602
|
+
serialize: mailtoSerialize,
|
|
603
|
+
domainHost: false,
|
|
604
|
+
unicodeSupport: true,
|
|
605
|
+
// `mailtoParse` re-derives every component from the raw path/query via
|
|
606
|
+
// `decodeHex`, which subsumes the generic normalizers, so running them first
|
|
607
|
+
// is wasted work (~28% of parsing a URI with header fields).
|
|
608
|
+
skipNormalize: true,
|
|
609
|
+
// A recipient list has no dot segments to remove, and `removeDotSegments`
|
|
610
|
+
// would rewrite a "./"-prefixed local part.
|
|
611
|
+
absolutePath: true
|
|
612
|
+
})
|
|
613
|
+
|
|
238
614
|
const SCHEMES = /** @type {Record<SchemeName, SchemeHandler>} */ ({
|
|
239
615
|
http,
|
|
240
616
|
https,
|
|
241
617
|
ws,
|
|
242
618
|
wss,
|
|
243
619
|
urn,
|
|
244
|
-
'urn:uuid': urnuuid
|
|
620
|
+
'urn:uuid': urnuuid,
|
|
621
|
+
mailto
|
|
245
622
|
})
|
|
246
623
|
|
|
247
624
|
Object.setPrototypeOf(SCHEMES, null)
|