@gjsify/http2 0.3.21 → 0.4.3

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.
@@ -1,352 +0,0 @@
1
- // Reference: Node.js lib/internal/http2/core.js
2
- // Reimplemented for GJS using Soup.Session (HTTP/2 transparently via ALPN over HTTPS)
3
- //
4
- // Phase 1 limitations:
5
- // - connect() over HTTP (non-TLS) uses HTTP/1.1 only (Soup does not support h2c)
6
- // - connect() over HTTPS upgrades to HTTP/2 automatically via ALPN if the server supports it
7
- // - pushStream, stream IDs, flow control are Soup-internal — not exposed
8
- // - rejectUnauthorized: false is best-effort (requires system-level TLS trust or custom DB)
9
-
10
- import Soup from '@girs/soup-3.0';
11
- import Gio from '@girs/gio-2.0';
12
- import GLib from '@girs/glib-2.0';
13
- import { EventEmitter } from 'node:events';
14
- import { Duplex } from 'node:stream';
15
- import { Buffer } from 'node:buffer';
16
- import { readBytesAsync } from '@gjsify/utils';
17
- import { constants, getDefaultSettings, type Http2Settings } from './protocol.js';
18
-
19
- export type { Http2Settings };
20
-
21
- export interface ClientSessionOptions {
22
- maxDeflateDynamicTableSize?: number;
23
- maxSessionMemory?: number;
24
- maxHeaderListPairs?: number;
25
- maxOutstandingPings?: number;
26
- maxReservedRemoteStreams?: number;
27
- maxSendHeaderBlockLength?: number;
28
- paddingStrategy?: number;
29
- peerMaxHeaderListSize?: number;
30
- protocol?: string;
31
- settings?: Http2Settings;
32
- // TLS options
33
- rejectUnauthorized?: boolean;
34
- ca?: string | Buffer | Array<string | Buffer>;
35
- cert?: string | Buffer | Array<string | Buffer>;
36
- key?: string | Buffer | Array<string | Buffer>;
37
- ALPNProtocols?: string[];
38
- }
39
-
40
- export interface ClientStreamOptions {
41
- endStream?: boolean;
42
- exclusive?: boolean;
43
- parent?: number;
44
- weight?: number;
45
- waitForTrailers?: boolean;
46
- signal?: AbortSignal;
47
- }
48
-
49
- // ─── Http2Session (base) ──────────────────────────────────────────────────────
50
-
51
- export class Http2Session extends EventEmitter {
52
- readonly type: number = constants.NGHTTP2_SESSION_CLIENT;
53
- readonly alpnProtocol: string | undefined = undefined;
54
- readonly encrypted: boolean = false;
55
-
56
- private _closed = false;
57
- private _destroyed = false;
58
- private _settings: Http2Settings;
59
-
60
- constructor() {
61
- super();
62
- this._settings = getDefaultSettings();
63
- }
64
-
65
- get closed(): boolean { return this._closed; }
66
- get destroyed(): boolean { return this._destroyed; }
67
- get connecting(): boolean { return false; }
68
- get pendingSettingsAck(): boolean { return false; }
69
- get localSettings(): Http2Settings { return { ...this._settings }; }
70
- get remoteSettings(): Http2Settings { return getDefaultSettings(); }
71
- get originSet(): Set<string> { return new Set(); }
72
-
73
- settings(settings: Http2Settings, callback?: () => void): void {
74
- Object.assign(this._settings, settings);
75
- if (callback) Promise.resolve().then(callback);
76
- }
77
-
78
- goaway(code?: number, _lastStreamId?: number, _data?: Uint8Array): void {
79
- this.emit('goaway', code ?? constants.NGHTTP2_NO_ERROR);
80
- this.destroy();
81
- }
82
-
83
- ping(payload?: Uint8Array, callback?: (err: Error | null, duration: number, payload: Uint8Array) => void): boolean {
84
- const buf = payload || new Uint8Array(8);
85
- if (callback) Promise.resolve().then(() => callback(null, 0, buf));
86
- return true;
87
- }
88
-
89
- close(callback?: () => void): void {
90
- if (this._closed) return;
91
- this._closed = true;
92
- this.emit('close');
93
- if (callback) callback();
94
- }
95
-
96
- destroy(error?: Error, code?: number): void {
97
- if (this._destroyed) return;
98
- this._destroyed = true;
99
- this._closed = true;
100
- if (error) this.emit('error', error);
101
- if (code !== undefined) this.emit('goaway', code);
102
- this.emit('close');
103
- }
104
-
105
- ref(): void {}
106
- unref(): void {}
107
- }
108
-
109
- // ─── ClientHttp2Stream ────────────────────────────────────────────────────────
110
- // Duplex: writable = request body (buffered until end()), readable = response body.
111
- // The Soup request is dispatched when end() is called.
112
-
113
- export class ClientHttp2Stream extends Duplex {
114
- readonly id = 1;
115
- readonly pending = false;
116
- readonly aborted = false;
117
- readonly bufferSize = 0;
118
- readonly endAfterHeaders = false;
119
-
120
- private _session: ClientHttp2Session;
121
- private _requestHeaders: Record<string, string | string[]>;
122
- private _requestChunks: Buffer[] = [];
123
- private _cancellable: Gio.Cancellable;
124
- private _state: number = constants.NGHTTP2_STREAM_STATE_OPEN;
125
- private _responseHeaders: Record<string, string | string[]> = {};
126
-
127
- get state(): number { return this._state; }
128
- get rstCode(): number { return constants.NGHTTP2_NO_ERROR; }
129
- get session(): ClientHttp2Session { return this._session; }
130
- get sentHeaders(): Record<string, string | string[]> { return this._requestHeaders; }
131
-
132
- constructor(session: ClientHttp2Session, requestHeaders: Record<string, string | string[]>) {
133
- super();
134
- this._session = session;
135
- this._requestHeaders = requestHeaders;
136
- this._cancellable = new Gio.Cancellable();
137
- }
138
-
139
- _read(_size: number): void {}
140
-
141
- _write(chunk: any, encoding: string, callback: (error?: Error | null) => void): void {
142
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding as BufferEncoding);
143
- this._requestChunks.push(buf);
144
- callback();
145
- }
146
-
147
- _final(callback: (error?: Error | null) => void): void {
148
- this._sendRequest()
149
- .then(() => callback())
150
- .catch((err) => callback(err instanceof Error ? err : new Error(String(err))));
151
- }
152
-
153
- private async _sendRequest(): Promise<void> {
154
- const session = this._session._getSoupSession();
155
- const authority = this._session._getAuthority();
156
-
157
- const method = (this._requestHeaders[':method'] as string) || 'GET';
158
- const path = (this._requestHeaders[':path'] as string) || '/';
159
- const scheme = (this._requestHeaders[':scheme'] as string) || (authority.startsWith('https') ? 'https' : 'http');
160
- const host = (this._requestHeaders[':authority'] as string) || authority.replace(/^https?:\/\//, '');
161
-
162
- const url = `${scheme}://${host}${path}`;
163
- let uri: GLib.Uri;
164
- try {
165
- uri = GLib.Uri.parse(url, GLib.UriFlags.NONE);
166
- } catch (err) {
167
- throw new Error(`Invalid HTTP/2 request URL: ${url}`);
168
- }
169
-
170
- const message = new Soup.Message({ method, uri });
171
-
172
- // Apply request headers (skip HTTP/2 pseudo-headers)
173
- const reqHeaders = message.get_request_headers();
174
- for (const [key, value] of Object.entries(this._requestHeaders)) {
175
- if (key.startsWith(':')) continue;
176
- if (Array.isArray(value)) {
177
- for (const v of value) reqHeaders.append(key, v);
178
- } else {
179
- reqHeaders.replace(key, value as string);
180
- }
181
- }
182
-
183
- // Set request body if any
184
- const body = Buffer.concat(this._requestChunks);
185
- if (body.length > 0) {
186
- const contentType = (this._requestHeaders['content-type'] as string) || 'application/octet-stream';
187
- message.set_request_body_from_bytes(contentType, new GLib.Bytes(body));
188
- }
189
-
190
- try {
191
- const inputStream = await new Promise<Gio.InputStream>((resolve, reject) => {
192
- session.send_async(message, GLib.PRIORITY_DEFAULT, this._cancellable, (_self: any, asyncRes: Gio.AsyncResult) => {
193
- try {
194
- resolve(session.send_finish(asyncRes));
195
- } catch (error) {
196
- reject(error);
197
- }
198
- });
199
- });
200
-
201
- // Collect response headers
202
- const statusCode = message.status_code;
203
- const responseHeaders = message.get_response_headers();
204
- const headersObj: Record<string, string | string[]> = { ':status': String(statusCode) };
205
- responseHeaders.foreach((name: string, value: string) => {
206
- const lower = name.toLowerCase();
207
- if (lower in headersObj) {
208
- const existing = headersObj[lower];
209
- if (Array.isArray(existing)) {
210
- existing.push(value);
211
- } else {
212
- headersObj[lower] = [existing as string, value];
213
- }
214
- } else {
215
- headersObj[lower] = value;
216
- }
217
- });
218
- this._responseHeaders = headersObj;
219
- this._state = constants.NGHTTP2_STREAM_STATE_HALF_CLOSED_LOCAL;
220
-
221
- // Emit 'response' before pushing body data so listeners can attach
222
- this.emit('response', headersObj, 0);
223
-
224
- // Stream response body
225
- try {
226
- let chunk: Uint8Array | null;
227
- while ((chunk = await readBytesAsync(inputStream, 16384, GLib.PRIORITY_DEFAULT, this._cancellable)) !== null) {
228
- if (chunk.length > 0) {
229
- this.push(Buffer.from(chunk));
230
- }
231
- }
232
- } catch (_readErr) {
233
- // Connection reset — push what we have
234
- }
235
-
236
- this.push(null);
237
- this._state = constants.NGHTTP2_STREAM_STATE_CLOSED;
238
- } catch (error: any) {
239
- this._state = constants.NGHTTP2_STREAM_STATE_CLOSED;
240
- if (!this._cancellable.is_cancelled()) {
241
- this.destroy(error instanceof Error ? error : new Error(String(error)));
242
- }
243
- }
244
- }
245
-
246
- close(code?: number, callback?: () => void): void {
247
- this._cancellable.cancel();
248
- this._state = constants.NGHTTP2_STREAM_STATE_CLOSED;
249
- this.emit('close', code ?? constants.NGHTTP2_NO_ERROR);
250
- if (callback) callback();
251
- }
252
-
253
- priority(_options: { exclusive?: boolean; parent?: number; weight?: number; silent?: boolean }): void {}
254
- sendTrailers(_headers: Record<string, string | string[]>): void {}
255
- setTimeout(_msecs: number, _callback?: () => void): this { return this; }
256
- }
257
-
258
- // ─── ClientHttp2Session ───────────────────────────────────────────────────────
259
-
260
- export class ClientHttp2Session extends Http2Session {
261
- override readonly type = constants.NGHTTP2_SESSION_CLIENT;
262
- declare readonly encrypted: boolean;
263
-
264
- private _authority: string;
265
- private _soupSession: Soup.Session;
266
- private _streams: Set<ClientHttp2Stream> = new Set();
267
-
268
- constructor(authority: string, options: ClientSessionOptions = {}) {
269
- super();
270
- this._authority = authority;
271
- this.encrypted = authority.startsWith('https:');
272
-
273
- this._soupSession = new Soup.Session();
274
-
275
- // Configure TLS for rejectUnauthorized: false (common in testing with self-signed certs)
276
- if (options.rejectUnauthorized === false) {
277
- // Connect to the accept-certificate signal on each message via session
278
- // This is a best-effort approach; system CA store may still reject the cert
279
- (this._soupSession as any).connect('accept-certificate',
280
- (_msg: any, _cert: any, _errors: any) => {
281
- return true;
282
- }
283
- );
284
- }
285
-
286
- // Emit 'connect' asynchronously after construction
287
- Promise.resolve().then(() => {
288
- if (!this.destroyed) {
289
- (this as any).alpnProtocol = this.encrypted ? 'h2' : undefined;
290
- this.emit('connect', this, null);
291
- }
292
- });
293
- }
294
-
295
- /** @internal Used by ClientHttp2Stream to get the Soup.Session */
296
- _getSoupSession(): Soup.Session {
297
- return this._soupSession;
298
- }
299
-
300
- /** @internal Used by ClientHttp2Stream to build the request URL */
301
- _getAuthority(): string {
302
- return this._authority;
303
- }
304
-
305
- request(headers: Record<string, string | string[]>, options?: ClientStreamOptions): ClientHttp2Stream {
306
- if (this.destroyed || this.closed) {
307
- throw new Error('Session is closed');
308
- }
309
-
310
- // Fill in missing pseudo-headers from the authority
311
- const finalHeaders = { ...headers };
312
- if (!finalHeaders[':scheme']) {
313
- finalHeaders[':scheme'] = this.encrypted ? 'https' : 'http';
314
- }
315
- if (!finalHeaders[':authority']) {
316
- finalHeaders[':authority'] = this._authority.replace(/^https?:\/\//, '');
317
- }
318
- if (!finalHeaders[':method']) {
319
- finalHeaders[':method'] = 'GET';
320
- }
321
- if (!finalHeaders[':path']) {
322
- finalHeaders[':path'] = '/';
323
- }
324
-
325
- const stream = new ClientHttp2Stream(this, finalHeaders);
326
- this._streams.add(stream);
327
- stream.once('close', () => this._streams.delete(stream));
328
-
329
- if (options?.endStream) {
330
- // No request body — end immediately to trigger the request
331
- stream.end();
332
- }
333
-
334
- return stream;
335
- }
336
-
337
- override close(callback?: () => void): void {
338
- for (const stream of this._streams) {
339
- stream.close();
340
- }
341
- this._streams.clear();
342
- super.close(callback);
343
- }
344
-
345
- override destroy(error?: Error, code?: number): void {
346
- for (const stream of this._streams) {
347
- stream.close();
348
- }
349
- this._streams.clear();
350
- super.destroy(error, code);
351
- }
352
- }
@@ -1,303 +0,0 @@
1
- // GJS-only integration tests for http2 server + client (Soup.Server / Soup.Session backed)
2
- // These tests only run on GJS since they require Soup 3.0.
3
- // Wrapped in on('Gjs') — not executed on Node.js.
4
-
5
- import { describe, it, expect, on } from '@gjsify/unit';
6
- import http2 from 'node:http2';
7
-
8
- // ─── Helpers ──────────────────────────────────────────────────────────────────
9
-
10
- type AnyServer = ReturnType<typeof http2.createServer>;
11
- type AnyStream = ReturnType<ReturnType<typeof http2.connect>['request']>;
12
-
13
- function withServer(
14
- handler: (req: any, res: any) => void,
15
- ): Promise<{ server: AnyServer; port: number }> {
16
- return new Promise((resolve, reject) => {
17
- const server = http2.createServer(handler);
18
- server.once('error', reject);
19
- server.listen(0, () => {
20
- const port = (server.address() as any)?.port;
21
- if (!port) return reject(new Error('Could not get server port'));
22
- resolve({ server, port });
23
- });
24
- });
25
- }
26
-
27
- function collectBody(stream: AnyStream): Promise<string> {
28
- return new Promise((resolve, reject) => {
29
- const chunks: Buffer[] = [];
30
- (stream as any).on('data', (chunk: Buffer) => chunks.push(chunk));
31
- (stream as any).on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
32
- (stream as any).on('error', reject);
33
- });
34
- }
35
-
36
- // ─── Tests ────────────────────────────────────────────────────────────────────
37
-
38
- export default async () => {
39
- await on('Gjs', async () => {
40
-
41
- await describe('http2.createServer()', async () => {
42
- await it('returns an server instance with listen/close', async () => {
43
- const server = http2.createServer();
44
- expect(server).toBeDefined();
45
- expect(typeof (server as any).listen).toBe('function');
46
- expect(typeof (server as any).close).toBe('function');
47
- });
48
-
49
- await it('listen() starts listening and emits listening', async () => {
50
- const server = http2.createServer();
51
- await new Promise<void>((resolve, reject) => {
52
- (server as any).once('error', reject);
53
- (server as any).listen(0, resolve);
54
- });
55
- expect((server as any).listening).toBeTruthy();
56
- const addr = (server as any).address();
57
- expect(addr).toBeDefined();
58
- expect(addr.port > 0).toBeTruthy();
59
- (server as any).close();
60
- });
61
-
62
- await it('close() stops listening', async () => {
63
- const server = http2.createServer();
64
- await new Promise<void>((res) => (server as any).listen(0, res));
65
- await new Promise<void>((res) => (server as any).close(res));
66
- expect((server as any).listening).toBeFalsy();
67
- });
68
- });
69
-
70
- await describe('http2 compat API: request event', async () => {
71
- await it('emits request with req and res objects', async () => {
72
- const { server, port } = await withServer((req, res) => {
73
- expect(req).toBeDefined();
74
- expect(res).toBeDefined();
75
- expect(typeof req.method).toBe('string');
76
- res.writeHead(200);
77
- res.end();
78
- });
79
-
80
- const session = http2.connect(`http://localhost:${port}`);
81
- const stream = session.request({ ':method': 'GET', ':path': '/' }, { endStream: true } as any);
82
-
83
- await new Promise<void>((resolve, reject) => {
84
- (stream as any).on('response', () => resolve());
85
- (stream as any).on('error', reject);
86
- setTimeout(() => reject(new Error('timeout')), 5000);
87
- });
88
-
89
- session.close();
90
- (server as any).close();
91
- });
92
-
93
- await it('req.method and req.url are populated', async () => {
94
- let capturedMethod = '';
95
- let capturedUrl = '';
96
-
97
- const { server, port } = await withServer((req, res) => {
98
- capturedMethod = req.method;
99
- capturedUrl = req.url;
100
- res.writeHead(204);
101
- res.end();
102
- });
103
-
104
- const session = http2.connect(`http://localhost:${port}`);
105
- const stream = session.request(
106
- { ':method': 'GET', ':path': '/hello?foo=bar' },
107
- { endStream: true } as any,
108
- );
109
-
110
- await new Promise<void>((resolve, reject) => {
111
- (stream as any).on('response', () => resolve());
112
- (stream as any).on('error', reject);
113
- setTimeout(() => reject(new Error('timeout')), 5000);
114
- });
115
-
116
- expect(capturedMethod).toBe('GET');
117
- expect(capturedUrl).toBe('/hello?foo=bar');
118
-
119
- session.close();
120
- (server as any).close();
121
- });
122
-
123
- await it('req.headers contains custom request headers', async () => {
124
- let capturedHeaders: Record<string, string | string[]> = {};
125
-
126
- const { server, port } = await withServer((req, res) => {
127
- capturedHeaders = req.headers;
128
- res.writeHead(200);
129
- res.end();
130
- });
131
-
132
- const session = http2.connect(`http://localhost:${port}`);
133
- const stream = session.request(
134
- { ':method': 'GET', ':path': '/', 'x-custom': 'test-value' },
135
- { endStream: true } as any,
136
- );
137
-
138
- await new Promise<void>((resolve, reject) => {
139
- (stream as any).on('response', () => resolve());
140
- (stream as any).on('error', reject);
141
- setTimeout(() => reject(new Error('timeout')), 5000);
142
- });
143
-
144
- expect(capturedHeaders['x-custom']).toBe('test-value');
145
-
146
- session.close();
147
- (server as any).close();
148
- });
149
- });
150
-
151
- await describe('http2 compat API: response body', async () => {
152
- await it('response body text is received by client stream', async () => {
153
- const { server, port } = await withServer((_req, res) => {
154
- res.writeHead(200, { 'content-type': 'text/plain' });
155
- res.end('Hello HTTP/2');
156
- });
157
-
158
- const session = http2.connect(`http://localhost:${port}`);
159
- const stream = session.request(
160
- { ':method': 'GET', ':path': '/' },
161
- { endStream: true } as any,
162
- );
163
-
164
- await new Promise<void>((resolve, reject) => {
165
- (stream as any).on('response', () => resolve());
166
- (stream as any).on('error', reject);
167
- setTimeout(() => reject(new Error('timeout')), 5000);
168
- });
169
- const body = await collectBody(stream);
170
-
171
- expect(body).toBe('Hello HTTP/2');
172
-
173
- session.close();
174
- (server as any).close();
175
- });
176
-
177
- await it(':status is included in response headers', async () => {
178
- const { server, port } = await withServer((_req, res) => {
179
- res.writeHead(201);
180
- res.end('created');
181
- });
182
-
183
- const session = http2.connect(`http://localhost:${port}`);
184
- const stream = session.request(
185
- { ':method': 'POST', ':path': '/items' },
186
- { endStream: true } as any,
187
- );
188
-
189
- let responseHeaders: Record<string, string | string[]> = {};
190
- await new Promise<void>((resolve, reject) => {
191
- (stream as any).on('response', (headers: any) => {
192
- responseHeaders = headers;
193
- resolve();
194
- });
195
- (stream as any).on('error', reject);
196
- setTimeout(() => reject(new Error('timeout')), 5000);
197
- });
198
- await collectBody(stream);
199
-
200
- expect(responseHeaders[':status']).toBe('201');
201
-
202
- session.close();
203
- (server as any).close();
204
- });
205
- });
206
-
207
- await describe('http2 compat API: request body', async () => {
208
- await it('server receives POST body via async iteration on req', async () => {
209
- let capturedBody = '';
210
-
211
- const { server, port } = await withServer(async (req, res) => {
212
- const chunks: Buffer[] = [];
213
- for await (const chunk of req) {
214
- chunks.push(chunk as Buffer);
215
- }
216
- capturedBody = Buffer.concat(chunks).toString('utf8');
217
- res.writeHead(200);
218
- res.end('ok');
219
- });
220
-
221
- const session = http2.connect(`http://localhost:${port}`);
222
- const stream = session.request({
223
- ':method': 'POST',
224
- ':path': '/upload',
225
- 'content-type': 'text/plain',
226
- });
227
-
228
- (stream as any).write('Hello');
229
- (stream as any).end(' World');
230
-
231
- await new Promise<void>((resolve, reject) => {
232
- (stream as any).on('response', () => resolve());
233
- (stream as any).on('error', reject);
234
- setTimeout(() => reject(new Error('timeout')), 5000);
235
- });
236
-
237
- expect(capturedBody).toBe('Hello World');
238
-
239
- session.close();
240
- (server as any).close();
241
- });
242
- });
243
-
244
- await describe('http2 session API: stream event', async () => {
245
- await it('server emits stream event with headers', async () => {
246
- let streamEventFired = false;
247
-
248
- const server = http2.createServer();
249
- (server as any).on('stream', (stream: any, headers: any) => {
250
- streamEventFired = true;
251
- expect(stream).toBeDefined();
252
- expect(typeof stream.respond).toBe('function');
253
- expect(typeof headers).toBe('object');
254
- stream.respond({ ':status': 200, 'content-type': 'text/plain' });
255
- stream.end('stream API response');
256
- });
257
-
258
- await new Promise<void>((res) => (server as any).listen(0, res));
259
- const port = (server as any).address()?.port ?? 0;
260
-
261
- const session = http2.connect(`http://localhost:${port}`);
262
- const stream = session.request(
263
- { ':method': 'GET', ':path': '/' },
264
- { endStream: true } as any,
265
- );
266
-
267
- const body = await new Promise<string>((resolve, reject) => {
268
- const chunks: Buffer[] = [];
269
- (stream as any).on('response', () => {});
270
- (stream as any).on('data', (chunk: Buffer) => chunks.push(chunk));
271
- (stream as any).on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
272
- (stream as any).on('error', reject);
273
- setTimeout(() => reject(new Error('timeout')), 5000);
274
- });
275
-
276
- expect(body).toBe('stream API response');
277
- expect(streamEventFired).toBeTruthy();
278
-
279
- session.close();
280
- (server as any).close();
281
- });
282
- });
283
-
284
- await describe('http2.connect()', async () => {
285
- await it('returns a session with request() method', async () => {
286
- const session = http2.connect('http://localhost:19999');
287
- expect(session).toBeDefined();
288
- expect(typeof session.request).toBe('function');
289
- expect(typeof session.close).toBe('function');
290
- session.close();
291
- });
292
-
293
- await it('session.request() returns a stream with on()', async () => {
294
- const session = http2.connect('http://localhost:19999');
295
- const stream = session.request({ ':method': 'GET', ':path': '/' });
296
- expect(stream).toBeDefined();
297
- expect(typeof (stream as any).on).toBe('function');
298
- session.close();
299
- });
300
- });
301
-
302
- });
303
- };