major: switch to http2

This commit is contained in:
Fedor Indutny 2026-03-31 17:47:19 -07:00 committed by GitHub
parent 2bf3aa95f0
commit d7a1b5852c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 2358 additions and 3342 deletions

View File

@ -24,6 +24,8 @@ jobs:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- name: Setup node.js
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
@ -31,13 +33,13 @@ jobs:
registry-url: 'https://registry.npmjs.org/'
- name: Install node_modules
run: npm ci
run: pnpm ci
- name: Lint
run: npm run lint
run: pnpm run lint
- name: Test
run: npm test
run: pnpm test
- name: Publish
run: npm publish --access public
run: pnpm publish --access public

View File

@ -16,16 +16,18 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- name: Setup node.js
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version-file: '.nvmrc'
- name: Install node_modules
run: npm install
run: pnpm install
- name: Run lint
run: npm run lint
run: pnpm run lint
- name: Run tests
run: npm test
run: pnpm test

3230
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,6 @@
{
"name": "@signalapp/mock-server",
"packageManager": "pnpm@10.18.1",
"version": "18.3.0",
"description": "Mock Signal Server for writing tests",
"main": "src/index.js",
@ -73,5 +74,10 @@
"mocha": "^9.2.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.44.1"
},
"pnpm": {
"patchedDependencies": {
"@types/ws": "patches/@types__ws.patch"
}
}
}

30
patches/@types__ws.patch Normal file
View File

@ -0,0 +1,30 @@
diff --git a/index.d.ts b/index.d.ts
index 6d08adc155873e948d2ffebf40622fe405159bc0..4041a625f51d84d719c878244aad2f74bc9791d9 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -10,6 +10,7 @@ import {
Server as HTTPServer,
} from "http";
import { Server as HTTPSServer } from "https";
+import { ServerHttp2Stream } from "http2";
import { createConnection } from "net";
import { Duplex, DuplexOptions } from "stream";
import { SecureContextOptions } from "tls";
@@ -76,7 +77,7 @@ declare class WebSocket extends EventEmitter {
onclose: ((event: WebSocket.CloseEvent) => void) | null;
onmessage: ((event: WebSocket.MessageEvent) => void) | null;
- constructor(address: null);
+ constructor(address: null, protocols: undefined, options: WebSocket.ClientOptions | ClientRequestArgs);
constructor(address: string | URL, options?: WebSocket.ClientOptions | ClientRequestArgs);
constructor(
address: string | URL,
@@ -84,6 +85,8 @@ declare class WebSocket extends EventEmitter {
options?: WebSocket.ClientOptions | ClientRequestArgs,
);
+ setSocket(socket: ServerHttp2Stream, head: Buffer, options: WebSocket.ClientOptions | ClientRequestArgs): void;
+
close(code?: number, data?: string | Buffer): void;
ping(data?: any, mask?: boolean, cb?: (err: Error) => void): void;
pong(data?: any, mask?: boolean, cb?: (err: Error) => void): void;

2214
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,12 @@ import fs from 'fs';
import fsPromises from 'fs/promises';
import { type Readable } from 'stream';
import path from 'path';
import https, { ServerOptions } from 'https';
import type { IncomingMessage, ServerResponse } from 'http';
import http2, {
SecureServerOptions,
Http2ServerRequest,
Http2ServerResponse,
} from 'http2';
import { parse as parseURL } from 'url';
import { PrivateKey, PublicKey } from '@signalapp/libsignal-client';
import {
@ -77,7 +82,7 @@ type ZKParams = Readonly<{
type StrictConfig = Readonly<{
trustRoot: TrustRoot;
zkParams: ZKParams;
https: ServerOptions;
https: SecureServerOptions;
timeout: number;
maxStorageReadKeys?: number;
cdn3Path?: string;
@ -87,7 +92,7 @@ type StrictConfig = Readonly<{
export type Config = Readonly<{
trustRoot?: TrustRoot;
zkParams?: ZKParams;
https?: ServerOptions;
https?: SecureServerOptions;
timeout?: number;
maxStorageReadKeys?: number;
cdn3Path?: string;
@ -178,7 +183,12 @@ export class Server extends BaseServer {
https: {
key: KEY,
cert: CERT,
allowHTTP1: true,
...(config.https ?? {}),
settings: {
...(config.https?.settings ?? {}),
enableConnectProtocol: true,
},
},
};
@ -223,63 +233,32 @@ export class Server extends BaseServer {
updates2Path: this.config.updates2Path,
});
const server = https.createServer(this.config.https, (req, res) => {
void run(req, res, httpHandler);
});
const server = http2
.createSecureServer(this.config.https, (req, res) => {
// micro is actually compatible with http2 requests, but the types are
// not.
void run(
req as unknown as IncomingMessage,
res as unknown as ServerResponse,
httpHandler,
);
})
.on('connect', (req: Http2ServerRequest, res: Http2ServerResponse) => {
// WebSocket
if (req.method === 'CONNECT') {
res.writeHead(200, this.wsUpgradeResponseHeaders);
const wss = new WebSocket.Server({
server,
// eslint-disable-next-line @typescript-eslint/no-misused-promises
verifyClient: async (info, callback) => {
const { url } = info.req;
assert(url, 'verifyClient: expected a URL on incoming request');
const { query } = parseURL(url, true);
const websocket = new WebSocket(null, undefined, {});
websocket.setSocket(req.stream, Buffer.alloc(0), {});
const conn = new WSConnection(req, websocket, this);
if (query.login == null && query.password == null) {
debug('verifyClient: Allowing connection with no credentials');
callback(true);
conn.start().catch((error: unknown) => {
websocket.close();
debug('Websocket handling error', error);
});
return;
}
// Note: when a device has been unlinked, it will use '' as its password
if (
query.login == null ||
Array.isArray(query.login) ||
typeof query.password !== 'string' ||
Array.isArray(query.password)
) {
debug('verifyClient: Malformed credentials @ %s: %j', url, query);
callback(false, 403);
return;
}
const device = await this.auth(query.login, query.password);
if (!device) {
debug('verifyClient: Invalid credentials @ %s: %j', url, query);
callback(false, 403);
return;
}
callback(true);
},
});
wss.on('connection', (ws, request) => {
const conn = new WSConnection(request, ws, this);
conn.start().catch((error: unknown) => {
ws.close();
debug('Websocket handling error', error);
});
});
wss.on('headers', (headers) => {
Object.entries(this.wsUpgradeResponseHeaders).forEach(
([header, value]) => {
headers.push(`${header}: ${value}`);
},
);
});
this.https = server;

View File

@ -26,7 +26,7 @@ import {
UuidCiphertext,
} from '@signalapp/libsignal-client/zkgroup';
import assert from 'assert';
import https from 'https';
import http2 from 'http2';
import crypto from 'crypto';
import createDebug from 'debug';
import { v4 as uuidv4 } from 'uuid';
@ -222,11 +222,7 @@ type StorageAuthEntry = Readonly<{
device: Device;
}>;
type MessageQueueEntry = {
readonly message: Buffer<ArrayBuffer>;
resolve: () => void;
reject: (error: Error) => void;
};
type MessageQueueEntry = (socket: WebSocket) => Promise<void>;
export type CallLinkEntry = Readonly<{
adminPasskey: Buffer<ArrayBuffer>;
@ -360,7 +356,7 @@ export abstract class Server {
protected privZKSecret: ServerSecretParams | undefined;
protected privGenericServerSecret: GenericServerSecretParams | undefined;
protected privBackupServerSecret: GenericServerSecretParams | undefined;
protected https: https.Server | undefined;
protected https: http2.Http2SecureServer | undefined;
public address(): AddressInfo {
if (!this.https) {
@ -807,7 +803,8 @@ export abstract class Server {
}
sockets.add(socket);
await this.sendQueue(device, socket);
// Don't wait for send to be over
void this.sendQueue(device, socket);
}
public removeWebSocket(device: Device, socket: WebSocket): void {
@ -862,21 +859,13 @@ export abstract class Server {
debug('queueing message for device=%s', target.debugId);
await new Promise<void>((resolve, reject) => {
// NOTE: set and push have to happen in the same tick, otherwise a race
// condition is possible in `removeWebSocket`.
let queue = this.messageQueue.get(target);
if (!queue) {
queue = [];
this.messageQueue.set(target, queue);
}
let queue = this.messageQueue.get(target);
if (!queue) {
queue = [];
this.messageQueue.set(target, queue);
}
queue.push({
message,
resolve,
reject,
});
});
queue.push((socket) => socket.sendMessage(message));
debug('queued message sent to device=%s', target.debugId);
}
@ -1756,24 +1745,13 @@ export abstract class Server {
}
debug('sending queued %d messages to %s', queue.length, device.debugId);
await Promise.all(
queue.map(async (entry) => {
const { message, resolve, reject } = entry;
try {
await socket.sendMessage(message);
} catch (error) {
assert(error instanceof Error);
reject(error);
return;
}
resolve();
}),
);
debug('queue for %s is empty', device.debugId);
await socket.sendMessage('empty');
try {
await Promise.all(
queue.map((fn) => fn(socket)).concat(socket.sendMessage('empty')),
);
} catch {
// Ignore errors, socket likely closed
}
}
private issueCredentials(

View File

@ -3,7 +3,7 @@
import assert from 'assert';
import { Buffer } from 'buffer';
import { IncomingMessage } from 'http';
import { Http2ServerRequest } from 'http2';
import { timingSafeEqual } from 'crypto';
import createDebug from 'debug';
import {
@ -64,10 +64,14 @@ const debug = createDebug('mock:ws:connection');
export class Connection extends Service {
private device: Device | undefined;
private readonly router = new Router();
private readonly router = new Router({
beforeRequest: (verb, path, headers) => {
return this.handleAuth(verb, path, headers);
},
});
constructor(
private readonly request: IncomingMessage,
private readonly request: Http2ServerRequest,
ws: WebSocket,
private readonly server: Server,
) {
@ -1011,10 +1015,9 @@ export class Connection extends Service {
}
if (path === '/v1/websocket/') {
return this.handleNormal(this.request);
} else {
debug('websocket connection has unexpected URL %s', url);
return;
}
debug('websocket connection has unexpected URL %s', url);
}
public async sendMessage(
@ -1067,8 +1070,17 @@ export class Connection extends Service {
}
}
private async handleNormal(incomingMessage: IncomingMessage) {
const authHeaders = incomingMessage.headers.authorization;
private async handleAuth(
verb: string,
path: string,
headers: Record<string, string>,
) {
// We are actively registering device
if (verb === 'PUT' && path === '/v1/devices/link') {
return;
}
const authHeaders = headers.authorization;
if (!authHeaders) {
debug('Websocket connection does not include Authorization header');
return;
@ -1088,7 +1100,7 @@ export class Connection extends Service {
const device = await this.server.auth(username, password);
if (!device) {
debug('Invalid WebSocket credentials @ %s: %j', incomingMessage.url, {
debug('Invalid WebSocket credentials @ %j', {
username,
password,
});
@ -1096,6 +1108,11 @@ export class Connection extends Service {
return;
}
if (this.device !== undefined) {
assert.strictEqual(this.device, device, 'Cannot change active device');
return;
}
this.device = device;
this.router.setIsAuthenticated(true);

View File

@ -32,11 +32,21 @@ type Route = Readonly<{
handler: Handler;
}>;
export type RouterOptions = Readonly<{
beforeRequest: (
verb: string,
path: string,
headers: Record<string, string>,
) => Promise<void>;
}>;
export class Router {
private readonly routes: Array<Route> = [];
private isAuthenticated = false;
constructor(private options: RouterOptions) {}
public register(method: string, pattern: string, handler: Handler): void {
this.routes.push({
method,
@ -83,6 +93,12 @@ export class Router {
// eslint-disable-next-line @typescript-eslint/no-deprecated
const { pathname, query } = parseURL(request.path ?? '');
await this.options.beforeRequest(
request.verb ?? '',
pathname ?? '',
headers,
);
for (const { method, pattern, handler } of this.routes) {
if (method !== request.verb) {
continue;

View File

@ -35,7 +35,9 @@ export abstract class Service {
debug('onMessage error', error.stack);
}
});
this.ws.once('close', () => this.onClose());
this.ws.once('close', () => {
this.onClose();
});
}
public async send(