websocket-connection.integ.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. import http from 'node:http';
  2. import WebSocket, { WebSocketServer } from 'ws';
  3. import { docs, setPersistence, setupWSConnection } from 'y-websocket/bin/utils';
  4. /**
  5. * Creates a minimal HTTP + y-websocket server for testing.
  6. * No authentication — pure document sync testing.
  7. */
  8. const createTestServer = (): { server: http.Server; wss: WebSocketServer } => {
  9. const server = http.createServer();
  10. const wss = new WebSocketServer({ noServer: true });
  11. server.on('upgrade', (request, socket, head) => {
  12. const url = request.url ?? '';
  13. if (!url.startsWith('/yjs/')) return;
  14. const pageId = url.slice('/yjs/'.length).split('?')[0];
  15. wss.handleUpgrade(request, socket, head, (ws) => {
  16. wss.emit('connection', ws, request);
  17. setupWSConnection(ws, request, { docName: pageId });
  18. });
  19. });
  20. return { server, wss };
  21. };
  22. /**
  23. * Connects a WebSocket client and waits for the connection to open.
  24. */
  25. const connectClient = (port: number, pageId: string): Promise<WebSocket> => {
  26. return new Promise((resolve, reject) => {
  27. const ws = new WebSocket(`ws://127.0.0.1:${port}/yjs/${pageId}`);
  28. ws.binaryType = 'arraybuffer';
  29. ws.on('open', () => resolve(ws));
  30. ws.on('error', reject);
  31. });
  32. };
  33. /**
  34. * Waits for a WebSocket to fully close.
  35. */
  36. const waitForClose = (ws: WebSocket): Promise<void> => {
  37. return new Promise((resolve) => {
  38. if (ws.readyState === WebSocket.CLOSED) return resolve();
  39. ws.on('close', () => resolve());
  40. });
  41. };
  42. describe('WebSocket Connection and Sync Flow', () => {
  43. let server: http.Server;
  44. let wss: WebSocketServer;
  45. let port: number;
  46. beforeAll(async () => {
  47. setPersistence(null);
  48. const testServer = createTestServer();
  49. server = testServer.server;
  50. wss = testServer.wss;
  51. await new Promise<void>((resolve) => {
  52. server.listen(0, '127.0.0.1', () => {
  53. const addr = server.address();
  54. if (addr && typeof addr === 'object') {
  55. port = addr.port;
  56. }
  57. resolve();
  58. });
  59. });
  60. });
  61. afterAll(async () => {
  62. for (const [name, doc] of docs) {
  63. doc.destroy();
  64. docs.delete(name);
  65. }
  66. await new Promise<void>((resolve) => {
  67. wss.close(() => {
  68. server.close(() => resolve());
  69. });
  70. });
  71. });
  72. afterEach(() => {
  73. for (const [name, doc] of docs) {
  74. doc.destroy();
  75. docs.delete(name);
  76. }
  77. });
  78. describe('Connection and sync flow', () => {
  79. it('should create a server-side Y.Doc on first client connection', async () => {
  80. const pageId = 'test-page-sync-001';
  81. const ws = await connectClient(port, pageId);
  82. // Wait for setupWSConnection to register the doc
  83. await new Promise((resolve) => setTimeout(resolve, 50));
  84. const serverDoc = docs.get(pageId);
  85. assert(serverDoc !== undefined);
  86. expect(serverDoc.name).toBe(pageId);
  87. expect(serverDoc.conns.size).toBe(1);
  88. ws.close();
  89. });
  90. it('should register multiple clients on the same server-side Y.Doc', async () => {
  91. const pageId = 'test-page-multi-001';
  92. const ws1 = await connectClient(port, pageId);
  93. const ws2 = await connectClient(port, pageId);
  94. await new Promise((resolve) => setTimeout(resolve, 50));
  95. const serverDoc = docs.get(pageId);
  96. assert(serverDoc !== undefined);
  97. expect(serverDoc.conns.size).toBe(2);
  98. ws1.close();
  99. ws2.close();
  100. });
  101. it('should keep the server doc alive when one client disconnects', async () => {
  102. const pageId = 'test-page-reconnect-001';
  103. const ws1 = await connectClient(port, pageId);
  104. const ws2 = await connectClient(port, pageId);
  105. await new Promise((resolve) => setTimeout(resolve, 50));
  106. // Disconnect client 1
  107. ws1.close();
  108. await waitForClose(ws1);
  109. await new Promise((resolve) => setTimeout(resolve, 50));
  110. // Server doc should still exist with client 2
  111. const serverDoc = docs.get(pageId);
  112. assert(serverDoc !== undefined);
  113. expect(serverDoc.conns.size).toBe(1);
  114. ws2.close();
  115. });
  116. });
  117. describe('Concurrency — single Y.Doc per page', () => {
  118. it('should create exactly one Y.Doc for simultaneous connections', async () => {
  119. const pageId = 'test-page-concurrent-001';
  120. // Connect multiple clients simultaneously
  121. const connections = await Promise.all([
  122. connectClient(port, pageId),
  123. connectClient(port, pageId),
  124. connectClient(port, pageId),
  125. ]);
  126. await new Promise((resolve) => setTimeout(resolve, 50));
  127. // Verify single Y.Doc instance
  128. const serverDoc = docs.get(pageId);
  129. assert(serverDoc !== undefined);
  130. expect(serverDoc.conns.size).toBe(3);
  131. // Only one doc for this page
  132. const matchingDocs = Array.from(docs.values()).filter(
  133. (d) => d.name === pageId,
  134. );
  135. expect(matchingDocs).toHaveLength(1);
  136. for (const ws of connections) {
  137. ws.close();
  138. }
  139. });
  140. it('should handle disconnect during connect without document corruption', async () => {
  141. const pageId = 'test-page-disconnect-001';
  142. // Client 1 connects
  143. const ws1 = await connectClient(port, pageId);
  144. await new Promise((resolve) => setTimeout(resolve, 50));
  145. // Write to server doc directly
  146. const serverDoc = docs.get(pageId);
  147. assert(serverDoc !== undefined);
  148. serverDoc.getText('codemirror').insert(0, 'Hello World');
  149. // Client 2 connects and immediately disconnects
  150. const ws2 = await connectClient(port, pageId);
  151. ws2.close();
  152. await waitForClose(ws2);
  153. await new Promise((resolve) => setTimeout(resolve, 50));
  154. // Server doc should still exist with client 1
  155. const docAfter = docs.get(pageId);
  156. assert(docAfter !== undefined);
  157. expect(docAfter.conns.size).toBe(1);
  158. // Text should be intact
  159. expect(docAfter.getText('codemirror').toString()).toBe('Hello World');
  160. ws1.close();
  161. });
  162. });
  163. });