|
| 1 | +// Flags: --experimental-quic --experimental-stream-iter --no-warnings |
| 2 | + |
| 3 | +// Test: HTTP/3 webtransport client initiated bidi stream |
| 4 | +// Server creates a bidi stream and client send data that is echoed back to the client |
| 5 | + |
| 6 | +import { hasQuic, skip, mustCall, mustNotCall, mustCallAtLeast } from '../common/index.mjs'; |
| 7 | +import assert from 'node:assert'; |
| 8 | +import * as fixtures from '../common/fixtures.mjs'; |
| 9 | + |
| 10 | +if (!hasQuic) { |
| 11 | + skip('QUIC is not enabled'); |
| 12 | +} |
| 13 | + |
| 14 | +const { listen, connect } = await import('node:quic'); |
| 15 | +const { createPrivateKey } = await import('node:crypto'); |
| 16 | +const { drainableProtocol: dp } = await import('stream/iter'); |
| 17 | + |
| 18 | +const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); |
| 19 | +const cert = fixtures.readKey('agent1-cert.pem'); |
| 20 | + |
| 21 | +const chunkSizes = [60000, 12, 1000000, 50000, 1600, 20000, 1000000, 30000, 0, 100]; |
| 22 | +const numChunks = chunkSizes.length; |
| 23 | +const byteLength = chunkSizes.reduce((accumulator, currentValue) => accumulator + currentValue, 0); |
| 24 | + |
| 25 | + |
| 26 | +// Build a deterministic payload so we can verify integrity. |
| 27 | +function buildChunk(index) { |
| 28 | + const chunk = new Uint8Array(chunkSizes[index]); |
| 29 | + // Fill with a pattern derived from the chunk index. |
| 30 | + const val = index & 0xff; |
| 31 | + for (let i = 0; i < chunkSizes[index]; i++) { |
| 32 | + chunk[i] = (val + i) & 0xff; |
| 33 | + } |
| 34 | + return chunk; |
| 35 | +} |
| 36 | + |
| 37 | +function checksum(data) { |
| 38 | + let sum = 0; |
| 39 | + for (let i = 0; i < data.byteLength; i++) { |
| 40 | + sum = (sum + data[i]) | 0; |
| 41 | + } |
| 42 | + return sum; |
| 43 | +} |
| 44 | + |
| 45 | +// Compute expected checksum. |
| 46 | +let expectedChecksum = 0; |
| 47 | +for (let i = 0; i < numChunks; i++) { |
| 48 | + const chunk = buildChunk(i); |
| 49 | + expectedChecksum = (expectedChecksum + checksum(chunk)) | 0; |
| 50 | +} |
| 51 | + |
| 52 | +const serverSessionOpened = Promise.withResolvers(); |
| 53 | + |
| 54 | + |
| 55 | +const serverEndpoint = await listen(mustCall(async (ss) => { |
| 56 | + ss.onapplication = mustCall((aopts) => { |
| 57 | + assert.strictEqual(!aopts.enableDatagrams, false); |
| 58 | + }); |
| 59 | + ss.onhandshake = mustCall(function() { |
| 60 | + assert.strictEqual(BigInt(this?.remoteTransportParams?.maxDatagramFrameSize) > 0, true); |
| 61 | + }); |
| 62 | + ss.onstream = mustCall((stream) => { |
| 63 | + stream.onsessionid = mustNotCall(async (sessionid) => { |
| 64 | + console.log('No client initiated stream expected!'); |
| 65 | + }); |
| 66 | + }); |
| 67 | +}), { |
| 68 | + sni: { '*': { keys: [key], certs: [cert] } }, |
| 69 | + application: { |
| 70 | + enableConnectProtocol: true, |
| 71 | + enableDatagrams: true, |
| 72 | + enableWebtransport: true |
| 73 | + }, |
| 74 | + transportParams: { |
| 75 | + maxDatagramFrameSize: 1000, |
| 76 | + initialMaxStreamsBidi: 100, // default value according to spec |
| 77 | + initialMaxStreamsUni: 100, // especially important as limit default is 0 |
| 78 | + }, |
| 79 | + onheaders: mustCall(async function(headers) { |
| 80 | + try { |
| 81 | + assert.strictEqual(headers[':scheme'], 'https'); |
| 82 | + assert.strictEqual(headers[':method'], 'CONNECT'); |
| 83 | + assert.strictEqual(headers[':protocol'], 'webtransport'); // depends on the draft |
| 84 | + assert.strictEqual(headers[':path'], '/testwtpath'); |
| 85 | + // We could also check for wt-available protocols |
| 86 | + this.sendHeaders( |
| 87 | + { ':status': '200' }, |
| 88 | + { terminal: false, webtransport: true } |
| 89 | + ); |
| 90 | + serverSessionOpened.resolve(this.id); |
| 91 | + } catch (error) { |
| 92 | + serverSessionOpened.reject(error); |
| 93 | + } |
| 94 | + // Should only be installed on wt streams |
| 95 | + this.onwtsessionclose = mustCall((code, reason) => { |
| 96 | + assert.strictEqual(code, 200); |
| 97 | + assert.strictEqual(reason, 'all perfect'); |
| 98 | + this.session.close(); |
| 99 | + }); |
| 100 | + |
| 101 | + // Well let's get a bidi stream and echo all |
| 102 | + const serverBidiStream = await this.session.createBidirectionalStream({ |
| 103 | + incremental: true, |
| 104 | + webtransportSession: this // Associate it with the sessionStream |
| 105 | + }); |
| 106 | + // Now we can echo all data. |
| 107 | + const writer = serverBidiStream.writer; |
| 108 | + let echoedData = 0; |
| 109 | + for await (const chunks of serverBidiStream) { |
| 110 | + for (const chunk of chunks) { |
| 111 | + echoedData += chunk.byteLength |
| 112 | + while (!writer.writeSync(chunk)) { |
| 113 | + // Flow controlled — wait for drain before retrying. |
| 114 | + const drainable = writer[dp](); |
| 115 | + if (drainable) await drainable; |
| 116 | + } |
| 117 | + } |
| 118 | + } |
| 119 | + writer.end(); |
| 120 | + }), |
| 121 | +}); |
| 122 | + |
| 123 | +const clientSession = await connect(serverEndpoint.address, { |
| 124 | + servername: 'localhost', |
| 125 | + verifyPeer: 'manual', |
| 126 | + application: { |
| 127 | + enableConnectProtocol: true, |
| 128 | + enableDatagrams: true, |
| 129 | + enableWebtransport: true |
| 130 | + }, |
| 131 | + transportParams: { |
| 132 | + maxDatagramFrameSize: 1000, |
| 133 | + initialMaxStreamsBidi: 100, // default value according to spec |
| 134 | + initialMaxStreamsUni: 100, // especially important as limit default is 0 |
| 135 | + }, |
| 136 | +}); |
| 137 | + |
| 138 | +const webtransportSupport = Promise.withResolvers(); |
| 139 | + |
| 140 | +clientSession.onapplication = mustCall((aopts) => { |
| 141 | + try { |
| 142 | + // Test for webtransport support |
| 143 | + assert.strictEqual(aopts.enableConnectProtocol, true); |
| 144 | + assert.strictEqual(aopts.enableDatagrams, true); |
| 145 | + assert.strictEqual(aopts.enableWebtransport, true); |
| 146 | + // Ok we have wt support |
| 147 | + webtransportSupport.resolve(); |
| 148 | + } catch (error) { |
| 149 | + webtransportSupport.reject(error); |
| 150 | + } |
| 151 | +}); |
| 152 | + |
| 153 | +clientSession.onstream = mustNotCall((stream) => { |
| 154 | + stream.onheaders = mustNotCall((stream) => { |
| 155 | + // Well this should not happen on client side |
| 156 | + console.log('Called onheaders on the client side!'); |
| 157 | + }); |
| 158 | +}); |
| 159 | + |
| 160 | +await clientSession.opened; |
| 161 | +await webtransportSupport.promise; |
| 162 | + |
| 163 | +clientSession.onstream = mustCall((stream) => { |
| 164 | + stream.onsessionid = mustCall(async (sessionid) => { |
| 165 | + assert.strictEqual(sessionid, wtSessionStream.id); |
| 166 | + await Promise.all([readFromStream(stream), writeToStream(stream)]); |
| 167 | + await wtSessionStream.closeWebtransportSessionStream(200, 'all perfect'); |
| 168 | + }); |
| 169 | +}); |
| 170 | + |
| 171 | +// Now we open a webtransport session, which is actually |
| 172 | +// a special bidirectional stream |
| 173 | +const wtSessionStream = await clientSession.createBidirectionalStream({ |
| 174 | + body: '', |
| 175 | +}); |
| 176 | +wtSessionStream.sendHeaders({ |
| 177 | + ':method': 'CONNECT', |
| 178 | + ':scheme': 'https', |
| 179 | + // This one depends on draft, draft14 says "webtransport", draft15 says "webtransport-h3" |
| 180 | + ':protocol': 'webtransport', |
| 181 | + ':path': '/testwtpath', |
| 182 | + ':authority': 'testserver:' + serverEndpoint.address.port |
| 183 | +}, { |
| 184 | + webtransport: true // Tell nghttp3 to treat the stream as a WT session stream |
| 185 | +}); |
| 186 | + |
| 187 | + |
| 188 | +// Next step send some data |
| 189 | + |
| 190 | + |
| 191 | +const readFromStream = mustCallAtLeast(async (stream) => { |
| 192 | + const readChunks = []; |
| 193 | + let readdata = 0; |
| 194 | + for await (const chunks of stream) { |
| 195 | + for (const chunk of chunks) { |
| 196 | + readdata += chunk.byteLength; |
| 197 | + } |
| 198 | + readChunks.push(...chunks); |
| 199 | + } |
| 200 | + const receivedBytes = readChunks.reduce((accu, curVal) => accu + curVal.byteLength, 0); |
| 201 | + |
| 202 | + assert.strictEqual(receivedBytes, byteLength); |
| 203 | + let receivedChecksum = 0; |
| 204 | + for (const chunk of readChunks) { |
| 205 | + receivedChecksum = (receivedChecksum + checksum(chunk)) | 0; |
| 206 | + } |
| 207 | + assert.strictEqual(receivedChecksum, expectedChecksum); |
| 208 | +}, 1); |
| 209 | + |
| 210 | + |
| 211 | +const writeToStream = mustCallAtLeast(async (stream) => { |
| 212 | + const w = stream.writer; |
| 213 | + let writtenData = 0; |
| 214 | + for (let i = 0; i < numChunks; i++) { |
| 215 | + const chunk = buildChunk(i); |
| 216 | + writtenData += chunk.byteLength; |
| 217 | + while (!w.writeSync(chunk)) { |
| 218 | + // Flow controlled — wait for drain before retrying. |
| 219 | + const drainable = w[dp](); |
| 220 | + if (drainable) await drainable; |
| 221 | + } |
| 222 | + } |
| 223 | + w.endSync(); |
| 224 | +}, 1); |
| 225 | + |
| 226 | +await serverSessionOpened.promise; |
| 227 | + |
| 228 | +try { |
| 229 | + await wtSessionStream.closed; |
| 230 | +} catch (error) { |
| 231 | + assert.strictEqual(error.errorCode, 200n); |
| 232 | + assert.strictEqual(error.reason, 'all perfect'); |
| 233 | +} |
| 234 | +await clientSession.close(); |
| 235 | +await serverEndpoint.close(); |
0 commit comments