quico 0.8.0 → 0.9.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/README.md +2 -2
- package/package.json +4 -3
- package/src/client.js +1 -0
- package/src/connect_udp.js +5 -2
- package/src/connect_udp_client.js +1 -0
- package/src/crypto.js +6 -0
- package/src/h3.js +86 -10
- package/src/h3_client.js +1 -0
- package/src/h3_server.js +3 -0
- package/src/qpack.js +11 -2
- package/src/quic_connection.js +179 -40
- package/src/quic_server.js +121 -61
- package/src/quic_socket.js +5 -5
- package/src/server.js +24 -15
- package/src/tls_bridge.js +58 -38
- package/src/transport.js +6 -2
- package/src/utils.js +36 -0
- package/src/webtransport.js +8 -3
package/README.md
CHANGED
|
@@ -155,7 +155,7 @@ stack trace ends in code you can open, not in a `.node` binary.
|
|
|
155
155
|
|---|---|
|
|
156
156
|
| **Node.js 18+** | ESM only (`import`). Tested on 18, 20 and 22. |
|
|
157
157
|
| **No compiler** | Pure JavaScript — no native addons. |
|
|
158
|
-
| **Dependencies** | [`lemon-tls`](https://github.com/colocohen/lemon-tls) (TLS 1.3)
|
|
158
|
+
| **Dependencies** | [`lemon-tls`](https://github.com/colocohen/lemon-tls) (TLS 1.3), [`flat-ranges`](https://www.npmjs.com/package/flat-ranges) (range arithmetic), and `cryptographic-formats` (certificate formats — the dates WebTransport's `serverCertificateHashes` checks; lemon-tls reads certificates with it too). Nothing else. |
|
|
159
159
|
|
|
160
160
|
```bash
|
|
161
161
|
npm install quico
|
|
@@ -2507,7 +2507,7 @@ and you route datagrams yourself.
|
|
|
2507
2507
|
| `quic.earlyDataStatus` | 0-RTT: `null` (none), `'offered'` (a client's, undecided), `'accepted'`, `'rejected'` |
|
|
2508
2508
|
| `quic.isEarlyStream(id)` | Server: whether the stream was opened by 0-RTT data |
|
|
2509
2509
|
| `quic.whenConfirmed(cb)` | `cb()` once the handshake is confirmed (RFC 9001 §4.1.2), at once if it is |
|
|
2510
|
-
| `quic.alpnProtocol`, `servername`, `getCertificate()`, `getTicketInfo()`, `isSessionReused()` | What the handshake decided, with `TLSSocket`'s names |
|
|
2510
|
+
| `quic.alpnProtocol`, `servername`, `getCertificate()`, `getTicketInfo()`, `isSessionReused()` | What the handshake decided, with `TLSSocket`'s names — `getCertificate()` in its shape too (`subject` and `issuer` as objects, `{ CN, O, … }`), lemon-tls's `certificateObject` |
|
|
2511
2511
|
| `quic.on(event, fn)` / `off` | Event subscription |
|
|
2512
2512
|
|
|
2513
2513
|
**Events:** `'connect'`, `'stream'` (`id, data, fin`), `'stream_reset'` (`id, code,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "quico",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "A pure JavaScript implementation of QUIC, HTTP/3, QPACK, and WebTransport for Node.js",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.cjs",
|
|
@@ -80,7 +80,8 @@
|
|
|
80
80
|
}
|
|
81
81
|
],
|
|
82
82
|
"dependencies": {
|
|
83
|
+
"cryptographic-formats": "^0.1.0",
|
|
83
84
|
"flat-ranges": "^2.2.0",
|
|
84
|
-
"lemon-tls": "^0.
|
|
85
|
+
"lemon-tls": "^0.11.0"
|
|
85
86
|
}
|
|
86
|
-
}
|
|
87
|
+
}
|
package/src/client.js
CHANGED
|
@@ -479,6 +479,7 @@ function request(url, options, callback) {
|
|
|
479
479
|
startupCap: options.startupCap, pmtuDiscovery: options.pmtuDiscovery,
|
|
480
480
|
maxUdpPayloadSize: options.maxUdpPayloadSize, initialRtt: options.initialRtt,
|
|
481
481
|
reorderWindow: options.reorderWindow, initialCongestionWindow: options.initialCongestionWindow,
|
|
482
|
+
socketBufferSize: options.socketBufferSize, // the UDP socket's buffers (utils.js)
|
|
482
483
|
// 0-RTT with the saved session (below), off unless asked for.
|
|
483
484
|
earlyData: options.earlyData === true
|
|
484
485
|
};
|
package/src/connect_udp.js
CHANGED
|
@@ -53,7 +53,7 @@ import { EventEmitter } from 'node:events';
|
|
|
53
53
|
import dgram from 'node:dgram';
|
|
54
54
|
import dns from 'node:dns';
|
|
55
55
|
import net from 'node:net';
|
|
56
|
-
import { readVarInt, writeVarInt } from './utils.js';
|
|
56
|
+
import { readVarInt, writeVarInt, checkedSocketBufferSize, udpSocketOptions } from './utils.js';
|
|
57
57
|
|
|
58
58
|
var DEFAULT_TEMPLATE = '/.well-known/masque/udp/{target_host}/{target_port}/';
|
|
59
59
|
var MIN_IDLE_MS = 120000; // §3.1: "SHOULD NOT use a period lower than two minutes"
|
|
@@ -369,6 +369,9 @@ function createConnectUdp(options) {
|
|
|
369
369
|
}
|
|
370
370
|
var headers = opts.headers || null;
|
|
371
371
|
if (opts.relay === false) { this._open(headers); return this; }
|
|
372
|
+
// The socket to the target, when this tunnel makes it (no opts.socket):
|
|
373
|
+
// its buffers (utils.js), 4 MB by default; socketBufferSize 0, the OS's.
|
|
374
|
+
var bufferSize = checkedSocketBufferSize(opts.socketBufferSize, 'accept({ socketBufferSize })');
|
|
372
375
|
|
|
373
376
|
var allowPrivate = opts.allowPrivate != null ? !!opts.allowPrivate : allowPrivateDefault;
|
|
374
377
|
var host = this.target.host;
|
|
@@ -390,7 +393,7 @@ function createConnectUdp(options) {
|
|
|
390
393
|
if (opts.socket) self._useSocket(opts.socket, false, pick.address, headers);
|
|
391
394
|
else {
|
|
392
395
|
var sock;
|
|
393
|
-
try { sock = dgram.createSocket(family === 6 ? 'udp6' : 'udp4'); }
|
|
396
|
+
try { sock = dgram.createSocket(udpSocketOptions({ type: family === 6 ? 'udp6' : 'udp4' }, bufferSize)); }
|
|
394
397
|
catch (e2) { self._refuse(502, proxyStatus('proxy_internal_error'), e2); return; }
|
|
395
398
|
sock.once('error', function (e3) {
|
|
396
399
|
if (self.state === 'pending') self._refuse(502, proxyStatus('destination_ip_unroutable', e3.code), e3);
|
|
@@ -395,6 +395,7 @@ function ProxyH3Connection(key, proxy, o, ech) {
|
|
|
395
395
|
if (self.state !== 'connecting') return;
|
|
396
396
|
self.udp = createQuicClientSocket({
|
|
397
397
|
remoteIp: ip, remotePort: proxy.port, hostname: o.servername || proxy.hostname,
|
|
398
|
+
socketBufferSize: o.socketBufferSize,
|
|
398
399
|
rejectUnauthorized: o.rejectUnauthorized !== false, ca: o.ca || null,
|
|
399
400
|
key: o.key || null, cert: o.cert || null, passphrase: o.passphrase,
|
|
400
401
|
quicVersions: o.quicVersions,
|
package/src/crypto.js
CHANGED
|
@@ -607,6 +607,12 @@ function decrypt_quic_packet(array, readKey, readIv, readHp, ownCid, largestPn,
|
|
|
607
607
|
return {
|
|
608
608
|
packet_number: packetNumber,
|
|
609
609
|
key_phase: keyPhase,
|
|
610
|
+
// The first byte with header protection removed: its reserved bits are
|
|
611
|
+
// the caller's to check — only once the AEAD has passed the packet
|
|
612
|
+
// (RFC 9000 §17.2, §17.3.1), since before that anyone could set them.
|
|
613
|
+
first_byte: array[0],
|
|
614
|
+
// null: the AEAD refused the packet. An empty Uint8Array: it passed, and
|
|
615
|
+
// there was nothing in it — a different thing (RFC 9000 §12.4).
|
|
610
616
|
plaintext: plaintext
|
|
611
617
|
};
|
|
612
618
|
}
|
package/src/h3.js
CHANGED
|
@@ -307,8 +307,17 @@ function parseSettingsPayload(buf) {
|
|
|
307
307
|
var list = [];
|
|
308
308
|
var seen = {};
|
|
309
309
|
while (offset < buf.length) {
|
|
310
|
-
|
|
311
|
-
|
|
310
|
+
// A payload that ends inside an identifier or before its value is
|
|
311
|
+
// H3_FRAME_ERROR (RFC 9114 §7.1). It was cut short silently: the setting
|
|
312
|
+
// dropped, the connection on.
|
|
313
|
+
var id = readVarInt(buf, offset);
|
|
314
|
+
var val = id ? readVarInt(buf, offset + id.byteLength) : null;
|
|
315
|
+
if (!id || !val) {
|
|
316
|
+
var te = new Error('SETTINGS payload ends inside a setting (' + (buf.length - offset) + ' byte(s) left)');
|
|
317
|
+
te.h3ErrorCode = 0x0106;
|
|
318
|
+
throw te;
|
|
319
|
+
}
|
|
320
|
+
offset += id.byteLength + val.byteLength;
|
|
312
321
|
// RFC 9114 §7.2.4: an identifier twice, or one of HTTP/2's that HTTP/3
|
|
313
322
|
// reserves (0x02-0x05), is H3_SETTINGS_ERROR.
|
|
314
323
|
if (seen[id.value] || (id.value >= 0x02 && id.value <= 0x05)) {
|
|
@@ -646,6 +655,11 @@ function H3Connection(options) {
|
|
|
646
655
|
var decoderStreamId = null;
|
|
647
656
|
var qpackKnownReceived = 0; // insertions the peer has acknowledged
|
|
648
657
|
var qpackPendingAcks = 0; // insertions of THEIRS we owe an ack for
|
|
658
|
+
// Field sections we sent with a non-zero Required Insert Count, not yet
|
|
659
|
+
// acknowledged: stream id → [RIC, …] in the order sent (RFC 9204 §2.1.1).
|
|
660
|
+
// The decoder acknowledges each (Section Acknowledgment), or cancels the
|
|
661
|
+
// stream's; an acknowledgment for none is an error (§4.4.1).
|
|
662
|
+
var qpackUnacked = new Map();
|
|
649
663
|
|
|
650
664
|
// ---- SETTINGS remembered for 0-RTT (RFC 9114 §7.2.4.2) ----
|
|
651
665
|
//
|
|
@@ -744,9 +758,10 @@ function H3Connection(options) {
|
|
|
744
758
|
* reference — and until an entry is below it, we send a literal instead.
|
|
745
759
|
* That single number is what makes this encoder non-blocking.
|
|
746
760
|
*
|
|
747
|
-
*
|
|
748
|
-
*
|
|
749
|
-
*
|
|
761
|
+
* Each instruction is held to what we actually sent (RFC 9204 §4.4): a
|
|
762
|
+
* Section Acknowledgment for a section we have outstanding on that stream,
|
|
763
|
+
* an Insert Count Increment of at least 1 that does not pass the entries we
|
|
764
|
+
* inserted. Anything else is QPACK_DECODER_STREAM_ERROR.
|
|
750
765
|
*/
|
|
751
766
|
function processQpackDecoderStream(s) {
|
|
752
767
|
if (s.buffered === 0) return;
|
|
@@ -755,14 +770,41 @@ function H3Connection(options) {
|
|
|
755
770
|
var buf = Buffer.from(copyBytes(s, s.buffered));
|
|
756
771
|
|
|
757
772
|
var r = qpackParseDecoderInstructions(buf, 0);
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
if (
|
|
761
|
-
|
|
773
|
+
for (var k = 0; k < r.instructions.length; k++) {
|
|
774
|
+
var ins = r.instructions[k];
|
|
775
|
+
if (ins.type === 'section_ack') {
|
|
776
|
+
var pend = qpackUnacked.get(ins.value);
|
|
777
|
+
if (!pend || pend.length === 0) {
|
|
778
|
+
throw qpackDecoderStreamError('Section Acknowledgment for stream ' + ins.value +
|
|
779
|
+
', which has no field section awaiting one');
|
|
780
|
+
}
|
|
781
|
+
var ric = pend.shift();
|
|
782
|
+
if (pend.length === 0) qpackUnacked.delete(ins.value);
|
|
783
|
+
// §4.4.1: the acknowledged section's entries are now known received.
|
|
784
|
+
if (ric > qpackKnownReceived) qpackKnownReceived = ric;
|
|
785
|
+
} else if (ins.type === 'stream_cancel') {
|
|
786
|
+
qpackUnacked.delete(ins.value);
|
|
787
|
+
} else {
|
|
788
|
+
// §4.4.3: zero, or past what was inserted, is an error.
|
|
789
|
+
if (ins.value === 0) throw qpackDecoderStreamError('Insert Count Increment of 0');
|
|
790
|
+
if (qpackKnownReceived + ins.value > encoderTable.insertCount) {
|
|
791
|
+
throw qpackDecoderStreamError('Insert Count Increment to ' + (qpackKnownReceived + ins.value) +
|
|
792
|
+
', beyond the ' + encoderTable.insertCount + ' entries inserted');
|
|
793
|
+
}
|
|
794
|
+
qpackKnownReceived += ins.value;
|
|
795
|
+
}
|
|
762
796
|
}
|
|
797
|
+
if (DEBUG && r.instructions.length) console.log('[h3] qpack decoder instructions ' + r.instructions.length +
|
|
798
|
+
' (known=' + qpackKnownReceived + ')');
|
|
763
799
|
consumeBytes(s, r.consumed);
|
|
764
800
|
}
|
|
765
801
|
|
|
802
|
+
function qpackDecoderStreamError(msg) {
|
|
803
|
+
var err = new Error('QPACK decoder stream: ' + msg);
|
|
804
|
+
err.h3ErrorCode = H3_ERR.QPACK_DECODER_STREAM_ERROR;
|
|
805
|
+
return err;
|
|
806
|
+
}
|
|
807
|
+
|
|
766
808
|
function evictQpackTable(limit) {
|
|
767
809
|
// Oldest first, until what remains fits. Sizes in BYTES: measuring by
|
|
768
810
|
// string length, as this did, undercounts any non-ASCII header value and
|
|
@@ -847,7 +889,15 @@ function H3Connection(options) {
|
|
|
847
889
|
if (context.control_streams_sent) return;
|
|
848
890
|
context.control_streams_sent = true;
|
|
849
891
|
|
|
850
|
-
|
|
892
|
+
// A reserved identifier among them, of a random N (RFC 9114 §7.2.4.1,
|
|
893
|
+
// 0x1f·N + 0x21): peers must ignore it — sending one keeps them honest.
|
|
894
|
+
// Not part of localSettings: 0-RTT's context (settingsContext) holds the
|
|
895
|
+
// ones that mean something, and this one changes every connection.
|
|
896
|
+
var greaseId = 0x1f * Math.floor(Math.random() * 0x10000) + 0x21;
|
|
897
|
+
var settings = concatUint8Arrays([
|
|
898
|
+
buildSettingsPayload(localSettings(context)),
|
|
899
|
+
writeVarInt(greaseId), writeVarInt(Math.floor(Math.random() * 64))
|
|
900
|
+
]);
|
|
851
901
|
|
|
852
902
|
var controlFrame = buildH3Frames([{ frame_type: 0x04, payload: settings }]);
|
|
853
903
|
|
|
@@ -878,6 +928,11 @@ function H3Connection(options) {
|
|
|
878
928
|
}
|
|
879
929
|
|
|
880
930
|
|
|
931
|
+
// Our control and QPACK streams (sendControlStreams).
|
|
932
|
+
function isOwnCriticalStream(sid) {
|
|
933
|
+
return sid === (context.isServer ? 3 : 2) || sid === encoderStreamId || sid === decoderStreamId;
|
|
934
|
+
}
|
|
935
|
+
|
|
881
936
|
// ---- Graceful shutdown: GOAWAY (RFC 9114 §5.2) ----
|
|
882
937
|
|
|
883
938
|
function sendGoaway(id) {
|
|
@@ -1155,6 +1210,15 @@ function H3Connection(options) {
|
|
|
1155
1210
|
// a WT stream, so it can be routed to its session.
|
|
1156
1211
|
listenQuic('stream_reset', function (streamId, code) {
|
|
1157
1212
|
var sid = Number(streamId);
|
|
1213
|
+
// The peer's control or QPACK stream, reset: closed, which RFC 9114
|
|
1214
|
+
// §6.2.1 and RFC 9204 §4.2 make a connection error however it happens.
|
|
1215
|
+
// Only a FIN was caught (processStream); a RESET_STREAM left the
|
|
1216
|
+
// connection serving on without them.
|
|
1217
|
+
var crit = context.streams[sid];
|
|
1218
|
+
if (crit && (crit.type === 'control' || crit.type === 'qpack_enc' || crit.type === 'qpack_dec')) {
|
|
1219
|
+
connectionError(H3_ERR.H3_CLOSED_CRITICAL_STREAM, 'the peer reset its ' + crit.type + ' stream');
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1158
1222
|
var wt = _wtStreams[sid];
|
|
1159
1223
|
var wasParsing = sid in context.streams;
|
|
1160
1224
|
// A client's request stream reset before its HEADERS came: there is no
|
|
@@ -1179,6 +1243,13 @@ function H3Connection(options) {
|
|
|
1179
1243
|
// whole cancellation.
|
|
1180
1244
|
listenQuic('stop_sending', function (streamId, code) {
|
|
1181
1245
|
var sid = Number(streamId);
|
|
1246
|
+
// Asked to stop one of OUR critical streams: QUIC has answered with
|
|
1247
|
+
// RESET_STREAM, so it is closed — the same connection error (§6.2.1:
|
|
1248
|
+
// "A receiver MUST NOT request that the sender close the control stream").
|
|
1249
|
+
if (context.control_streams_sent && isOwnCriticalStream(sid)) {
|
|
1250
|
+
connectionError(H3_ERR.H3_CLOSED_CRITICAL_STREAM, 'the peer stopped our ' + (sid === encoderStreamId ? 'QPACK encoder' : sid === decoderStreamId ? 'QPACK decoder' : 'control') + ' stream');
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1182
1253
|
if (_abortedHere.has(sid)) return;
|
|
1183
1254
|
ev.emit('stop_sending', sid, code);
|
|
1184
1255
|
});
|
|
@@ -2124,6 +2195,11 @@ function H3Connection(options) {
|
|
|
2124
2195
|
// Insertions FIRST, on their own stream: they are for the next request,
|
|
2125
2196
|
// not this one.
|
|
2126
2197
|
flushQpackInserts(enc.inserts);
|
|
2198
|
+
if (enc.requiredInsertCount > 0) {
|
|
2199
|
+
var pend = qpackUnacked.get(streamId);
|
|
2200
|
+
if (pend) pend.push(enc.requiredInsertCount);
|
|
2201
|
+
else qpackUnacked.set(streamId, [enc.requiredInsertCount]);
|
|
2202
|
+
}
|
|
2127
2203
|
|
|
2128
2204
|
var frame = buildH3Frames([{ frame_type: 1, payload: new Uint8Array(enc.block) }]);
|
|
2129
2205
|
if (DEBUG) console.log('[h3] sendHeaders stream=' + streamId + ' len=' + frame.byteLength + ' fin=' + !!fin);
|
package/src/h3_client.js
CHANGED
|
@@ -248,6 +248,7 @@ function request(options, callback) {
|
|
|
248
248
|
udpSocket = createQuicClientSocket({
|
|
249
249
|
remoteIp: remoteIp,
|
|
250
250
|
remotePort: remotePort,
|
|
251
|
+
socketBufferSize: options.socketBufferSize,
|
|
251
252
|
// SNI and the name the certificate is checked for: tls.connect's
|
|
252
253
|
// servername where given.
|
|
253
254
|
hostname: options.servername || hostname,
|
package/src/h3_server.js
CHANGED
|
@@ -153,6 +153,7 @@ function createServer(options, handler) {
|
|
|
153
153
|
maxUdpPayloadSize: options.maxUdpPayloadSize,
|
|
154
154
|
initialRtt: options.initialRtt,
|
|
155
155
|
reorderWindow: options.reorderWindow,
|
|
156
|
+
socketBufferSize: options.socketBufferSize,
|
|
156
157
|
connectionOptions: options.connectionOptions,
|
|
157
158
|
quicVersions: options.quicVersions,
|
|
158
159
|
socket: options.socket || null,
|
|
@@ -1704,6 +1705,8 @@ function createServer(options, handler) {
|
|
|
1704
1705
|
|
|
1705
1706
|
return {
|
|
1706
1707
|
listen: function (p, h, cb) { closingServer = null; return qserver.listen(p, h, cb); },
|
|
1708
|
+
// The UDP socket's address — after listen(0), the port the OS gave.
|
|
1709
|
+
address: function () { return qserver.address(); },
|
|
1707
1710
|
close: close,
|
|
1708
1711
|
closeIdleConnections: closeIdleConnections,
|
|
1709
1712
|
closeAllConnections: closeAllConnections,
|
package/src/qpack.js
CHANGED
|
@@ -270,6 +270,9 @@ var DECODER_INSERT_COUNT = 0x00; // 00xxxxxx
|
|
|
270
270
|
function parseDecoderInstructions(buf, offset) {
|
|
271
271
|
var increments = 0;
|
|
272
272
|
var i = offset || 0;
|
|
273
|
+
// Each instruction in order, for the encoder to check (RFC 9204 §4.4):
|
|
274
|
+
// { type: 'section_ack' | 'stream_cancel' | 'increment', value }.
|
|
275
|
+
var instructions = [];
|
|
273
276
|
|
|
274
277
|
// readPrefixedInt(buf, prefixBits, pos) — prefix width SECOND, position
|
|
275
278
|
// THIRD, and it returns { value, next } rather than a byte length. Getting
|
|
@@ -284,19 +287,22 @@ function parseDecoderInstructions(buf, offset) {
|
|
|
284
287
|
r = readPrefixedInt(buf, 7, i);
|
|
285
288
|
if (!r) break;
|
|
286
289
|
i = r.next;
|
|
290
|
+
instructions.push({ type: 'section_ack', value: r.value });
|
|
287
291
|
} else if (b & DECODER_STREAM_CANCEL) {
|
|
288
292
|
r = readPrefixedInt(buf, 6, i);
|
|
289
293
|
if (!r) break;
|
|
290
294
|
i = r.next;
|
|
295
|
+
instructions.push({ type: 'stream_cancel', value: r.value });
|
|
291
296
|
} else {
|
|
292
297
|
r = readPrefixedInt(buf, 6, i);
|
|
293
298
|
if (!r) break;
|
|
294
299
|
increments += r.value;
|
|
295
300
|
i = r.next;
|
|
301
|
+
instructions.push({ type: 'increment', value: r.value });
|
|
296
302
|
}
|
|
297
303
|
}
|
|
298
304
|
|
|
299
|
-
return { increments: increments, consumed: i - (offset || 0) };
|
|
305
|
+
return { increments: increments, consumed: i - (offset || 0), instructions: instructions };
|
|
300
306
|
}
|
|
301
307
|
|
|
302
308
|
|
|
@@ -439,7 +445,10 @@ function encodeHeaderBlock(headerList, table, options) {
|
|
|
439
445
|
|
|
440
446
|
return {
|
|
441
447
|
block: Buffer.from(prefix.concat(out)),
|
|
442
|
-
inserts: inserts
|
|
448
|
+
inserts: inserts,
|
|
449
|
+
// The section's Required Insert Count: non-zero, the peer acknowledges
|
|
450
|
+
// the section (Section Acknowledgment), which the encoder holds it to.
|
|
451
|
+
requiredInsertCount: maxRef
|
|
443
452
|
};
|
|
444
453
|
}
|
|
445
454
|
|